Skip to content

Commit 80e6892

Browse files
authored
OpenAPI/Swagger routes loader and request/response validator (#150)
1 parent 0996f04 commit 80e6892

File tree

19 files changed

+3016
-36
lines changed

19 files changed

+3016
-36
lines changed

openig-core/pom.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
1515
Copyright 2010-2011 ApexIdentity Inc.
1616
Portions Copyright 2011-2016 ForgeRock AS.
17-
Portions copyright 2025 3A Systems LLC.
17+
Portions copyright 2025-2026 3A Systems LLC.
1818
-->
1919
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
2020
<modelVersion>4.0.0</modelVersion>
@@ -161,6 +161,13 @@
161161
<groupId>commons-io</groupId>
162162
<artifactId>commons-io</artifactId>
163163
</dependency>
164+
<dependency>
165+
<groupId>com.atlassian.oai</groupId>
166+
<artifactId>swagger-request-validator-core</artifactId>
167+
<version>2.46.0</version>
168+
</dependency>
169+
170+
<!-- test dependencies -->
164171
<dependency>
165172
<groupId>org.glassfish.grizzly</groupId>
166173
<artifactId>grizzly-http-server</artifactId>

openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.forgerock.openig.filter.HeaderFilter;
3737
import org.forgerock.openig.filter.HttpBasicAuthFilter;
3838
import org.forgerock.openig.filter.LocationHeaderFilter;
39+
import org.forgerock.openig.filter.OpenApiValidationFilter;
3940
import org.forgerock.openig.filter.PasswordReplayFilterHeaplet;
4041
import org.forgerock.openig.filter.ScriptableFilter;
4142
import org.forgerock.openig.filter.SqlAttributesFilter;
@@ -101,6 +102,7 @@ public class CoreClassAliasResolver implements ClassAliasResolver {
101102
ALIASES.put("KeyStore", KeyStoreHeaplet.class);
102103
ALIASES.put("LocationHeaderFilter", LocationHeaderFilter.class);
103104
ALIASES.put("MappedThrottlingPolicy", MappedThrottlingPolicyHeaplet.class);
105+
ALIASES.put("OpenApiValidationFilter", OpenApiValidationFilter.class);
104106
ALIASES.put("PasswordReplayFilter", PasswordReplayFilterHeaplet.class);
105107
ALIASES.put("Router", RouterHandler.class);
106108
ALIASES.put("RouterHandler", RouterHandler.class);

openig-core/src/main/java/org/forgerock/openig/el/Functions.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*
1414
* Copyright 2010-2011 ApexIdentity Inc.
1515
* Portions Copyright 2011-2016 ForgeRock AS.
16+
* Portions Copyright 2026 3A Systems LLC.
1617
*/
1718

1819
package org.forgerock.openig.el;
@@ -33,6 +34,7 @@
3334
import java.util.regex.Pattern;
3435
import java.util.regex.PatternSyntaxException;
3536

37+
import org.apache.commons.lang3.StringEscapeUtils;
3638
import org.forgerock.http.util.Uris;
3739
import org.forgerock.openig.util.StringUtil;
3840
import org.forgerock.util.encode.Base64;
@@ -441,4 +443,14 @@ public static String fileToUrl(File file) {
441443
}
442444
}
443445

446+
/**
447+
* Escapes the characters in a {@code String} using JSON string rules.
448+
*
449+
* @param value the string to escape, may be null
450+
* @return a JSON escaped string
451+
*/
452+
public static String escapeJson(String value) {
453+
return StringEscapeUtils.escapeJson(value);
454+
}
455+
444456
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
* The contents of this file are subject to the terms of the Common Development and
3+
* Distribution License (the License). You may not use this file except in compliance with the
4+
* License.
5+
*
6+
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7+
* specific language governing permission and limitations under the License.
8+
*
9+
* When distributing Covered Software, include this CDDL Header Notice in each file and include
10+
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11+
* Header, with the fields enclosed by brackets [] replaced by your own identifying
12+
* information: "Portions copyright [year] [name of copyright owner]".
13+
*
14+
* Copyright 2026 3A Systems LLC.
15+
*/
16+
17+
package org.forgerock.openig.filter;
18+
19+
import com.atlassian.oai.validator.OpenApiInteractionValidator;
20+
import com.atlassian.oai.validator.model.SimpleRequest;
21+
import com.atlassian.oai.validator.model.SimpleResponse;
22+
import com.atlassian.oai.validator.report.ValidationReport;
23+
import org.apache.http.NameValuePair;
24+
import org.apache.http.client.utils.URLEncodedUtils;
25+
import org.forgerock.http.Filter;
26+
import org.forgerock.http.Handler;
27+
import org.forgerock.http.protocol.Request;
28+
import org.forgerock.http.protocol.Response;
29+
import org.forgerock.http.protocol.Status;
30+
import org.forgerock.json.JsonValue;
31+
import org.forgerock.openig.heap.GenericHeaplet;
32+
import org.forgerock.openig.heap.HeapException;
33+
import org.forgerock.services.context.AttributesContext;
34+
import org.forgerock.services.context.Context;
35+
import org.forgerock.util.promise.NeverThrowsException;
36+
import org.forgerock.util.promise.Promise;
37+
import org.forgerock.util.promise.Promises;
38+
import org.slf4j.Logger;
39+
import org.slf4j.LoggerFactory;
40+
41+
import java.io.IOException;
42+
import java.nio.charset.StandardCharsets;
43+
import java.util.List;
44+
import java.util.Map;
45+
import java.util.concurrent.ExecutionException;
46+
import java.util.stream.Collectors;
47+
48+
import static org.forgerock.openig.util.JsonValues.optionalHeapObject;
49+
50+
/**
51+
* Validates HTTP requests and responses against an
52+
* OpenAPI (Swagger 2.x / OpenAPI 3.x) specification
53+
*
54+
* <h2>Request validation</h2>
55+
* <p>If the request fails validation the filter stops processing and delegates to
56+
* {@code requestValidationErrorHandler} instead of forwarding the request downstream.
57+
* The default {@code requestValidationErrorHandler} returns {@code 400 Bad Request}.</p>
58+
*
59+
* <h2>Response validation</h2>
60+
* <p>After the downstream handler returns a response, the filter validates it against the spec.
61+
* Behaviour depends on {@code failOnResponseViolation}:
62+
* <ul>
63+
* <li>{@code true} – delegate to {@code responseValidationErrorHandler}. The default returns
64+
* {@code 503 Service Unavailable}</li>
65+
* <li>{@code false} (default) – log a warning and pass the original response through.</li>
66+
* </ul>
67+
* </p>
68+
*
69+
* <h2>Heap configuration</h2>
70+
* <pre>{@code
71+
* {
72+
* "name": "myValidator",
73+
* "type": "OpenApiValidationFilter",
74+
* "config": {
75+
* "specFile": "/path/to/openapi.yaml",
76+
* "failOnResponseViolation": false,
77+
* "requestValidationErrorHandler": "403BadRequest",
78+
* "responseValidationErrorHandler": "503ServiceUnavailable"
79+
* }
80+
* }
81+
* }</pre>
82+
*/
83+
public class OpenApiValidationFilter implements Filter {
84+
85+
/**
86+
* Key under which the {@link ValidationReport} is stored in the
87+
* {@link AttributesContext} before delegating to an error handler.
88+
*/
89+
public static final String ATTR_OPENAPI_VALIDATION_REPORT = "openApiValidationReport";
90+
91+
private static final Logger logger = LoggerFactory.getLogger(OpenApiValidationFilter.class);
92+
93+
private final OpenApiInteractionValidator validator;
94+
95+
private final boolean failOnResponseViolation;
96+
97+
private final Handler requestValidationErrorHandler;
98+
99+
private final Handler responseValidationErrorHandler;
100+
101+
/**
102+
* Creates a filter backed by a pre-built {@link OpenApiInteractionValidator}.
103+
*
104+
* @param spec The OpenAPI / Swagger specification to use in the validator
105+
* @param failOnResponseViolation if {@code true}, a response validation failure results in
106+
* a {@code 503} error; if {@code false}, it is only logged
107+
* @param requestValidationErrorHandler handler invoked on request validation failure
108+
* @param responseValidationErrorHandler handler invoked on response validation failure when
109+
* {@code failOnResponseViolation} is {@code true}
110+
*/
111+
private OpenApiValidationFilter(String spec, boolean failOnResponseViolation,
112+
Handler requestValidationErrorHandler, Handler responseValidationErrorHandler) {
113+
this(OpenApiInteractionValidator.createForInlineApiSpecification(spec).build(), failOnResponseViolation,
114+
requestValidationErrorHandler, responseValidationErrorHandler);
115+
}
116+
117+
OpenApiValidationFilter(OpenApiInteractionValidator validator, boolean failOnResponseViolation) {
118+
this(validator, failOnResponseViolation,
119+
defaultRequestValidationErrorHandler(), defaultResponseValidationErrorHandler());
120+
}
121+
122+
OpenApiValidationFilter(OpenApiInteractionValidator validator, boolean failOnResponseViolation,
123+
Handler requestValidationErrorHandler, Handler responseValidationErrorHandler) {
124+
this.validator = validator;
125+
this.failOnResponseViolation = failOnResponseViolation;
126+
this.requestValidationErrorHandler = requestValidationErrorHandler;
127+
this.responseValidationErrorHandler = responseValidationErrorHandler;
128+
}
129+
130+
@Override
131+
public Promise<Response, NeverThrowsException> filter(Context context, Request request, Handler next) {
132+
133+
final SimpleRequest validatorRequest;
134+
try {
135+
validatorRequest = validatorRequestOf(request);
136+
} catch (IOException e) {
137+
logger.error("exception while reading the request", e);
138+
return Promises.newResultPromise(new Response(Status.INTERNAL_SERVER_ERROR));
139+
}
140+
141+
final ValidationReport requestReport = validator.validateRequest(validatorRequest);
142+
if (requestReport.hasErrors()) {
143+
144+
logger.info("Request validation failed for {} {}: {}",
145+
request.getMethod(), request.getUri(), requestReport);
146+
return requestValidationErrorHandler.handle(injectReportToContext(context, requestReport), request);
147+
}
148+
149+
return next.handle(context, request).then(response -> {
150+
final com.atlassian.oai.validator.model.Response validatorResponse;
151+
try {
152+
validatorResponse = validatorResponseOf(response);
153+
} catch (IOException e) {
154+
logger.error("exception while reading the response", e);
155+
return new Response(Status.INTERNAL_SERVER_ERROR);
156+
}
157+
158+
ValidationReport responseValidationReport
159+
= validator.validateResponse(validatorRequest.getPath(), validatorRequest.getMethod(), validatorResponse);
160+
if(responseValidationReport.hasErrors()) {
161+
logger.warn("upstream response does not match specification: {}", responseValidationReport);
162+
if(failOnResponseViolation) {
163+
try {
164+
return responseValidationErrorHandler.handle(
165+
injectReportToContext(context, responseValidationReport), request)
166+
.get();
167+
} catch (InterruptedException | ExecutionException e) {
168+
logger.error("exception while handling the response", e);
169+
return new Response(Status.INTERNAL_SERVER_ERROR);
170+
}
171+
}
172+
}
173+
return response;
174+
});
175+
}
176+
177+
private static Context injectReportToContext(final Context parent, final ValidationReport report) {
178+
Context context = parent;
179+
if(!parent.containsContext(AttributesContext.class)) {
180+
context = new AttributesContext(parent);
181+
}
182+
context.asContext(AttributesContext.class).getAttributes().put(ATTR_OPENAPI_VALIDATION_REPORT, report.getMessages());
183+
return context;
184+
}
185+
186+
private static Response buildErrorResponse(final Status status, final String body) {
187+
final Response response = new Response(status);
188+
response.getHeaders().put("Content-Type", "text/plain; charset=UTF-8");
189+
response.setEntity(body);
190+
return response;
191+
}
192+
193+
private static SimpleRequest validatorRequestOf(final Request request) throws IOException {
194+
SimpleRequest.Builder builder = new SimpleRequest.Builder(request.getMethod(), request.getUri().getPath());
195+
if(request.getEntity().getBytes().length > 0) {
196+
builder.withBody(request.getEntity().getBytes());
197+
}
198+
199+
if (request.getHeaders() != null) {
200+
request.getHeaders().asMapOfHeaders().forEach((key, value) -> builder.withHeader(key, value.getValues()));
201+
if(request.getEntity().getBytes().length > 0
202+
&& request.getHeaders().keySet().stream().noneMatch(k -> k.equalsIgnoreCase("Content-Type"))) {
203+
builder.withHeader("Content-Type", "application/json");
204+
}
205+
}
206+
207+
List<NameValuePair> params = URLEncodedUtils.parse(request.getUri().asURI(), StandardCharsets.UTF_8);
208+
209+
Map<String, List<String>> paramsMap = params.stream()
210+
.collect(Collectors.groupingBy(
211+
NameValuePair::getName,
212+
Collectors.mapping(NameValuePair::getValue, Collectors.toList())
213+
));
214+
paramsMap.forEach(builder::withQueryParam);
215+
216+
return builder.build();
217+
}
218+
219+
private static SimpleResponse validatorResponseOf(final Response response) throws IOException {
220+
final SimpleResponse.Builder builder = new SimpleResponse.Builder(response.getStatus().getCode());
221+
if(response.getEntity().getBytes().length > 0) {
222+
builder.withBody(response.getEntity().getBytes());
223+
}
224+
225+
if (response.getHeaders() != null) {
226+
response.getHeaders().asMapOfHeaders().forEach((key, value) -> builder.withHeader(key, value.getValues()));
227+
if(response.getEntity().getBytes().length > 0
228+
&& response.getHeaders().keySet().stream().noneMatch(k -> k.equalsIgnoreCase("Content-Type"))) {
229+
builder.withHeader("Content-Type", "application/json");
230+
}
231+
}
232+
return builder.build();
233+
}
234+
235+
public static Handler defaultRequestValidationErrorHandler() {
236+
return (context, request) ->
237+
Promises.newResultPromise(buildErrorResponse(Status.BAD_REQUEST,
238+
"Request validation failed: " + context.asContext(AttributesContext.class)
239+
.getAttributes().get(ATTR_OPENAPI_VALIDATION_REPORT).toString()));
240+
}
241+
242+
public static Handler defaultResponseValidationErrorHandler() {
243+
return (context, request) ->
244+
Promises.newResultPromise(buildErrorResponse(Status.SERVICE_UNAVAILABLE,
245+
"Response validation failed: " + context.asContext(AttributesContext.class)
246+
.getAttributes().get(ATTR_OPENAPI_VALIDATION_REPORT).toString()));
247+
}
248+
249+
public static class Heaplet extends GenericHeaplet {
250+
251+
@Override
252+
public Object create() throws HeapException {
253+
254+
JsonValue evaluatedConfig = config.as(evaluatedWithHeapProperties());
255+
final String openApiSpec = evaluatedConfig.get("spec").required().asString();
256+
257+
final boolean failOnResponseViolation =
258+
evaluatedConfig.get("failOnResponseViolation").defaultTo(false).asBoolean();
259+
260+
Handler requestValidationErrorHandler = config.get("requestValidationErrorHandler")
261+
.as(optionalHeapObject(heap, Handler.class));
262+
requestValidationErrorHandler = requestValidationErrorHandler == null ? defaultRequestValidationErrorHandler() : requestValidationErrorHandler;
263+
264+
Handler responseValidationErrorHandler = config.get("responseValidationErrorHandler")
265+
.as(optionalHeapObject(heap, Handler.class));
266+
responseValidationErrorHandler = responseValidationErrorHandler == null ? defaultResponseValidationErrorHandler() : responseValidationErrorHandler;
267+
268+
return new OpenApiValidationFilter(openApiSpec, failOnResponseViolation,
269+
requestValidationErrorHandler, responseValidationErrorHandler);
270+
271+
}
272+
}
273+
}

openig-core/src/main/java/org/forgerock/openig/handler/router/DirectoryMonitor.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* information: "Portions copyright [year] [name of copyright owner]".
1313
*
1414
* Copyright 2014-2016 ForgeRock AS.
15+
* Portions copyright 2026 3A Systems LLC
1516
*/
1617

1718
package org.forgerock.openig.handler.router;
@@ -85,7 +86,7 @@ class DirectoryMonitor {
8586
* a non-{@literal null} directory (it may or may not exists) to monitor
8687
*/
8788
public DirectoryMonitor(final File directory) {
88-
this(directory, new HashMap<File, Long>());
89+
this(directory, new HashMap<>());
8990
}
9091

9192
/**
@@ -177,15 +178,14 @@ FileChangeSet createFileChangeSet() {
177178
/**
178179
* Factory method to be used as a fluent {@link FileFilter} declaration.
179180
*
180-
* @return a filter for {@literal .json} files
181+
* @return a filter for {@literal .json and .yaml} files
181182
*/
182183
private static FileFilter jsonFiles() {
183-
return new FileFilter() {
184-
@Override
185-
public boolean accept(final File path) {
186-
return path.isFile() && path.getName().endsWith(".json");
187-
}
188-
};
184+
return path -> path.isFile() && (
185+
path.getName().endsWith(".json")
186+
|| path.getName().endsWith(".yaml")
187+
|| path.getName().endsWith(".yml")
188+
);
189189
}
190190

191191
void store(String routeId, JsonValue routeConfig) throws IOException {

0 commit comments

Comments
 (0)