From fd877a60ce916c9d090f7806409f5ea17aefa2ba Mon Sep 17 00:00:00 2001 From: maximthomas Date: Tue, 17 Mar 2026 16:10:05 +0300 Subject: [PATCH 1/7] OpenAPI/Swagger routes loader and request/response validator --- openig-core/pom.xml | 9 +- .../openig/alias/CoreClassAliasResolver.java | 2 + .../filter/OpenApiValidationFilter.java | 203 +++++ .../handler/router/DirectoryMonitor.java | 16 +- .../handler/router/OpenApiRouteBuilder.java | 273 +++++++ .../handler/router/OpenApiSpecLoader.java | 121 +++ .../openig/handler/router/RouterHandler.java | 70 +- .../filter/OpenApiValidationFilterTest.java | 231 ++++++ .../router/OpenApiRouteBuilderTest.java | 330 ++++++++ .../handler/router/OpenApiSpecLoaderTest.java | 195 +++++ .../handler/router/RouterHandlerTest.java | 107 +++ openig-war/pom.xml | 41 +- .../test/integration/IT_SwaggerRoute.java | 136 ++++ .../test/resources/routes/01-find-pet.json | 24 + .../src/test/resources/routes/petstore.yaml | 741 ++++++++++++++++++ 15 files changed, 2487 insertions(+), 12 deletions(-) create mode 100644 openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java create mode 100644 openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiRouteBuilder.java create mode 100644 openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiSpecLoader.java create mode 100644 openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java create mode 100644 openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java create mode 100644 openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiSpecLoaderTest.java create mode 100644 openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java create mode 100644 openig-war/src/test/resources/routes/01-find-pet.json create mode 100644 openig-war/src/test/resources/routes/petstore.yaml 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-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..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/filter/OpenApiValidationFilter.java b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java new file mode 100644 index 000000000..647406c69 --- /dev/null +++ b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java @@ -0,0 +1,203 @@ +/* + * 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.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.stream.Collectors; + +/** + * Validates HTTP requests and responses against an + * OpenAPI (Swagger 2.x / OpenAPI 3.x) specification + * + *

Request validation

+ *

If the request fails validation the filter returns a {@code 400 Bad Request} response + * immediately, without forwarding the request downstream. The response body is a plain-text + * list of validation messages. + * + *

Response validation

+ *

After the downstream handler returns a response, the filter validates it against the spec. + * Behaviour on failure is controlled by the {@code failOnResponseViolation} configuration flag: + *

    + *
  • {@code true} – return a {@code 502 Bad Gateway} with the validation messages.
  • + *
  • {@code false} (default) – log a warning and pass the response through unchanged.
  • + *
+ * + *

Heap configuration

+ *
{@code
+ * {
+ *   "name": "myValidator",
+ *   "type": "OpenApiValidationFilter",
+ *   "config": {
+ *     "specFile": "/path/to/openapi.yaml",
+ *     "failOnResponseViolation": false
+ *   }
+ * }
+ * }
+ */ +public class OpenApiValidationFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(OpenApiValidationFilter.class); + + private final OpenApiInteractionValidator validator; + + private final boolean failOnResponseViolation; + + /** + * 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 502} error; if {@code false}, it is only logged + */ + private OpenApiValidationFilter(String spec, boolean failOnResponseViolation) { + this(OpenApiInteractionValidator.createForInlineApiSpecification(spec).build(), failOnResponseViolation); + } + + OpenApiValidationFilter(OpenApiInteractionValidator validator, boolean failOnResponseViolation) { + this.validator = validator; + this.failOnResponseViolation = failOnResponseViolation; + } + + @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"); + 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 Promises.newResultPromise( + buildErrorResponse(Status.BAD_REQUEST, "Request validation failed:\n" + requestReport)); + } + + 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"); + 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) { + return buildErrorResponse (Status.BAD_GATEWAY, "Response validation failed:\n" + responseValidationReport); + } + } + return response; + }); + } + + 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 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(); + + return new OpenApiValidationFilter(openApiSpec, failOnResponseViolation); + + } + } +} 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..1caca41d6 --- /dev/null +++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiRouteBuilder.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.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) + * @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) { + 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: {})", + routeName, specFile.getName(), condition, baseUri != null ? baseUri : ""); + + // ----- heap: one OpenApiValidationFilter entry ----- + final Map validatorConfig = new LinkedHashMap<>(); + validatorConfig.put("spec", "${read('" + specFile.getAbsolutePath() + "')}"); + validatorConfig.put("failOnResponseViolation", false); + + 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) { + final Map decoratorMap = new LinkedHashMap<>(); + decoratorMap.put("baseURI", baseUri); + 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. + * + *

Transformation rules (applied in order): + *

    + *
  1. Literal {@code .} → {@code \.} (escape regex metachar)
  2. + *
  3. Literal {@code +} → {@code \+} (escape regex metachar)
  4. + *
  5. {@code {paramName}} → {@code [^/]+} (path parameter → non-slash segment)
  6. + *
  7. Prepend {@code ^}, append {@code $} (full-path anchor)
  8. + *
+ * + *

Examples: + *

    + *
  • {@code /pets} → {@code ^/pets$}
  • + *
  • {@code /pets/{id}} → {@code ^/pets/[^/]+$}
  • + *
  • {@code /a.b/{x}/c} → {@code ^/a\.b/[^/]+/c$}
  • + *
  • {@code /v1/{org}/{repo}/releases} → {@code ^/v1/[^/]+/[^/]+/releases$}
  • + *
+ * + * @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: + *

    + *
  1. File extension must be {@code .json}, {@code .yaml}, or {@code .yml}.
  2. + *
  3. The parsed root object must contain an {@code openapi} key (OAS 3.x) or + * a {@code swagger} key (Swagger 2.x).
  4. + *
+ * + *

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..2fe9b222f 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,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; @@ -38,8 +39,10 @@ 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; @@ -112,6 +116,18 @@ 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; + + /** + * 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 +160,19 @@ 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()); + } + + protected RouterHandler(final RouteBuilder builder, final DirectoryMonitor directoryMonitor, + final OpenApiSpecLoader openApiSpecLoader, OpenApiRouteBuilder openApiRouteBuilder) { this.builder = builder; this.directoryMonitor = directoryMonitor; ReadWriteLock lock = new ReentrantReadWriteLock(); this.read = lock.readLock(); this.write = lock.writeLock(); + + this.openApiSpecLoader = openApiSpecLoader; + this.openApiRouteBuilder = openApiRouteBuilder; } /** @@ -392,6 +416,41 @@ public void onChanges(FileChangeSet changes) { } private void onAddedFile(File file) { + if(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); + final String routeId = routeJson.get("name").asString(); + final String routeName = routeId; + + try { + load(routeId, routeName, 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 +465,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); } } 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..e3c3eeb41 --- /dev/null +++ b/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java @@ -0,0 +1,231 @@ +/* + * 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.File; +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_returns502_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.BAD_GATEWAY); + 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..1b939be79 --- /dev/null +++ b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java @@ -0,0 +1,330 @@ +/* + * 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 heap = build(spec).get("heap").asList(); + assertThat(heap).isNotEmpty(); + + final boolean hasValidator = heap.stream() + .filter(o -> o instanceof java.util.Map) + .map(o -> (java.util.Map) o) + .anyMatch(m -> "OpenApiValidationFilter".equals(m.get("type"))); + assertThat(hasValidator).isTrue(); + } + + @Test + public void buildRouteJson_validatorHeapObjectHasAbsoluteSpecFilePath() throws IOException { + final File spec = writeYaml("petstore.yaml", specWithPaths("/pets")); + final JsonValue route = build(spec); + + final java.util.Map validatorEntry = route.get("heap").asList().stream() + .filter(o -> o instanceof java.util.Map) + .map(o -> (java.util.Map) o) + .filter(m -> "OpenApiValidationFilter".equals(m.get("type"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No OpenApiValidationFilter in heap")); + + final java.util.Map config = (java.util.Map) validatorEntry.get("config"); + assertThat(config.get("spec").toString()).contains(spec.getAbsolutePath()); + } + + @Test + public void buildRouteJson_handlerIsChainWithClientHandler() throws IOException { + final File spec = writeYaml("petstore.yaml", specWithPaths("/pets")); + final JsonValue handler = build(spec).get("handler"); + assertThat(handler.get("type").asString()).isEqualTo("Chain"); + assertThat(handler.get("config").get("handler").asString()).isEqualTo("ClientHandler"); + } + + @Test + public void buildRouteJson_chainFiltersContainValidatorName() throws IOException { + final File spec = writeYaml("petstore.yaml", specWithPaths("/pets")); + final List filters = build(spec).get("handler") + .get("config").get("filters").asList(String.class); + assertThat(filters).isNotEmpty(); + assertThat(filters.stream().anyMatch(f -> f.toLowerCase().contains("validator") + || f.toLowerCase().contains("openapi"))).isTrue(); + } + + // ---- baseURI --------------------------------------------------------- + + @Test + public void buildRouteJson_setsBaseUri_whenSpecHasServer() throws IOException { + final File spec = writeYaml("with-server.yaml", + "openapi: '3.0.3'\n" + + "info:\n" + + " title: API\n" + + " version: '1'\n" + + "servers:\n" + + " - url: 'https://api.example.com/v2'\n" + + "paths:\n" + + " /items:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: OK\n"); + + assertThat(build(spec).get("baseURI").asString()).isEqualTo("https://api.example.com/v2"); + } + + @Test + public void buildRouteJson_hasNoBaseUri_whenSpecHasNoServer() throws IOException { + final File spec = writeYaml("no-server.yaml", specWithPaths("/pets")); + assertThat(build(spec).get("baseURI").isNull()).isTrue(); + } + + + + private File writeYaml(final String name, final String content) throws IOException { + final File file = new File(tempDir, name); + Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); + return file; + } + + private JsonValue build(final File specFile) { + final Optional api = specLoader.tryLoad(specFile); + assertThat(api.isPresent()).as("Expected spec file to parse successfully: " + specFile).isTrue(); + return routeBuilder.buildRouteJson(api.get(), specFile); + } + + private static String specWithPaths(final String... paths) { + final StringBuilder sb = new StringBuilder() + .append("openapi: '3.0.3'\n") + .append("info:\n") + .append(" title: Test API\n") + .append(" version: '1.0.0'\n") + .append("paths:\n"); + for (final String path : paths) { + sb.append(" ").append(path).append(":\n"); + sb.append(" get:\n"); + sb.append(" responses:\n"); + sb.append(" '200':\n"); + sb.append(" description: OK\n"); + } + return sb.toString(); + } + + private static long countOccurrences(final String text, final String substring) { + long count = 0; + int idx = 0; + while ((idx = text.indexOf(substring, idx)) != -1) { + count++; + idx += substring.length(); + } + return count; + } +} diff --git a/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiSpecLoaderTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiSpecLoaderTest.java new file mode 100644 index 000000000..92679727c --- /dev/null +++ b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiSpecLoaderTest.java @@ -0,0 +1,195 @@ +/* + * 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 org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OpenApiSpecLoaderTest { + + private File tempDir; + + private OpenApiSpecLoader loader; + + @BeforeMethod + public void setUp() throws IOException { + tempDir = Files.createTempDirectory("openig-spec-loader-test").toFile(); + loader = new OpenApiSpecLoader(); + } + + @AfterMethod + public void tearDown() { + deleteRecursively(tempDir); + } + + @Test + public void isOpenApiFile_returnsFalse_forNullFile() { + assertThat(loader.isOpenApiFile(null)).isFalse(); + } + + @Test + public void isOpenApiFile_returnsFalse_forRegularOpenIGRouteJson() throws IOException { + final File f = write("route.json", + "{ \"name\": \"my-route\", \"handler\": { \"type\": \"ClientHandler\" } }"); + assertThat(loader.isOpenApiFile(f)).isFalse(); + } + + @Test + public void isOpenApiFile_returnsTrue_forOpenApi3JsonFile() throws IOException { + final File f = write("petstore.json", minimalOpenApi3Json()); + assertThat(loader.isOpenApiFile(f)).isTrue(); + } + + @Test + public void isOpenApiFile_returnsTrue_forOpenApi3YamlFile() throws IOException { + final File f = write("petstore.yaml", minimalOpenApi3Yaml()); + assertThat(loader.isOpenApiFile(f)).isTrue(); + } + + @Test + public void isOpenApiFile_returnsTrue_forYmlExtension() throws IOException { + final File f = write("petstore.yml", minimalOpenApi3Yaml()); + assertThat(loader.isOpenApiFile(f)).isTrue(); + } + + @Test + public void isOpenApiFile_returnsFalse_forUnsupportedExtension() throws IOException { + final File f = write("spec.xml", ""); + assertThat(loader.isOpenApiFile(f)).isFalse(); + } + + @Test + public void isOpenApiFile_returnsFalse_forEmptyFile() throws IOException { + final File f = write("empty.json", ""); + assertThat(loader.isOpenApiFile(f)).isFalse(); + } + + @Test + public void isOpenApiFile_returnsFalse_forMalformedJson() throws IOException { + final File f = write("broken.json", "{ not valid json }}}"); + assertThat(loader.isOpenApiFile(f)).isFalse(); + } + + @Test + public void isOpenApiFile_returnsTrue_forSwagger2JsonFile() throws IOException { + final File f = write("swagger2.json", + "{ \"swagger\": \"2.0\", \"info\": { \"title\": \"T\", \"version\": \"1\" }," + + " \"paths\": {}, \"basePath\": \"/\" }"); + assertThat(loader.isOpenApiFile(f)).isTrue(); + } + + // ---- tryLoad --------------------------------------------------------- + + @Test + public void tryLoad_returnsEmpty_forNonOpenApiFile() throws IOException { + final File f = write("route.json", + "{ \"name\": \"r\", \"handler\": { \"type\": \"ClientHandler\" } }"); + assertThat(loader.tryLoad(f).isEmpty()).isTrue(); + } + + @Test + public void tryLoad_returnsOpenAPI_forValidOpenApi3Json() throws IOException { + final File f = write("petstore.json", minimalOpenApi3Json()); + final Optional result = loader.tryLoad(f); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getInfo().getTitle()).isEqualTo("Petstore"); + } + + @Test + public void tryLoad_returnsOpenAPI_forValidOpenApi3Yaml() throws IOException { + final File f = write("petstore.yaml", minimalOpenApi3Yaml()); + final Optional result = loader.tryLoad(f); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getInfo().getTitle()).isEqualTo("Petstore"); + } + + @Test + public void tryLoad_populatesPaths() throws IOException { + final File f = write("petstore.yaml", minimalOpenApi3Yaml()); + final Optional result = loader.tryLoad(f); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getPaths()).containsKey("/pets"); + } + + @Test + public void tryLoad_doesNotThrow_forMalformedSpec() throws IOException { + final File f = write("bad.yaml", "openapi: 3.0.0\ninfo: ~\nrandom garbage: !!!"); + assertThat(loader.tryLoad(f).isEmpty()).isTrue(); + } + + private File write(final String name, final String content) throws IOException { + final File file = new File(tempDir, name); + Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); + return file; + } + + private static void deleteRecursively(final File dir) { + if (dir == null || !dir.exists()) { + return; + } + final File[] children = dir.listFiles(); + if (children != null) { + for (final File child : children) { + deleteRecursively(child); + } + } + dir.delete(); + } + + private static String minimalOpenApi3Json() { + return "{\n" + + " \"openapi\": \"3.0.3\",\n" + + " \"info\": { \"title\": \"Petstore\", \"version\": \"1.0.0\" },\n" + + " \"paths\": {\n" + + " \"/pets\": {\n" + + " \"get\": {\n" + + " \"summary\": \"List all pets\",\n" + + " \"operationId\": \"listPets\",\n" + + " \"responses\": {\n" + + " \"200\": { \"description\": \"OK\" }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + } + + private static String minimalOpenApi3Yaml() { + return "openapi: \"3.0.3\"\n" + + "info:\n" + + " title: Petstore\n" + + " version: \"1.0.0\"\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " summary: List all pets\n" + + " operationId: listPets\n" + + " responses:\n" + + " '200':\n" + + " description: OK\n"; + } +} \ No newline at end of file diff --git a/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java index 3ffa3a88f..fbe734273 100644 --- a/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java +++ b/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.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; @@ -28,6 +29,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -36,11 +38,15 @@ import java.io.IOException; import java.net.URISyntaxException; import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import io.swagger.v3.oas.models.OpenAPI; import org.assertj.core.api.iterable.Extractor; import org.forgerock.http.Handler; import org.forgerock.http.protocol.Request; @@ -80,6 +86,15 @@ public class RouterHandlerTest { @Mock private Logger logger; + @Mock + private OpenApiSpecLoader mockSpecLoader; + + @Mock + private OpenApiRouteBuilder mockOpenApiRouteBuilder; + + @Mock + private RouteBuilder mockRouteBuilder; + private File routes; @BeforeMethod @@ -295,6 +310,80 @@ public void testScheduleOnlyOnceDirectoryMonitoring() throws Exception { verifyNoMoreInteractions(scheduledExecutorService); } + // OpenAPI tests + + @Test + public void onChanges_deploysRoute_whenOpenApiSpecFileIsAdded() throws Exception { + final File tmpDir = mock(File.class); + final File specFile = mock(File.class); + final OpenAPI fakeSpec = new OpenAPI(); + final JsonValue routeJson = buildFakeRouteJson("petstore"); + final Route mockRoute = mockRoute("petstore"); + + when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); + when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec)); + when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile)).thenReturn(routeJson); + when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute); + + RouterHandler handler = new RouterHandler( + mockRouteBuilder, + new DirectoryMonitor(routes), + mockSpecLoader, + mockOpenApiRouteBuilder); + FileChangeSet fileChangeSet = new FileChangeSet(tmpDir, Set.of(specFile), Set.of(), Set.of()); + handler.onChanges(fileChangeSet); + + verify(mockSpecLoader).tryLoad(specFile); + verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile); + verify(mockRouteBuilder).build(any(), any(), any()); + } + + @Test + public void onChanges_doesNotDeployRoute_whenSpecFileFails() throws Exception { + final File tmpDir = mock(File.class); + final File brokenSpecFile = mock(File.class); + + when(mockSpecLoader.isOpenApiFile(brokenSpecFile)).thenReturn(true); + when(mockSpecLoader.tryLoad(brokenSpecFile)).thenReturn(Optional.empty()); + + FileChangeSet fileChangeSet = new FileChangeSet(tmpDir, Set.of(brokenSpecFile), Set.of(), Set.of()); + RouterHandler handler = new RouterHandler( + mockRouteBuilder, + new DirectoryMonitor(routes), + mockSpecLoader, + mockOpenApiRouteBuilder); + handler.onChanges(fileChangeSet); + + verify(mockRouteBuilder, never()).build(any(), any(), any()); + } + + @Test + public void stop_destroysAllRoutes() throws Exception { + final File tmpDir = mock(File.class); + final File specFile = mock(File.class); + final OpenAPI fakeSpec = new OpenAPI(); + final JsonValue routeJson = buildFakeRouteJson("petstore"); + final Route mockRoute = mockRoute("petstore"); + + DirectoryMonitor directoryMonitor = new DirectoryMonitor(routes); + + when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); + when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec)); + when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile)).thenReturn(routeJson); + when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute); + RouterHandler handler = new RouterHandler( + mockRouteBuilder, + directoryMonitor, + mockSpecLoader, + mockOpenApiRouteBuilder); + + FileChangeSet fileChangeSet = new FileChangeSet(tmpDir, Set.of(specFile), Set.of(), Set.of()); + handler.onChanges(fileChangeSet); + handler.stop(); + + verify(mockRoute).destroy(); + } + private void assertStatusOnUri(Handler router, String uri, Status expected) throws URISyntaxException, ExecutionException, InterruptedException { Request ping = new Request().setMethod("GET"); @@ -324,4 +413,22 @@ private static File endpointsDirectory() throws IOException { return getRelativeDirectory(RouteBuilderTest.class, "endpoints"); } + private static JsonValue buildFakeRouteJson(final String name) { + return JsonValue.json(JsonValue.object( + JsonValue.field("name", name), + JsonValue.field("handler", JsonValue.object( + JsonValue.field("type", "Chain"), + JsonValue.field("config", JsonValue.object( + JsonValue.field("filters", List.of()), + JsonValue.field("handler", "ClientHandler"))))))); + } + + private static Route mockRoute(final String id) { + final Route r = mock(Route.class); + when(r.getId()).thenReturn(id); + when(r.accept(any(), any())).thenReturn(false); + return r; + } + + } diff --git a/openig-war/pom.xml b/openig-war/pom.xml index b0e27b0b3..abc20ceba 100644 --- a/openig-war/pom.xml +++ b/openig-war/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 @@ -31,6 +31,7 @@ ${project.build.directory}/${project.build.finalName}/WEB-INF/legal-notices tomcat10x + ${basedir}/target/config @@ -116,6 +117,24 @@ assertj-core test + + org.awaitility + awaitility + 4.3.0 + test + + + io.rest-assured + rest-assured + 5.5.7 + test + + + org.wiremock + wiremock + 3.13.2 + test + org.openidentityplatform.openig openig-ui @@ -173,6 +192,25 @@ + + org.apache.maven.plugins + maven-failsafe-plugin + + false + + ${test.config.path} + + + + + + integration-test + + integration-test + integration-test + + + org.codehaus.cargo cargo-maven3-plugin @@ -224,6 +262,7 @@ on true + ${basedir}/target/config diff --git a/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java b/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java new file mode 100644 index 000000000..933435958 --- /dev/null +++ b/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.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.openidentityplatrform.openig.test.integration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import org.apache.commons.io.IOUtils; +import org.forgerock.openig.handler.router.OpenApiRouteBuilder; +import org.hamcrest.Matchers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Objects; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; + +public class IT_SwaggerRoute { + + private static final Logger logger = LoggerFactory.getLogger(OpenApiRouteBuilder.class); + + private WireMockServer wireMockServer; + @BeforeClass + public void setupWireMock() { + wireMockServer = new WireMockServer(WireMockConfiguration.options().port(8090)); + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + + stubFor(get(urlPathEqualTo("/v2/pet/findByStatus")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("[\n" + + "{\"id\": 1, \"name\": \"Bella\", \"status\": \"available\"},\n" + + "{\"id\": 2, \"name\": \"Charlie\", \"status\": \"available\"}\n" + + "]"))); + } + + @AfterClass + public void tearDownWireMock() { + wireMockServer.stop(); + } + + @Test + public void testSwaggerRoute() throws IOException { + String testConfigPath = getTestConfigPath(); + Path destination = Path.of(testConfigPath.concat("/config/routes/petstore.yaml")); + + try(InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("routes/petstore.yaml")) { + assert inputStream != null; + Files.createDirectories(destination.getParent()); + Files.copy(inputStream, destination, StandardCopyOption.REPLACE_EXISTING); + } + testPetRoute("openapi-petstore", destination); + } + + @Test + public void testCustomRoute() throws IOException { + String testConfigPath = getTestConfigPath(); + String openApiSpec = this.getClass().getClassLoader().getResource("routes/petstore.yaml").getPath(); + String routeContents = IOUtils.resourceToString("routes/01-find-pet.json", StandardCharsets.UTF_8, this.getClass().getClassLoader()) + .replace("$$SWAGGER_FILE$$", openApiSpec); + + Path destination = Path.of(testConfigPath.concat("/config/routes/01-find-pet.json")); + Files.createDirectories(destination.getParent()); + Files.writeString(destination, routeContents); + + testPetRoute("01-find-pet", destination); + } + + private void testPetRoute(String routeId, Path destination) throws IOException { + await().pollInterval(3, SECONDS) + .atMost(15, SECONDS).until(() -> routeAvailable(routeId)); + + RestAssured + .given().when().get("/v2/pet/findByStatus?status=available") + .then() + .statusCode(200) + .body("[0].id", Matchers.equalTo(1)); + + Files.delete(destination); + + await().pollInterval(3, SECONDS) + .atMost(15, SECONDS).until(() -> !routeAvailable(routeId)); + + RestAssured + .given().when().get("/v2/pet/findByStatus?status=available") + .then() + .statusCode(404); + } + + public boolean routeAvailable(String routeId) { + Response response = RestAssured + .given().when(). + get("/openig/api/system/objects/_router/routes?_queryFilter=true"); + + String res = response.then().extract().path("result[0]._id"); + return Objects.equals(res, routeId); + } + + private String getTestConfigPath() { + return System.getProperty("test.config.path"); + } + + +} diff --git a/openig-war/src/test/resources/routes/01-find-pet.json b/openig-war/src/test/resources/routes/01-find-pet.json new file mode 100644 index 000000000..50e1e44d6 --- /dev/null +++ b/openig-war/src/test/resources/routes/01-find-pet.json @@ -0,0 +1,24 @@ +{ + "name": "swagger-petstore-test", + "condition": "${(matches(request.uri.path, '^/v2/pet/findByStatus$') && matches(request.method, '^GET$'))}", + "baseURI": "http://localhost:8090/v2", + "heap": [ + { + "name": "OpenApiValidator", + "type": "OpenApiValidationFilter", + "config": { + "spec": "${read('$$SWAGGER_FILE$$')}", + "failOnResponseViolation": false + } + } + ], + "handler": { + "type": "Chain", + "config": { + "filters": [ + "OpenApiValidator" + ], + "handler": "ClientHandler" + } + } +} \ No newline at end of file diff --git a/openig-war/src/test/resources/routes/petstore.yaml b/openig-war/src/test/resources/routes/petstore.yaml new file mode 100644 index 000000000..0e06e5fd0 --- /dev/null +++ b/openig-war/src/test/resources/routes/petstore.yaml @@ -0,0 +1,741 @@ +openapi: 3.0.0 +servers: + - url: 'http://localhost:8090/v2' +info: + description: >- + This is a sample server Petstore server. For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + externalDocs: + url: "http://petstore.swagger.io/v2/doc/updatePet" + description: "API documentation for the updatePet operation" + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + deprecated: true + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'read:pets' + deprecated: true + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid Order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: order placed for purchasing the pet + required: true + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Created user object + required: true + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: + Set-Cookie: + description: >- + Cookie authentication key for use with the `api_key` + apiKey authentication. + schema: + type: string + example: AUTH_KEY=abcde12345; Path=/; HttpOnly + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + responses: + default: + description: successful operation + security: + - api_key: [] + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Updated user object + required: true + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + security: + - api_key: [] +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + required: true + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + schemas: + Order: + title: Pet Order + description: An order for a pets from the pet store + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + title: Pet category + description: A category for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + xml: + name: Category + User: + title: a User + description: A User who is purchasing from the pet store + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + title: Pet Tag + description: A tag for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + title: a Pet + description: A pet for sale in the pet store + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + xml: + name: photoUrl + wrapped: true + items: + type: string + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + deprecated: true + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + title: An uploaded response + description: Describes the result of uploading an image resource + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string \ No newline at end of file From 03b81ebaa9124c1b4b87b0cbf3671d0fd6e13f9f Mon Sep 17 00:00:00 2001 From: maximthomas Date: Wed, 18 Mar 2026 10:31:54 +0300 Subject: [PATCH 2/7] Update documentation and tests --- .../handler/router/OpenApiRouteBuilder.java | 15 +- .../openig/handler/router/RouterHandler.java | 101 +++++++--- .../router/OpenApiRouteBuilderTest.java | 3 +- .../handler/router/RouterHandlerTest.java | 175 ++++++++++++++---- .../main/asciidoc/reference/filters-conf.adoc | 117 ++++++++++++ .../asciidoc/reference/handlers-conf.adoc | 66 ++++++- 6 files changed, 411 insertions(+), 66 deletions(-) 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 index 1caca41d6..982a7f88c 100644 --- 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 @@ -67,22 +67,27 @@ public class OpenApiRouteBuilder { * @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) { + 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: {})", - routeName, specFile.getName(), condition, baseUri != null ? baseUri : ""); + 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", false); + validatorConfig.put("failOnResponseViolation", failOnResponseViolation); final Map validatorHeapObject = new LinkedHashMap<>(); validatorHeapObject.put("name", VALIDATOR_HEAP_NAME); @@ -108,8 +113,6 @@ public JsonValue buildRouteJson(final OpenAPI spec, final File specFile) { // Apply baseURI decorator when the spec declares a server URL if (baseUri != null) { - final Map decoratorMap = new LinkedHashMap<>(); - decoratorMap.put("baseURI", baseUri); routeMap.put("baseURI", baseUri); } 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 2fe9b222f..59952ed4f 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 @@ -19,6 +19,7 @@ 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; @@ -86,6 +87,10 @@ * "directory": "/tmp/routes", * "defaultHandler": "404NotFound", * "scanInterval": 2 or "2 seconds" + * "openApiValidation": { + * "enabled": true, + * "failOnResponseViolation": false + * } * } * } * } @@ -99,6 +104,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 */ @@ -123,6 +131,8 @@ public class RouterHandler implements FileChangeListener, Handler { /** 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. */ @@ -160,11 +170,12 @@ 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()); + this(builder, directoryMonitor, new OpenApiSpecLoader(), new OpenApiRouteBuilder(), new OpenApiValidationSettings()); } protected RouterHandler(final RouteBuilder builder, final DirectoryMonitor directoryMonitor, - final OpenApiSpecLoader openApiSpecLoader, OpenApiRouteBuilder openApiRouteBuilder) { + final OpenApiSpecLoader openApiSpecLoader, OpenApiRouteBuilder openApiRouteBuilder, + final OpenApiValidationSettings openApiValidationSettings) { this.builder = builder; this.directoryMonitor = directoryMonitor; ReadWriteLock lock = new ReentrantReadWriteLock(); @@ -173,6 +184,7 @@ protected RouterHandler(final RouteBuilder builder, final DirectoryMonitor direc this.openApiSpecLoader = openApiSpecLoader; this.openApiRouteBuilder = openApiRouteBuilder; + this.openApiValidationSettings = openApiValidationSettings; } /** @@ -329,12 +341,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(); @@ -416,7 +423,7 @@ public void onChanges(FileChangeSet changes) { } private void onAddedFile(File file) { - if(openApiSpecLoader.isOpenApiFile(file)) { + if(openApiValidationSettings.enabled && openApiSpecLoader.isOpenApiFile(file)) { loadOpenApiSpec(file); } else { loadRouteFile(file); @@ -436,12 +443,12 @@ private void loadOpenApiSpec(final File specFile) { return; } - final JsonValue routeJson = openApiRouteBuilder.buildRouteJson(specOpt.get(), specFile); + final JsonValue routeJson = openApiRouteBuilder.buildRouteJson( + specOpt.get(), specFile, openApiValidationSettings.failOnResponseViolation); final String routeId = routeJson.get("name").asString(); - final String routeName = routeId; try { - load(routeId, routeName, routeJson); + load(routeId, routeId, routeJson); openApiRouteIds.put(specFile, routeId); logger.info("OpenAPI route '{}' loaded successfully from {}", routeId, specFile.getName()); } catch (Exception e) { @@ -513,10 +520,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); @@ -528,6 +546,9 @@ public Object create() throws HeapException { "frapi:openig:router-handler"))); logger.info("Routes endpoint available at '{}'", registration.getPath()); } + + + return handler; } @@ -557,14 +578,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); } }; @@ -597,4 +615,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/handler/router/OpenApiRouteBuilderTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java index 1b939be79..83454db5e 100644 --- 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 @@ -288,7 +288,6 @@ public void buildRouteJson_hasNoBaseUri_whenSpecHasNoServer() throws IOException } - private File writeYaml(final String name, final String content) throws IOException { final File file = new File(tempDir, name); Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); @@ -298,7 +297,7 @@ private File writeYaml(final String name, final String content) throws IOExcepti private JsonValue build(final File specFile) { final Optional api = specLoader.tryLoad(specFile); assertThat(api.isPresent()).as("Expected spec file to parse successfully: " + specFile).isTrue(); - return routeBuilder.buildRouteJson(api.get(), specFile); + return routeBuilder.buildRouteJson(api.get(), specFile, true); } private static String specWithPaths(final String... paths) { diff --git a/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java index fbe734273..b60f87277 100644 --- a/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java +++ b/openig-core/src/test/java/org/forgerock/openig/handler/router/RouterHandlerTest.java @@ -27,6 +27,7 @@ import static org.forgerock.openig.http.RunMode.EVALUATION; import static org.forgerock.openig.http.RunMode.PRODUCTION; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -72,7 +73,6 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -@SuppressWarnings("javadoc") public class RouterHandlerTest { private HeapImpl heap; @@ -314,7 +314,6 @@ public void testScheduleOnlyOnceDirectoryMonitoring() throws Exception { @Test public void onChanges_deploysRoute_whenOpenApiSpecFileIsAdded() throws Exception { - final File tmpDir = mock(File.class); final File specFile = mock(File.class); final OpenAPI fakeSpec = new OpenAPI(); final JsonValue routeJson = buildFakeRouteJson("petstore"); @@ -322,68 +321,168 @@ public void onChanges_deploysRoute_whenOpenApiSpecFileIsAdded() throws Exception when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec)); - when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile)).thenReturn(routeJson); + when(mockOpenApiRouteBuilder.buildRouteJson(eq(fakeSpec), eq(specFile), anyBoolean())).thenReturn(routeJson); when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute); - RouterHandler handler = new RouterHandler( - mockRouteBuilder, - new DirectoryMonitor(routes), - mockSpecLoader, - mockOpenApiRouteBuilder); - FileChangeSet fileChangeSet = new FileChangeSet(tmpDir, Set.of(specFile), Set.of(), Set.of()); - handler.onChanges(fileChangeSet); + RouterHandler handler = newHandler(); + handler.onChanges(addedChangeSet(specFile)); verify(mockSpecLoader).tryLoad(specFile); - verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile); + verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, false); verify(mockRouteBuilder).build(any(), any(), any()); } @Test public void onChanges_doesNotDeployRoute_whenSpecFileFails() throws Exception { - final File tmpDir = mock(File.class); + final File brokenSpecFile = mock(File.class); when(mockSpecLoader.isOpenApiFile(brokenSpecFile)).thenReturn(true); when(mockSpecLoader.tryLoad(brokenSpecFile)).thenReturn(Optional.empty()); - FileChangeSet fileChangeSet = new FileChangeSet(tmpDir, Set.of(brokenSpecFile), Set.of(), Set.of()); - RouterHandler handler = new RouterHandler( - mockRouteBuilder, - new DirectoryMonitor(routes), - mockSpecLoader, - mockOpenApiRouteBuilder); - handler.onChanges(fileChangeSet); + RouterHandler handler = newHandler(); + handler.onChanges(addedChangeSet(brokenSpecFile)); verify(mockRouteBuilder, never()).build(any(), any(), any()); } @Test public void stop_destroysAllRoutes() throws Exception { - final File tmpDir = mock(File.class); final File specFile = mock(File.class); final OpenAPI fakeSpec = new OpenAPI(); final JsonValue routeJson = buildFakeRouteJson("petstore"); - final Route mockRoute = mockRoute("petstore"); - - DirectoryMonitor directoryMonitor = new DirectoryMonitor(routes); + final Route mockRoute = mockRoute("petstore"); when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec)); - when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile)).thenReturn(routeJson); + when(mockOpenApiRouteBuilder.buildRouteJson(eq(fakeSpec), eq(specFile), anyBoolean())).thenReturn(routeJson); when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute); - RouterHandler handler = new RouterHandler( - mockRouteBuilder, - directoryMonitor, - mockSpecLoader, - mockOpenApiRouteBuilder); - FileChangeSet fileChangeSet = new FileChangeSet(tmpDir, Set.of(specFile), Set.of(), Set.of()); - handler.onChanges(fileChangeSet); + RouterHandler handler = newHandler(); + + handler.onChanges(addedChangeSet(specFile)); handler.stop(); verify(mockRoute).destroy(); } + @Test + public void onChanges_ignoresOpenApiSpecFile_whenEnabledIsFalse() throws Exception { + + RouterHandler handler = handlerWith(new RouterHandler.OpenApiValidationSettings(false, false)); + + final File specFile = mock(File.class); + + // Even if the loader would recognise the file, the handler must skip it + when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); + + handler.onChanges(addedChangeSet(specFile)); + + // Neither the loader nor the route builder should have been consulted + verify(mockSpecLoader, never()).tryLoad(any()); + verify(mockOpenApiRouteBuilder, never()).buildRouteJson(any(), any(), any(Boolean.class)); + verify(mockRouteBuilder, never()).build(any(), any(), any()); + } + + @Test + public void buildRouteJson_isCalledWithFalse_whenFailOnResponseViolationIsFalse() + throws Exception { + final RouterHandler strictHandler = handlerWith( + new RouterHandler.OpenApiValidationSettings(true, false)); + final File specFile = mock(File.class); + final OpenAPI fakeSpec = new OpenAPI(); + final JsonValue routeJson = buildFakeRouteJson("api"); + final Route mockRoute = mockRoute("api"); + + when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); + when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec)); + when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile, false)) + .thenReturn(routeJson); + when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute); + + + strictHandler.onChanges(addedChangeSet(specFile)); + + // Must be called with failOnResponseViolation=false + verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, false); + } + + @Test + public void buildRouteJson_isCalledWithTrue_whenFailOnResponseViolationIsTrue() + throws Exception { + final RouterHandler strictHandler = handlerWith( + new RouterHandler.OpenApiValidationSettings(true, true)); + final File specFile = mock(File.class); + final OpenAPI fakeSpec = new OpenAPI(); + final JsonValue routeJson = buildFakeRouteJson("api"); + final Route mockRoute = mockRoute("api"); + + when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true); + when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec)); + when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile, true)) + .thenReturn(routeJson); + when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute); + + strictHandler.onChanges(addedChangeSet(specFile)); + + // Must be called with failOnResponseViolation=true + verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, true); + } + + @Test + public void openApiValidationSettings_failOnResponseViolation_defaultsToFalse() { + final RouterHandler.OpenApiValidationSettings settings = + new RouterHandler.OpenApiValidationSettings(); + assertThat(settings.failOnResponseViolation).isFalse(); + } + + @Test + public void generatedRouteJson_containsFalse_whenFailOnResponseViolationIsFalse() + throws Exception { + // End-to-end: use the real OpenApiRouteBuilder to check the JSON it produces + final OpenApiRouteBuilder realBuilder = new OpenApiRouteBuilder(); + final File specFile = mock(File.class); + // Minimal parsed spec with one path + final io.swagger.v3.oas.models.OpenAPI spec = new io.swagger.v3.oas.models.OpenAPI(); + spec.setInfo(new io.swagger.v3.oas.models.info.Info().title("Test").version("1")); + spec.setPaths(new io.swagger.v3.oas.models.Paths()); + + final JsonValue routeJson = realBuilder.buildRouteJson(spec, specFile, false); + + final java.util.List heap = routeJson.get("heap").asList(); + final java.util.Map validatorEntry = heap.stream() + .filter(o -> o instanceof java.util.Map) + .map(o -> (java.util.Map) o) + .filter(m -> "OpenApiValidationFilter".equals(m.get("type"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No OpenApiValidationFilter in heap")); + + final java.util.Map cfg = (java.util.Map) validatorEntry.get("config"); + assertThat(cfg.get("failOnResponseViolation")).isEqualTo(false); + } + + @Test + public void generatedRouteJson_containsTrue_whenFailOnResponseViolationIsTrue() + throws Exception { + final OpenApiRouteBuilder realBuilder = new OpenApiRouteBuilder(); + final File specFile = mock(File.class); + final io.swagger.v3.oas.models.OpenAPI spec = new io.swagger.v3.oas.models.OpenAPI(); + spec.setInfo(new io.swagger.v3.oas.models.info.Info().title("Test").version("1")); + spec.setPaths(new io.swagger.v3.oas.models.Paths()); + + final JsonValue routeJson = realBuilder.buildRouteJson(spec, specFile, true); + + final java.util.Map validatorEntry = routeJson.get("heap").asList().stream() + .filter(o -> o instanceof java.util.Map) + .map(o -> (java.util.Map) o) + .filter(m -> "OpenApiValidationFilter".equals(m.get("type"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No OpenApiValidationFilter in heap")); + + final java.util.Map cfg = (java.util.Map) validatorEntry.get("config"); + assertThat(cfg.get("failOnResponseViolation")).isEqualTo(true); + } + private void assertStatusOnUri(Handler router, String uri, Status expected) throws URISyntaxException, ExecutionException, InterruptedException { Request ping = new Request().setMethod("GET"); @@ -430,5 +529,19 @@ private static Route mockRoute(final String id) { return r; } + private RouterHandler handlerWith(RouterHandler.OpenApiValidationSettings openApiValidationSettings) { + return new RouterHandler( + mockRouteBuilder, + new DirectoryMonitor(routes), + mockSpecLoader, + mockOpenApiRouteBuilder, openApiValidationSettings); + } + private RouterHandler newHandler() { + return handlerWith(new RouterHandler.OpenApiValidationSettings()); + } + + private FileChangeSet addedChangeSet(File route) { + return new FileChangeSet(mock(File.class), Set.of(route), Set.of(), Set.of()); + } } diff --git a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc index e7b1c25ac..130bb0f7e 100644 --- a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc @@ -1595,6 +1595,123 @@ link:http://tools.ietf.org/html/rfc6749[The OAuth 2.0 Authorization Framework, w link:http://tools.ietf.org/html/rfc6750[OAuth 2.0 Bearer Token Usage, window=\_blank] ''' + +[#OpenApiValidationFilter] +=== OpenApiValidationFilter — validate requests and responses against an OpenAPI specification + +[#OpenApiValidationFilter-description] +==== Description + +Validates inbound HTTP requests and outbound HTTP responses against an +https://spec.openapis.org/oas/latest.html[OpenAPI, window=_blank] +specification (Swagger 2.x or OpenAPI 3.x) + +*Request validation* + +Before forwarding a request to the next filter or handler in the chain, +the filter validates the request's path, HTTP method, query parameters, headers, +and body against the specification. If validation fails, the filter returns a +`400 Bad Request` response immediately. The downstream handler is not called. +The response body is `text/plain` and contains one validation message per line. + +*Response validation* + +After the downstream handler returns a response, the filter validates the response +status code, headers, and body against the specification. Behaviour on failure is +controlled by the `failOnResponseViolation` configuration property: + +* `true` — the filter returns a `502 Bad Gateway` response containing the +validation messages. +* `false` (default) — the violation is logged at `WARN` level and the original +response is passed through unchanged. + +*Auto-loading from the routes directory* + +In addition to being declared manually in a route heap, this filter is used +automatically when a `RouterHandler` detects an OpenAPI spec file +(`.yaml`, `.yml`, or `.json`) in the routes directory. +See xref:handlers-conf.adoc#Router[Router(5)] for details. + +==== Usage + +[source, json] +---- +{ + "name": string, + "type": "OpenApiValidationFilter", + "config": { + "spec": expression, string + "failOnResponseViolation": expression, boolean + } +} +---- + +==== Properties +-- + +`"spec"`: __expression, string, required__:: +The OpenAPI specification contents. +Both JSON and YAML formats are supported. +The specification may describe a Swagger 2.x (`swagger: "2.0"`) or an +OpenAPI 3.x (`openapi: "3.x.y"`) API. ++ +See also xref:expressions-conf.adoc#Expressions[Expressions(5)]. + +`"failOnResponseViolation"`: __boolean, optional__:: +Controls the behaviour when response validation fails. ++ +* `true` — return a `502 Bad Gateway` error response. +* `false` — log a warning at `WARN` level and pass the original response through. ++ +Default: `false`. +-- + +==== Example + +The following route validates all requests to `/api` against a local +OpenAPI specification and forwards them to the upstream service. +Response violations are logged as warnings but do not block the response. + +[source, json] +---- +{ + "name": "api-route", + "condition": "${matches(request.uri.path, '^/api(/.*)?$')}", + "heap": [ + { + "name": "ApiValidator", + "type": "OpenApiValidationFilter", + "config": { + "spec": "${read('/opt/openig/config/routes/api.yaml')}", + "failOnResponseViolation": false + } + } + ], + "handler": { + "type": "Chain", + "config": { + "filters": [ "ApiValidator" ], + "handler": "ClientHandler" + } + }, + "baseURI": "https://api.example.com" +} +---- +==== Javadoc +link:{apidocs-url}/org/forgerock/openig/filter/OpenApiValidationFilter.html[org.forgerock.openig.filter.OpenApiValidationFilter, window=\_blank] + +==== See Also + +xref:handlers-conf.adoc#Router[Router(5)] + +xref:expressions-conf.adoc#Expressions[Expressions(5)] + +https://spec.openapis.org/oas/latest.html[OpenAPI Specification, window=_blank] + +https://swagger.io/specification/v2/[Swagger 2.0 Specification, window=_blank] + +''' + [#PasswordReplayFilter] === PasswordReplayFilter — replay credentials with a single filter diff --git a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc index c15561a88..d991b56a2 100644 --- a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc @@ -680,6 +680,33 @@ A Router is a handler that routes request processing to separate configuration f The Router reloads configuration files for Routes from the specified directory at the specified scan interval. +*OpenAPI spec auto-loading* + +In addition to regular route JSON files, the Router automatically detects +OpenAPI specification files (Swagger 2.x or OpenAPI 3.x) placed in the same +routes directory. Supported file extensions are `.json`, `.yaml`, and `.yml`. +A file is recognised as an OpenAPI spec when its root document contains an +`openapi` or `swagger` key. + +When such a file is detected, the Router: + +. Parses the specification using the Swagger Parser library. +. Synthesises a complete route configuration whose condition matches every + `(path, HTTP method)` pair declared in the spec. + Path parameter placeholders such as `{userId}` are converted to the regex + `[^/]+` so they match any non-slash path segment. +. Wires an `OpenApiValidationFilter` into the route chain to validate all + inbound requests and outbound responses against the spec. +. Sets the `baseURI` to `servers[0].url` when the spec declares a server URL. +. Hot-reloads the route whenever the spec file changes on disk. +. Undeploys the route when the spec file is removed. + +Normal `.json` route configuration files that do not contain an `openapi` or +`swagger` root key continue to be processed as before. + +For details about the validation filter that is added to synthesised routes, see +xref:filters-conf.adoc#OpenApiValidationFilter[OpenApiValidationFilter(5)]. + [#d210e3569] ==== Usage @@ -691,11 +718,14 @@ The Router reloads configuration files for Routes from the specified directory a "config": { "defaultHandler": Handler reference, "directory": expression, - "scanInterval": integer + "scanInterval": integer, + "openApiValidation": { + "failOnResponseViolation": boolean + } } } ---- -An alternative value for type is RouterHandler. +An alternative value for type is `RouterHandler`. [#d210e3577] ==== Properties @@ -739,11 +769,41 @@ Default: 10 (seconds) + To prevent OpenIG from reloading Route configurations after you except at startup, set the scan interval to -1. +`"openApiValidation"`: __object, optional__:: +Controls the automatic detection of OpenAPI specification files and the +behaviour of the `OpenApiValidationFilter` instances that are synthesised for +each detected spec. ++ +[open] +==== +`"enabled"`: __boolean, optional__:: +Whether to enable automatic detection and loading of OpenAPI specification +files from the routes directory. ++ +When set to `false`, the Router ignores `.yaml`, `.yml`, and spec-like `.json` +files and only processes regular route JSON configurations. ++ +Default: `true`. + +`"failOnResponseViolation"`: __boolean, optional__:: +Passed to every `OpenApiValidationFilter` instance that is created automatically +from a detected spec file. ++ +* `true` — a response that does not conform to the spec causes the filter to + return a `502 Bad Gateway` error to the client. +* `false` — a response violation is logged at `WARN` level and the original + response is passed through unchanged. +This setting applies only to auto-generated routes. Routes that declare an +`OpenApiValidationFilter` manually in their heap control this flag themselves. + ++ +Default: `false`. + -- [#d210e3636] ==== Javadoc -link:{apidocs-url}/index.html?org/forgerock/openig/handler/router/RouterHandler.html[org.forgerock.openig.handler.router.RouterHandler, window=\_blank] +link:{apidocs-url}org/forgerock/openig/handler/router/RouterHandler.html[org.forgerock.openig.handler.router.RouterHandler, window=\_blank] ''' [#SamlFederationHandler] From c590f92a138021bb704d581abf40f318118924c0 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Wed, 18 Mar 2026 10:40:57 +0300 Subject: [PATCH 3/7] Fix tests stability --- .../openig/handler/router/RouterHandler.java | 1 - .../test/integration/IT_SwaggerRoute.java | 29 ++++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) 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 59952ed4f..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 @@ -37,7 +37,6 @@ 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; diff --git a/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java b/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java index 933435958..b9dea9b3a 100644 --- a/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java +++ b/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_SwaggerRoute.java @@ -74,10 +74,10 @@ public void tearDownWireMock() { @Test public void testSwaggerRoute() throws IOException { String testConfigPath = getTestConfigPath(); - Path destination = Path.of(testConfigPath.concat("/config/routes/petstore.yaml")); + Path destination = Path.of(testConfigPath, "config", "routes", "petstore.yaml"); try(InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("routes/petstore.yaml")) { - assert inputStream != null; + Objects.requireNonNull(inputStream, "routes/petstore.yaml resource missing"); Files.createDirectories(destination.getParent()); Files.copy(inputStream, destination, StandardCopyOption.REPLACE_EXISTING); } @@ -90,8 +90,7 @@ public void testCustomRoute() throws IOException { String openApiSpec = this.getClass().getClassLoader().getResource("routes/petstore.yaml").getPath(); String routeContents = IOUtils.resourceToString("routes/01-find-pet.json", StandardCharsets.UTF_8, this.getClass().getClassLoader()) .replace("$$SWAGGER_FILE$$", openApiSpec); - - Path destination = Path.of(testConfigPath.concat("/config/routes/01-find-pet.json")); + Path destination = Path.of(testConfigPath, "config", "routes", "01-find-pet.json"); Files.createDirectories(destination.getParent()); Files.writeString(destination, routeContents); @@ -99,16 +98,18 @@ public void testCustomRoute() throws IOException { } private void testPetRoute(String routeId, Path destination) throws IOException { - await().pollInterval(3, SECONDS) - .atMost(15, SECONDS).until(() -> routeAvailable(routeId)); - - RestAssured - .given().when().get("/v2/pet/findByStatus?status=available") - .then() - .statusCode(200) - .body("[0].id", Matchers.equalTo(1)); - - Files.delete(destination); + try { + await().pollInterval(3, SECONDS) + .atMost(15, SECONDS).until(() -> routeAvailable(routeId)); + + RestAssured + .given().when().get("/v2/pet/findByStatus?status=available") + .then() + .statusCode(200) + .body("[0].id", Matchers.equalTo(1)); + } finally { + Files.delete(destination); + } await().pollInterval(3, SECONDS) .atMost(15, SECONDS).until(() -> !routeAvailable(routeId)); From 1ad403da26647fdfae90b9f8ec4724b14b44ab1e Mon Sep 17 00:00:00 2001 From: maximthomas Date: Wed, 18 Mar 2026 10:57:05 +0300 Subject: [PATCH 4/7] Fix docs --- openig-doc/src/main/asciidoc/reference/handlers-conf.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc index d991b56a2..872599a49 100644 --- a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc @@ -720,6 +720,7 @@ xref:filters-conf.adoc#OpenApiValidationFilter[OpenApiValidationFilter(5)]. "directory": expression, "scanInterval": integer, "openApiValidation": { + "enabled": boolean "failOnResponseViolation": boolean } } From 4384ba699d960d51fdee68e8d19dbf6440039473 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Wed, 18 Mar 2026 14:02:06 +0300 Subject: [PATCH 5/7] Add requestValidationErrorHandler and responseValidationErrorHandler to the OpenApiValidationFilter --- .../filter/OpenApiValidationFilter.java | 69 +++++++++++++++---- .../filter/OpenApiValidationFilterTest.java | 7 +- .../main/asciidoc/reference/filters-conf.adoc | 61 +++++++++++++--- .../test/resources/routes/01-find-pet.json | 10 ++- 4 files changed, 117 insertions(+), 30 deletions(-) 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 index 647406c69..b7445267b 100644 --- a/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java +++ b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java @@ -43,22 +43,26 @@ import java.util.Map; 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 returns a {@code 400 Bad Request} response - * immediately, without forwarding the request downstream. The response body is a plain-text - * list of validation messages. + *

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 on failure is controlled by the {@code failOnResponseViolation} configuration flag: + * Behaviour depends on {@code failOnResponseViolation}: *

    - *
  • {@code true} – return a {@code 502 Bad Gateway} with the validation messages.
  • - *
  • {@code false} (default) – log a warning and pass the response through unchanged.
  • + *
  • {@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.
  • *
+ *

* *

Heap configuration

*
{@code
@@ -67,7 +71,9 @@
  *   "type": "OpenApiValidationFilter",
  *   "config": {
  *     "specFile": "/path/to/openapi.yaml",
- *     "failOnResponseViolation": false
+ *     "failOnResponseViolation": false,
+ *     "requestValidationErrorHandler": "403BadRequest",
+ *     "responseValidationErrorHandler": "503ServiceUnavailable"
  *   }
  * }
  * }
@@ -80,20 +86,37 @@ public class OpenApiValidationFilter implements Filter { 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 502} error; if {@code false}, it is only logged + * 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) { - this(OpenApiInteractionValidator.createForInlineApiSpecification(spec).build(), failOnResponseViolation); + 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 @@ -111,8 +134,7 @@ public Promise filter(Context context, Request r if (requestReport.hasErrors()) { logger.info("Request validation failed for {} {}: {}", request.getMethod(), request.getUri(), requestReport); - return Promises.newResultPromise( - buildErrorResponse(Status.BAD_REQUEST, "Request validation failed:\n" + requestReport)); + return requestValidationErrorHandler.handle(context, request); } return next.handle(context, request).then(response -> { @@ -129,7 +151,7 @@ public Promise filter(Context context, Request r if(responseValidationReport.hasErrors()) { logger.warn("upstream response does not match specification: {}", responseValidationReport); if(failOnResponseViolation) { - return buildErrorResponse (Status.BAD_GATEWAY, "Response validation failed:\n" + responseValidationReport); + return responseValidationErrorHandler.handle(context, request).getOrThrowUninterruptibly(); } } return response; @@ -185,6 +207,16 @@ private static SimpleResponse validatorResponseOf(final Response response) throw return builder.build(); } + public static Handler defaultRequestValidationErrorHandler() { + return (context, request) -> + Promises.newResultPromise(buildErrorResponse(Status.BAD_REQUEST, "Request validation failed")); + } + + public static Handler defaultResponseValidationErrorHandler() { + return (context, request) -> + Promises.newResultPromise(buildErrorResponse(Status.SERVICE_UNAVAILABLE, "Response validation failed")); + } + public static class Heaplet extends GenericHeaplet { @Override @@ -196,7 +228,16 @@ public Object create() throws HeapException { final boolean failOnResponseViolation = evaluatedConfig.get("failOnResponseViolation").defaultTo(false).asBoolean(); - return new OpenApiValidationFilter(openApiSpec, failOnResponseViolation); + Handler requestValidationErrorHandler = evaluatedConfig.get("requestValidationErrorHandler") + .as(optionalHeapObject(heap, Handler.class)); + requestValidationErrorHandler = requestValidationErrorHandler == null ? defaultRequestValidationErrorHandler() : requestValidationErrorHandler; + + Handler responseValidationErrorHandler = evaluatedConfig.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/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java b/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java index e3c3eeb41..81881b9cc 100644 --- a/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java +++ b/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java @@ -32,7 +32,6 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicReference; @@ -86,7 +85,6 @@ public void filter_returns400_whenRequestValidationFails() throws Exception { 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()); } @@ -111,7 +109,7 @@ public void filter_passesResponse_whenResponseValidationFailsAndFlagIsFalse() th } @Test - public void filter_returns502_whenResponseValidationFailsAndFlagIsTrue() throws Exception { + 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")); @@ -125,9 +123,8 @@ public void filter_returns502_whenResponseValidationFailsAndFlagIsTrue() throws final Response response = filter.filter(rootContext, buildGetRequest("http://localhost/pets"), mockNextHandler).get(); - assertThat(response.getStatus()).isEqualTo(Status.BAD_GATEWAY); + assertThat(response.getStatus()).isEqualTo(Status.SERVICE_UNAVAILABLE); assertThat(response.getEntity().getString()).contains("Response validation failed"); - assertThat(response.getEntity().getString()).contains("Response schema mismatch"); } @Test diff --git a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc index 130bb0f7e..b84baab8b 100644 --- a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc @@ -1610,9 +1610,9 @@ specification (Swagger 2.x or OpenAPI 3.x) Before forwarding a request to the next filter or handler in the chain, the filter validates the request's path, HTTP method, query parameters, headers, -and body against the specification. If validation fails, the filter returns a -`400 Bad Request` response immediately. The downstream handler is not called. -The response body is `text/plain` and contains one validation message per line. +and body against the specification. If validation fails, the filter stops processing +and delegates to `requestValidationErrorHandler` instead of calling the downstream +handler. The default `requestValidationErrorHandler` returns `400 Bad Request`. *Response validation* @@ -1620,10 +1620,10 @@ After the downstream handler returns a response, the filter validates the respon status code, headers, and body against the specification. Behaviour on failure is controlled by the `failOnResponseViolation` configuration property: -* `true` — the filter returns a `502 Bad Gateway` response containing the -validation messages. -* `false` (default) — the violation is logged at `WARN` level and the original -response is passed through unchanged. +* `true` — delegate to `responseValidationErrorHandler`. The default handler +returns `503 Service Unavailable`. +* `false` (default) — log a warning at `WARN` level and pass the original +response through unchanged. *Auto-loading from the routes directory* @@ -1640,8 +1640,10 @@ See xref:handlers-conf.adoc#Router[Router(5)] for details. "name": string, "type": "OpenApiValidationFilter", "config": { - "spec": expression, string - "failOnResponseViolation": expression, boolean + "spec": expression, string, + "failOnResponseViolation": expression, boolean, + "requestValidationErrorHandler": Handler reference, + "responseValidationErrorHandler": Handler reference } } ---- @@ -1664,6 +1666,29 @@ Controls the behaviour when response validation fails. * `false` — log a warning at `WARN` level and pass the original response through. + Default: `false`. + +`"requestValidationErrorHandler"`: __Handler reference or inline, optional__:: +Handler invoked when request validation fails. ++ +Provide either the name of a Handler object defined in the heap, or an inline Handler +configuration object. ++ +Default: a built-in handler equivalent to the following declaration, which returns +`400 Bad Request` ++ +See also xref:handlers-conf.adoc#handlers-conf[Handlers]. + +`"responseValidationErrorHandler"`: __Handler reference or inline, optional__:: +Handler invoked when response validation fails and `failOnResponseViolation` is `true`. ++ +Provide either the name of a Handler object defined in the heap, or an inline Handler +configuration object. ++ +Default: a built-in handler equivalent to the following declaration, which returns +`503 Service Unavailable` ++ +See also xref:handlers-conf.adoc#handlers-conf[Handlers]. + -- ==== Example @@ -1683,7 +1708,23 @@ Response violations are logged as warnings but do not block the response. "type": "OpenApiValidationFilter", "config": { "spec": "${read('/opt/openig/config/routes/api.yaml')}", - "failOnResponseViolation": false + "failOnResponseViolation": false, + "requestValidationErrorHandler": { + "type": "StaticResponseHandler", + "config": { + "status": 400, + "headers": { "Content-Type": ["application/json"] }, + "entity": "{\"error\": \"request_validation_failed\"}" + } + }, + "responseValidationErrorHandler": { + "type": "StaticResponseHandler", + "config": { + "status": 503, + "headers": { "Content-Type": ["application/json"] }, + "entity": "{\"error\": \"response_validation_failed\"}" + } + } } } ], diff --git a/openig-war/src/test/resources/routes/01-find-pet.json b/openig-war/src/test/resources/routes/01-find-pet.json index 50e1e44d6..8d6dc4972 100644 --- a/openig-war/src/test/resources/routes/01-find-pet.json +++ b/openig-war/src/test/resources/routes/01-find-pet.json @@ -8,7 +8,15 @@ "type": "OpenApiValidationFilter", "config": { "spec": "${read('$$SWAGGER_FILE$$')}", - "failOnResponseViolation": false + "failOnResponseViolation": false, + "requestValidationErrorHandler": { + "type": "StaticResponseHandler", + "config": { + "status": 400, + "headers": { "Content-Type": ["application/json"] }, + "entity": "{\"error\": \"request_validation_failed\"}" + } + } } } ], From bfee5c58ea0fd2d2073377804a20d4e6a440e238 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Wed, 18 Mar 2026 14:28:47 +0300 Subject: [PATCH 6/7] update exception processing for the OpenApiValidationFilter.filter function --- .../openig/filter/OpenApiValidationFilter.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index b7445267b..ed29b2833 100644 --- a/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java +++ b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java @@ -41,6 +41,7 @@ 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; @@ -126,7 +127,7 @@ public Promise filter(Context context, Request r try { validatorRequest = validatorRequestOf(request); } catch (IOException e) { - logger.error("exception while reading the request"); + logger.error("exception while reading the request", e); return Promises.newResultPromise(new Response(Status.INTERNAL_SERVER_ERROR)); } @@ -142,7 +143,7 @@ public Promise filter(Context context, Request r try { validatorResponse = validatorResponseOf(response); } catch (IOException e) { - logger.error("exception while reading the response"); + logger.error("exception while reading the response", e); return new Response(Status.INTERNAL_SERVER_ERROR); } @@ -151,7 +152,12 @@ public Promise filter(Context context, Request r if(responseValidationReport.hasErrors()) { logger.warn("upstream response does not match specification: {}", responseValidationReport); if(failOnResponseViolation) { - return responseValidationErrorHandler.handle(context, request).getOrThrowUninterruptibly(); + try { + return responseValidationErrorHandler.handle(context, request).get(); + } catch (InterruptedException | ExecutionException e) { + logger.error("exception while handling the response", e); + return new Response(Status.INTERNAL_SERVER_ERROR); + } } } return response; From a226be544c6d3ea421c5c0dd8ab94ad12a483b04 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Mon, 23 Mar 2026 15:16:52 +0300 Subject: [PATCH 7/7] Add OpenAPI validation messages to the AttributesContext --- .../org/forgerock/openig/el/Functions.java | 12 +++++++ .../filter/OpenApiValidationFilter.java | 35 +++++++++++++++---- .../filter/OpenApiValidationFilterTest.java | 2 ++ .../asciidoc/reference/expressions-conf.adoc | 26 +++++++++++++- .../main/asciidoc/reference/filters-conf.adoc | 9 +++-- .../test/resources/routes/01-find-pet.json | 2 +- 6 files changed, 76 insertions(+), 10 deletions(-) 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 index ed29b2833..953ec3528 100644 --- a/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java +++ b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java @@ -30,6 +30,7 @@ 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; @@ -81,6 +82,12 @@ */ 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; @@ -133,9 +140,10 @@ public Promise filter(Context context, Request r final ValidationReport requestReport = validator.validateRequest(validatorRequest); if (requestReport.hasErrors()) { + logger.info("Request validation failed for {} {}: {}", request.getMethod(), request.getUri(), requestReport); - return requestValidationErrorHandler.handle(context, request); + return requestValidationErrorHandler.handle(injectReportToContext(context, requestReport), request); } return next.handle(context, request).then(response -> { @@ -153,7 +161,9 @@ public Promise filter(Context context, Request r logger.warn("upstream response does not match specification: {}", responseValidationReport); if(failOnResponseViolation) { try { - return responseValidationErrorHandler.handle(context, request).get(); + 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); @@ -164,6 +174,15 @@ public Promise filter(Context context, Request r }); } + 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"); @@ -215,12 +234,16 @@ private static SimpleResponse validatorResponseOf(final Response response) throw public static Handler defaultRequestValidationErrorHandler() { return (context, request) -> - Promises.newResultPromise(buildErrorResponse(Status.BAD_REQUEST, "Request validation failed")); + 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")); + 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 { @@ -234,11 +257,11 @@ public Object create() throws HeapException { final boolean failOnResponseViolation = evaluatedConfig.get("failOnResponseViolation").defaultTo(false).asBoolean(); - Handler requestValidationErrorHandler = evaluatedConfig.get("requestValidationErrorHandler") + Handler requestValidationErrorHandler = config.get("requestValidationErrorHandler") .as(optionalHeapObject(heap, Handler.class)); requestValidationErrorHandler = requestValidationErrorHandler == null ? defaultRequestValidationErrorHandler() : requestValidationErrorHandler; - Handler responseValidationErrorHandler = evaluatedConfig.get("responseValidationErrorHandler") + Handler responseValidationErrorHandler = config.get("responseValidationErrorHandler") .as(optionalHeapObject(heap, Handler.class)); responseValidationErrorHandler = responseValidationErrorHandler == null ? defaultResponseValidationErrorHandler() : responseValidationErrorHandler; 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 index 81881b9cc..1a3888709 100644 --- a/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java +++ b/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java @@ -85,6 +85,7 @@ public void filter_returns400_whenRequestValidationFails() throws Exception { 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()); } @@ -125,6 +126,7 @@ public void filter_returns503_whenResponseValidationFailsAndFlagIsTrue() throws assertThat(response.getStatus()).isEqualTo(Status.SERVICE_UNAVAILABLE); assertThat(response.getEntity().getString()).contains("Response validation failed"); + assertThat(response.getEntity().getString()).contains("Response schema mismatch"); } @Test diff --git a/openig-doc/src/main/asciidoc/reference/expressions-conf.adoc b/openig-doc/src/main/asciidoc/reference/expressions-conf.adoc index 5ddf4eee6..86685eeb1 100644 --- a/openig-doc/src/main/asciidoc/reference/expressions-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/expressions-conf.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -248,6 +248,30 @@ The base64-encoded string. -- +[#functions-escapeJson] +==== escapeJson + +[source] +---- +escapeJson(string) +---- +Returns a JSON escaped string, applying JSON string escaping rules to the provided value. +Useful when embedding dynamic values inside a JSON structure within an expression. +.Parameters +-- + +string:: +The string to escape, which may be `null`. + +-- +.Returns +-- + +string:: +The JSON escaped string, or `null` if the input was `null`. + +-- + [#functions-formDecodeParameterNameOrValue] ==== formDecodeParameterNameOrValue diff --git a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc index b84baab8b..d7fcf1aaa 100644 --- a/openig-doc/src/main/asciidoc/reference/filters-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/filters-conf.adoc @@ -1625,6 +1625,11 @@ returns `503 Service Unavailable`. * `false` (default) — log a warning at `WARN` level and pass the original response through unchanged. +*Validation report in the context* + +Before delegating to either error handler, the filter places validation report +object into `AttributesContext` under the key `openApiValidationReport`. + *Auto-loading from the routes directory* In addition to being declared manually in a route heap, this filter is used @@ -1714,7 +1719,7 @@ Response violations are logged as warnings but do not block the response. "config": { "status": 400, "headers": { "Content-Type": ["application/json"] }, - "entity": "{\"error\": \"request_validation_failed\"}" + "entity": "{\"error\": \"request_validation_failed: ${escapeJson(toString(attributes.openApiValidationReport))}\"}" } }, "responseValidationErrorHandler": { @@ -1722,7 +1727,7 @@ Response violations are logged as warnings but do not block the response. "config": { "status": 503, "headers": { "Content-Type": ["application/json"] }, - "entity": "{\"error\": \"response_validation_failed\"}" + "entity": "{\"error\": \"response_validation_failed: ${escapeJson(toString(attributes.openApiValidationReport))}\"}" } } } diff --git a/openig-war/src/test/resources/routes/01-find-pet.json b/openig-war/src/test/resources/routes/01-find-pet.json index 8d6dc4972..5c89b4bbc 100644 --- a/openig-war/src/test/resources/routes/01-find-pet.json +++ b/openig-war/src/test/resources/routes/01-find-pet.json @@ -14,7 +14,7 @@ "config": { "status": 400, "headers": { "Content-Type": ["application/json"] }, - "entity": "{\"error\": \"request_validation_failed\"}" + "entity": "{\"error\": \"request_validation_failed: ${attributes.openApiValidationReport}\"}" } } }