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 ddd121df..fc634ac0 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
@@ -49,6 +49,7 @@
import org.forgerock.openig.handler.ClientHandler;
import org.forgerock.openig.handler.DesKeyGenHandler;
import org.forgerock.openig.handler.DispatchHandler;
+import org.forgerock.openig.handler.OpenApiMockResponseHandler;
import org.forgerock.openig.handler.ScriptableHandler;
import org.forgerock.openig.handler.SequenceHandler;
import org.forgerock.openig.handler.StaticResponseHandler;
@@ -102,6 +103,7 @@ public class CoreClassAliasResolver implements ClassAliasResolver {
ALIASES.put("KeyStore", KeyStoreHeaplet.class);
ALIASES.put("LocationHeaderFilter", LocationHeaderFilter.class);
ALIASES.put("MappedThrottlingPolicy", MappedThrottlingPolicyHeaplet.class);
+ ALIASES.put("OpenApiMockResponseHandler", OpenApiMockResponseHandler.class);
ALIASES.put("OpenApiValidationFilter", OpenApiValidationFilter.class);
ALIASES.put("PasswordReplayFilter", PasswordReplayFilterHeaplet.class);
ALIASES.put("Router", RouterHandler.class);
diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java b/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
new file mode 100644
index 00000000..46411dd5
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
@@ -0,0 +1,479 @@
+/*
+ * 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;
+
+import io.swagger.v3.oas.models.media.Schema;
+import net.datafaker.Faker;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.Random;
+import java.util.UUID;
+
+/**
+ * Generates realistic mock values for OpenAPI schema properties.
+ *
+ * Values are chosen using the following priority order:
+ *
+ * Schema {@code format} (date, date-time, email, uri, uuid, ipv4, hostname, byte, password, …)
+ * Field-name heuristic powered by Datafaker
+ * (case-insensitive, separator-agnostic lookup)
+ * Schema {@code type} fallback (generic string / integer / number / boolean)
+ *
+ *
+ * The generator uses a seeded {@link Faker} so results are deterministic and
+ * reproducible across test runs.
+ *
+ *
Numeric and string constraints ({@code minimum}, {@code maximum},
+ * {@code minLength}, {@code maxLength}) are respected when present.
+ */
+public class MockDataGenerator {
+
+ /** Seeded random shared by the Faker instance and numeric generators for deterministic output. */
+ private static final Random RNG = new Random(42L);
+
+ /** Datafaker instance backed by the same seed. */
+ private static final Faker FAKER = new Faker(Locale.ENGLISH, RNG);
+
+ // Boolean heuristic sets (normalised names)
+ private static final java.util.Set BOOL_TRUE_NAMES = new java.util.HashSet<>(
+ java.util.Arrays.asList(
+ "enabled", "active", "verified", "confirmed", "approved",
+ "published", "available", "visible", "ispublic", "success",
+ "valid", "required", "locked"
+ ));
+
+ private static final java.util.Set BOOL_FALSE_NAMES = new java.util.HashSet<>(
+ java.util.Arrays.asList(
+ "deleted", "archived", "banned", "blocked", "disabled",
+ "hidden", "isprivate", "deprecated", "failed", "error"
+ ));
+
+ private MockDataGenerator() {
+ // utility class
+ }
+
+ /**
+ * Generates a realistic mock value for the given field name and schema.
+ *
+ * @param fieldName the property name (may be {@code null} for anonymous/array items)
+ * @param schema the OpenAPI schema for this field
+ * @return a mock value compatible with the schema type
+ */
+ @SuppressWarnings("rawtypes")
+ public static Object generate(final String fieldName, final Schema> schema) {
+ if (schema == null) {
+ return fieldName != null ? fieldName + "-value" : "value";
+ }
+
+ final String format = schema.getFormat();
+ final String type = schema.getType();
+
+ // 1. Format-based generation
+ if (format != null) {
+ final Object formatValue = generateByFormat(format, schema);
+ if (formatValue != null) {
+ return formatValue;
+ }
+ }
+
+ // 2. Field-name-based (Datafaker)
+ if (fieldName != null) {
+ final Object fakerValue = generateByFieldName(fieldName, type, schema);
+ if (fakerValue != null) {
+ return fakerValue;
+ }
+ }
+
+ // 3. Type-based fallback
+ return generateByType(fieldName, type, schema);
+ }
+
+ /**
+ * Generates a value based on the OpenAPI format string.
+ *
+ * @return a value, or {@code null} if no specific generator exists for the format
+ */
+ private static Object generateByFormat(final String format, final Schema> schema) {
+ switch (format.toLowerCase()) {
+ case "date":
+ return LocalDate.now().minusDays(RNG.nextInt(365))
+ .format(DateTimeFormatter.ISO_LOCAL_DATE);
+ case "date-time":
+ return LocalDateTime.now().minusDays(RNG.nextInt(365))
+ .format(DateTimeFormatter.ISO_DATE_TIME) + "Z";
+ case "time":
+ return "12:00:00";
+ case "email":
+ return FAKER.internet().emailAddress();
+ case "uri":
+ case "url":
+ return "https://" + FAKER.internet().domainName();
+ case "uri-reference":
+ return "/api/resource/" + FAKER.number().numberBetween(1, 9999);
+ case "uuid":
+ case "guid":
+ return UUID.randomUUID().toString();
+ case "ipv4":
+ return FAKER.internet().ipV4Address();
+ case "ipv6":
+ return FAKER.internet().ipV6Address();
+ case "hostname":
+ return FAKER.internet().domainName();
+ case "byte":
+ return java.util.Base64.getEncoder().encodeToString(
+ FAKER.lorem().word().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ case "binary":
+ return "binary-data";
+ case "password":
+ return FAKER.internet().password(8, 16, true, true, true);
+ case "int32":
+ return generateInt(schema, FAKER.number().numberBetween(1, 10000));
+ case "int64":
+ return generateLong(schema, FAKER.number().numberBetween(1L, 100000L));
+ case "float":
+ case "double":
+ return generateDouble(schema, FAKER.number().randomDouble(2, 1, 10000));
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Attempts to generate a value using Datafaker based on the normalised field name.
+ *
+ * @return a value, or {@code null} if no heuristic matches the field name
+ */
+ @SuppressWarnings("squid:S3776")
+ private static Object generateByFieldName(final String fieldName,
+ final String type,
+ final Schema> schema) {
+ final String key = normalise(fieldName);
+
+ // --- Strings ---
+ // Personal
+ if (key.equals("firstname")) return coerce(FAKER.name().firstName(), type, schema);
+ if (key.equals("lastname")) return coerce(FAKER.name().lastName(), type, schema);
+ if (key.equals("fullname") || key.equals("displayname"))
+ return coerce(FAKER.name().fullName(), type, schema);
+ if (key.equals("name")) return coerce(FAKER.name().fullName(), type, schema);
+ if (key.equals("username") || key.equals("login"))
+ return coerce(FAKER.name().username(), type, schema);
+
+ // Contact
+ if (key.equals("email") || key.equals("mail"))
+ return coerce(FAKER.internet().emailAddress(), type, schema);
+ if (key.equals("phone") || key.equals("phonenumber") || key.equals("mobile"))
+ return coerce(FAKER.phoneNumber().phoneNumber(), type, schema);
+ if (key.equals("fax"))
+ return coerce(FAKER.phoneNumber().phoneNumber(), type, schema);
+
+ // Address
+ if (key.equals("address") || key.equals("street"))
+ return coerce(FAKER.address().streetAddress(), type, schema);
+ if (key.equals("city"))
+ return coerce(FAKER.address().city(), type, schema);
+ if (key.equals("state"))
+ return coerce(FAKER.address().state(), type, schema);
+ if (key.equals("country"))
+ return coerce(FAKER.address().country(), type, schema);
+ if (key.equals("zip") || key.equals("zipcode") || key.equals("postalcode"))
+ return coerce(FAKER.address().zipCode(), type, schema);
+
+ // Internet
+ if (key.equals("url") || key.equals("website") || key.equals("homepage"))
+ return coerce("https://" + FAKER.internet().domainName(), type, schema);
+ if (key.equals("avatar") || key.equals("photo") || key.equals("picture")
+ || key.equals("image") || key.equals("thumbnail"))
+ return coerce("https://" + FAKER.internet().domainName() + "/images/"
+ + FAKER.internet().slug() + ".jpg", type, schema);
+ if (key.equals("gravatar"))
+ return coerce("https://www.gravatar.com/avatar/" + FAKER.hashing().md5(), type, schema);
+
+ // Text / content
+ if (key.equals("description") || key.equals("summary") || key.equals("content")
+ || key.equals("body") || key.equals("note") || key.equals("comment")
+ || key.equals("text"))
+ return coerce(FAKER.lorem().sentence(8), type, schema);
+ if (key.equals("message"))
+ return coerce(FAKER.lorem().sentence(4), type, schema);
+ if (key.equals("title") || key.equals("subject"))
+ return coerce(FAKER.book().title(), type, schema);
+ if (key.equals("label"))
+ return coerce(FAKER.lorem().word(), type, schema);
+ if (key.equals("slug"))
+ return coerce(FAKER.internet().slug(), type, schema);
+ if (key.equals("tag") || key.equals("tags"))
+ return coerce(FAKER.lorem().word(), type, schema);
+ if (key.equals("category"))
+ return coerce(FAKER.book().genre(), type, schema);
+ if (key.equals("locale") || key.equals("language"))
+ return coerce("en-US", type, schema);
+ if (key.equals("timezone"))
+ return coerce(FAKER.address().timeZone(), type, schema);
+ if (key.equals("currency"))
+ return coerce(FAKER.currency().code(), type, schema);
+
+ // Business
+ if (key.equals("company") || key.equals("organisation") || key.equals("organization"))
+ return coerce(FAKER.company().name(), type, schema);
+ if (key.equals("department"))
+ return coerce(FAKER.commerce().department(), type, schema);
+ if (key.equals("role"))
+ return coerce(FAKER.job().title(), type, schema);
+ if (key.equals("team"))
+ return coerce(FAKER.team().name(), type, schema);
+ if (key.equals("project"))
+ return coerce(FAKER.app().name(), type, schema);
+ if (key.equals("version"))
+ return coerce(FAKER.app().version(), type, schema);
+ if (key.equals("code") || key.equals("reference"))
+ return coerce(FAKER.code().isbnGs1(), type, schema);
+
+ // Auth
+ if (key.equals("password"))
+ return coerce(FAKER.internet().password(8, 16, true, true, true), type, schema);
+ if (key.equals("token") || key.equals("accesstoken"))
+ return coerce(FAKER.hashing().sha256(), type, schema);
+ if (key.equals("refreshtoken"))
+ return coerce(FAKER.hashing().sha256(), type, schema);
+ if (key.equals("apikey"))
+ return coerce("sk-" + FAKER.hashing().sha256().substring(0, 24), type, schema);
+ if (key.equals("secret"))
+ return coerce(FAKER.hashing().sha256().substring(0, 16), type, schema);
+ if (key.equals("hash"))
+ return coerce(FAKER.hashing().md5(), type, schema);
+ if (key.equals("salt"))
+ return coerce(FAKER.hashing().sha256().substring(0, 8), type, schema);
+
+ // --- Numbers ---
+ if (key.equals("id") || key.equals("uid") || key.equals("userid") || key.equals("accountid"))
+ return coerce(FAKER.number().numberBetween(1001, 99999), type, schema);
+ if (key.equals("age"))
+ return coerce(FAKER.number().numberBetween(18, 80), type, schema);
+ if (key.equals("year"))
+ return coerce(FAKER.number().numberBetween(2000, 2024), type, schema);
+ if (key.equals("month"))
+ return coerce(FAKER.number().numberBetween(1, 12), type, schema);
+ if (key.equals("day"))
+ return coerce(FAKER.number().numberBetween(1, 28), type, schema);
+ if (key.equals("hour"))
+ return coerce(FAKER.number().numberBetween(0, 23), type, schema);
+ if (key.equals("minute") || key.equals("second"))
+ return coerce(FAKER.number().numberBetween(0, 59), type, schema);
+ if (key.equals("count") || key.equals("total"))
+ return coerce(FAKER.number().numberBetween(1, 200), type, schema);
+ if (key.equals("quantity") || key.equals("size"))
+ return coerce(FAKER.number().numberBetween(1, 50), type, schema);
+ if (key.equals("amount") || key.equals("price") || key.equals("cost"))
+ return coerce(FAKER.number().randomDouble(2, 1, 9999), type, schema);
+ if (key.equals("discount") || key.equals("tax"))
+ return coerce(FAKER.number().randomDouble(2, 0, 50), type, schema);
+ if (key.equals("rating"))
+ return coerce(FAKER.number().randomDouble(1, 1, 5), type, schema);
+ if (key.equals("score") || key.equals("rank"))
+ return coerce(FAKER.number().numberBetween(1, 100), type, schema);
+ if (key.equals("port"))
+ return coerce(FAKER.number().numberBetween(1024, 65535), type, schema);
+ if (key.equals("latitude"))
+ return coerce(Double.parseDouble(FAKER.address().latitude().replace(",", ".")), type, schema);
+ if (key.equals("longitude"))
+ return coerce(Double.parseDouble(FAKER.address().longitude().replace(",", ".")), type, schema);
+
+ // --- Booleans ---
+ if ("boolean".equals(type)) {
+ return generateBoolean(fieldName);
+ }
+
+ return null;
+ }
+
+ /**
+ * Generates a value based on schema type when no format or field-name match was found.
+ */
+ private static Object generateByType(final String fieldName, final String type, final Schema> schema) {
+ if (type == null) {
+ return fieldName != null ? FAKER.lorem().word() : "value";
+ }
+ switch (type.toLowerCase()) {
+ case "integer":
+ return generateInt(schema, FAKER.number().numberBetween(1, 10000));
+ case "number":
+ return generateDouble(schema, FAKER.number().randomDouble(2, 1, 10000));
+ case "boolean":
+ return generateBoolean(fieldName);
+ case "string":
+ return generateString(fieldName, schema);
+ case "array":
+ case "object":
+ default:
+ return fieldName != null ? FAKER.lorem().word() : "value";
+ }
+ }
+
+ /**
+ * Generates a realistic boolean value using field-name heuristics.
+ * Fields whose name implies truth (e.g. "enabled") return {@code true};
+ * fields implying falsehood (e.g. "deleted") return {@code false};
+ * all others default to {@code true}.
+ */
+ static boolean generateBoolean(final String fieldName) {
+ if (fieldName == null) {
+ return true;
+ }
+ final String key = normalise(fieldName);
+ if (BOOL_FALSE_NAMES.contains(key)) {
+ return false;
+ }
+ if (BOOL_TRUE_NAMES.contains(key)) {
+ return true;
+ }
+ // Heuristics for names not in the sets
+ if (key.startsWith("is") || key.startsWith("has") || key.startsWith("can")
+ || key.startsWith("should")) {
+ return true;
+ }
+ if (key.contains("delete") || key.contains("archive") || key.contains("disable")
+ || key.contains("block") || key.contains("ban")) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Generates a mock string value, respecting {@code minLength} / {@code maxLength} constraints.
+ */
+ private static String generateString(final String fieldName, final Schema> schema) {
+ String base = fieldName != null ? FAKER.lorem().word() : FAKER.lorem().word();
+ // Respect minLength
+ final Integer minLength = schema.getMinLength();
+ if (minLength != null && base.length() < minLength) {
+ final StringBuilder sb = new StringBuilder(base);
+ while (sb.length() < minLength) {
+ sb.append(FAKER.lorem().characters(1));
+ }
+ base = sb.toString();
+ }
+ // Respect maxLength
+ final Integer maxLength = schema.getMaxLength();
+ if (maxLength != null && base.length() > maxLength) {
+ base = base.substring(0, maxLength);
+ }
+ return base;
+ }
+
+ /**
+ * Generates an integer within schema {@code minimum} / {@code maximum} constraints.
+ */
+ static int generateInt(final Schema> schema, final int defaultValue) {
+ int min = defaultValue;
+ int max = defaultValue + 1000;
+ if (schema.getMinimum() != null) {
+ min = schema.getMinimum().intValue();
+ }
+ if (schema.getMaximum() != null) {
+ max = schema.getMaximum().intValue();
+ }
+ if (min >= max) {
+ return min;
+ }
+ return min + RNG.nextInt(max - min + 1);
+ }
+
+ /**
+ * Generates a long within schema {@code minimum} / {@code maximum} constraints.
+ */
+ private static long generateLong(final Schema> schema, final long defaultValue) {
+ long min = defaultValue;
+ long max = defaultValue + 1000L;
+ if (schema.getMinimum() != null) {
+ min = schema.getMinimum().longValue();
+ }
+ if (schema.getMaximum() != null) {
+ max = schema.getMaximum().longValue();
+ }
+ if (min >= max) {
+ return min;
+ }
+ return min + (long) (RNG.nextDouble() * (max - min));
+ }
+
+ /**
+ * Generates a double within schema {@code minimum} / {@code maximum} constraints.
+ */
+ static double generateDouble(final Schema> schema, final double defaultValue) {
+ double min = defaultValue;
+ double max = defaultValue + 100.0;
+ if (schema.getMinimum() != null) {
+ min = schema.getMinimum().doubleValue();
+ }
+ if (schema.getMaximum() != null) {
+ max = schema.getMaximum().doubleValue();
+ }
+ if (min >= max) {
+ return min;
+ }
+ return min + RNG.nextDouble() * (max - min);
+ }
+
+ /**
+ * Coerces a Datafaker-generated value to the expected schema type where possible.
+ */
+ private static Object coerce(final Object value, final String type, final Schema> schema) {
+ if (type == null) {
+ return value;
+ }
+ switch (type.toLowerCase()) {
+ case "integer":
+ if (value instanceof Number) {
+ return ((Number) value).intValue();
+ }
+ return generateInt(schema, FAKER.number().numberBetween(1, 10000));
+ case "number":
+ if (value instanceof Number) {
+ return ((Number) value).doubleValue();
+ }
+ return generateDouble(schema, FAKER.number().randomDouble(2, 1, 10000));
+ case "boolean":
+ if (value instanceof Boolean) {
+ return value;
+ }
+ return generateBoolean(null);
+ case "string":
+ if (value instanceof String) {
+ return value;
+ }
+ return String.valueOf(value);
+ default:
+ return value;
+ }
+ }
+
+ /**
+ * Normalises a field name by lower-casing it and removing all non-alphanumeric characters
+ * (e.g. underscores, hyphens, dots) so that {@code first_name}, {@code firstName},
+ * and {@code first-name} all map to the same lookup key.
+ */
+ static String normalise(final String name) {
+ if (name == null) {
+ return "";
+ }
+ return name.toLowerCase().replaceAll("[^a-z0-9]", "");
+ }
+}
diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java b/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
new file mode 100644
index 00000000..89741d52
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
@@ -0,0 +1,451 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import io.swagger.parser.OpenAPIParser;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.media.ArraySchema;
+import io.swagger.v3.oas.models.media.ComposedSchema;
+import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.MediaType;
+import io.swagger.v3.oas.models.media.Schema;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import io.swagger.v3.oas.models.responses.ApiResponses;
+import io.swagger.v3.oas.models.servers.Server;
+import io.swagger.v3.parser.core.models.ParseOptions;
+import io.swagger.v3.parser.core.models.SwaggerParseResult;
+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.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A {@link Handler} that generates valid mock HTTP responses with realistic test data
+ * derived from an OpenAPI / Swagger specification.
+ *
+ * Instead of proxying to an upstream service the handler:
+ *
+ * Matches the incoming request path + method against the paths declared in the spec.
+ * Locates the best response schema (prefers 200, then 201, then first 2xx, then
+ * {@code default}).
+ * Recursively generates a JSON body from that schema using {@link MockDataGenerator}.
+ * Returns the generated body with {@code Content-Type: application/json}.
+ *
+ *
+ * If no matching path is found the handler returns {@code 404 Not Found}; if a path is
+ * matched but the HTTP method is not declared the handler returns {@code 405 Method Not Allowed}.
+ *
+ *
Heap configuration
+ * {@code
+ * {
+ * "name": "MockHandler",
+ * "type": "OpenApiMockResponseHandler",
+ * "config": {
+ * "spec": "${read('/path/to/openapi.yaml')}",
+ * "defaultStatusCode": 200,
+ * "arraySize": 3
+ * }
+ * }
+ * }
+ *
+ *
+ * Configuration properties
+ * Property Type Required Default Description
+ * spec String Yes –
+ * OpenAPI spec content (YAML or JSON)
+ * defaultStatusCode Integer No 200
+ * HTTP status code to use for generated responses
+ * arraySize Integer No 1
+ * Number of items to generate for array-typed responses
+ *
+ */
+public class OpenApiMockResponseHandler implements Handler {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenApiMockResponseHandler.class);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ static {
+ MAPPER.registerModule(new JavaTimeModule());
+ MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ }
+
+ private final OpenAPI openAPI;
+
+ private final int defaultStatusCode;
+
+ private final int arraySize;
+
+ /**
+ * Creates a new mock handler backed by the supplied spec content.
+ *
+ * @param specContent the raw OpenAPI spec (YAML or JSON)
+ * @param defaultStatusCode HTTP status code to use for generated responses
+ * @param arraySize number of items to generate for array-typed responses
+ */
+ public OpenApiMockResponseHandler(final String specContent,
+ final int defaultStatusCode,
+ final int arraySize) {
+ final ParseOptions options = new ParseOptions();
+ options.setResolve(true);
+ options.setResolveFully(true);
+ final SwaggerParseResult result =
+ new OpenAPIParser().readContents(specContent, null, options);
+ if (result.getMessages() != null && !result.getMessages().isEmpty()) {
+ logger.warn("OpenAPI spec parse warnings: {}", result.getMessages());
+ }
+ this.openAPI = result.getOpenAPI();
+ this.defaultStatusCode = defaultStatusCode;
+ this.arraySize = arraySize;
+ }
+
+ // Package-private constructor for tests (allows injecting a pre-parsed spec)
+ OpenApiMockResponseHandler(final OpenAPI openAPI, final int defaultStatusCode, final int arraySize) {
+ this.openAPI = openAPI;
+ this.defaultStatusCode = defaultStatusCode;
+ this.arraySize = arraySize;
+ }
+
+ @Override
+ public Promise handle(final Context context, final Request request) {
+ if (openAPI == null || openAPI.getPaths() == null) {
+ return Promises.newResultPromise(jsonResponse(Status.valueOf(defaultStatusCode), "{}"));
+ }
+
+ final String requestPath = request.getUri().getPath();
+ final String requestMethod = request.getMethod().toUpperCase();
+
+ // Find matching path template
+ PathItem matchedPathItem = null;
+ String matchedTemplate = null;
+ final String basePath = getBasePath(openAPI);
+ for (Map.Entry entry : openAPI.getPaths().entrySet()) {
+ final String entryPath = basePath.isEmpty()
+ ? entry.getKey()
+ : basePath.concat(entry.getKey());
+ if (pathMatches(entryPath, requestPath)) {
+ matchedPathItem = entry.getValue();
+ matchedTemplate = entry.getKey();
+ break;
+ }
+ }
+
+ if (matchedPathItem == null) {
+ logger.debug("No matching path for {}", requestPath);
+ return Promises.newResultPromise(new Response(Status.NOT_FOUND));
+ }
+
+ // Find operation for method
+ final Operation operation = getOperation(matchedPathItem, requestMethod);
+ if (operation == null) {
+ logger.debug("No operation for {} {}", requestMethod, matchedTemplate);
+ return Promises.newResultPromise(new Response(Status.METHOD_NOT_ALLOWED));
+ }
+
+ // Resolve best response schema
+ final Schema> schema = bestResponseSchema(operation);
+ final Object body = generateBody(schema);
+
+ final String json;
+ try {
+ json = MAPPER.writeValueAsString(body);
+ } catch (JsonProcessingException e) {
+ logger.error("Failed to serialise mock response", e);
+ return Promises.newResultPromise(new Response(Status.INTERNAL_SERVER_ERROR));
+ }
+
+ return Promises.newResultPromise(jsonResponse(Status.valueOf(defaultStatusCode), json));
+ }
+
+ // -----------------------------------------------------------------------
+ // Path matching
+ // -----------------------------------------------------------------------
+
+ /**
+ * Returns {@code true} if the concrete {@code requestPath} matches the OpenAPI
+ * path template (which may contain {@code {paramName}} placeholders).
+ */
+ static boolean pathMatches(final String template, final String requestPath) {
+ if (template == null || requestPath == null) {
+ return false;
+ }
+ // Convert template to regex: escape dots, replace {param} with [^/]+
+ final String regex = "^"
+ + template.replace(".", "\\.")
+ .replaceAll("\\{[^/{}]+}", "[^/]+")
+ + "$";
+ return requestPath.matches(regex);
+ }
+
+ // -----------------------------------------------------------------------
+ // Operation lookup
+ // -----------------------------------------------------------------------
+
+ private static Operation getOperation(final PathItem pathItem, final String method) {
+ switch (method) {
+ case "GET": return pathItem.getGet();
+ case "PUT": return pathItem.getPut();
+ case "POST": return pathItem.getPost();
+ case "DELETE": return pathItem.getDelete();
+ case "OPTIONS": return pathItem.getOptions();
+ case "HEAD": return pathItem.getHead();
+ case "PATCH": return pathItem.getPatch();
+ case "TRACE": return pathItem.getTrace();
+ default: return null;
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Response schema resolution
+ // -----------------------------------------------------------------------
+
+ /**
+ * Picks the best {@link Schema} from the operation's responses.
+ * Priority: 200 → 201 → first 2xx → default.
+ *
+ * @return the schema, or {@code null} if none is declared
+ */
+ @SuppressWarnings("rawtypes")
+ static Schema> bestResponseSchema(final Operation operation) {
+ final ApiResponses responses = operation.getResponses();
+ if (responses == null || responses.isEmpty()) {
+ return null;
+ }
+
+ // Try status codes in preference order
+ for (final String code : new String[]{"200", "201"}) {
+ final Schema> s = schemaFromResponse(responses.get(code));
+ if (s != null) {
+ return s;
+ }
+ }
+
+ // First 2xx
+ for (final Map.Entry entry : responses.entrySet()) {
+ if (entry.getKey().startsWith("2")) {
+ final Schema> s = schemaFromResponse(entry.getValue());
+ if (s != null) {
+ return s;
+ }
+ }
+ }
+
+ // default
+ return schemaFromResponse(responses.getDefault());
+ }
+
+ @SuppressWarnings("rawtypes")
+ private static Schema> schemaFromResponse(final ApiResponse apiResponse) {
+ if (apiResponse == null) {
+ return null;
+ }
+ final Content content = apiResponse.getContent();
+ if (content == null || content.isEmpty()) {
+ return null;
+ }
+ // Prefer application/json, then first entry
+ MediaType mediaType = content.get("application/json");
+ if (mediaType == null) {
+ mediaType = content.values().iterator().next();
+ }
+ return mediaType == null ? null : mediaType.getSchema();
+ }
+
+ private static String getBasePath(OpenAPI spec) {
+ if (spec.getServers() == null || spec.getServers().isEmpty()) {
+ return "";
+ }
+ final Server server = spec.getServers().get(0);
+ if (server.getUrl() == null || server.getUrl().isBlank()
+ || server.getUrl().equals("/")) {
+ return "";
+ }
+ // Remove trailing slash
+ String url = server.getUrl().trim();
+ if (url.endsWith("/")) {
+ url = url.substring(0, url.length() - 1);
+ }
+
+ try {
+ return new URI(url).getPath();
+ } catch (URISyntaxException e) {
+ logger.warn("error parsing base URI: {}", e.toString());
+ return "";
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Body generation
+ // -----------------------------------------------------------------------
+
+ /**
+ * Generates a Java object graph from the supplied schema that can be serialised to JSON.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ Object generateBody(final Schema> schema) {
+ return generateValue(null, schema, 0);
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private Object generateValue(final String fieldName, final Schema> schema, final int depth) {
+ if (schema == null) {
+ return null;
+ }
+
+ // Depth guard to prevent infinite recursion on circular $refs
+ if (depth > 10) {
+ return null;
+ }
+
+ // 1. Use example value if present (highest priority)
+ if (schema.getExample() != null) {
+ return schema.getExample();
+ }
+
+ // 2. Use first enum value if defined
+ final List> enums = schema.getEnum();
+ if (enums != null && !enums.isEmpty()) {
+ return enums.get(0);
+ }
+
+ // 3. Handle composed schemas (allOf / oneOf / anyOf)
+ if (schema instanceof ComposedSchema) {
+ final ComposedSchema composed = (ComposedSchema) schema;
+ if (composed.getAllOf() != null && !composed.getAllOf().isEmpty()) {
+ return generateAllOf(composed.getAllOf(), depth);
+ }
+ if (composed.getOneOf() != null && !composed.getOneOf().isEmpty()) {
+ return generateValue(fieldName, composed.getOneOf().get(0), depth + 1);
+ }
+ if (composed.getAnyOf() != null && !composed.getAnyOf().isEmpty()) {
+ return generateValue(fieldName, composed.getAnyOf().get(0), depth + 1);
+ }
+ }
+
+ final String type = schema.getType();
+
+ // 4. Object type
+ if ("object".equals(type) || (type == null && schema.getProperties() != null)) {
+ return generateObject(schema, depth);
+ }
+
+ // 5. Array type
+ if ("array".equals(type) || schema instanceof ArraySchema) {
+ return generateArray(fieldName, schema, depth);
+ }
+
+ // 6. Delegate to MockDataGenerator for primitives
+ return MockDataGenerator.generate(fieldName, schema);
+ }
+
+ /**
+ * Merges all schemas from an {@code allOf} list into a single object map.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private Map generateAllOf(final List schemas, final int depth) {
+ final Map merged = new LinkedHashMap<>();
+ for (final Schema> s : schemas) {
+ final Object v = generateValue(null, s, depth + 1);
+ if (v instanceof Map) {
+ merged.putAll((Map) v);
+ }
+ }
+ return merged;
+ }
+
+ /**
+ * Generates a JSON object (Map) from the schema's properties.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private Map generateObject(final Schema> schema, final int depth) {
+ final Map obj = new LinkedHashMap<>();
+ final Map properties = schema.getProperties();
+ if (properties == null || properties.isEmpty()) {
+ return obj;
+ }
+ for (final Map.Entry entry : properties.entrySet()) {
+ obj.put(entry.getKey(), generateValue(entry.getKey(), entry.getValue(), depth + 1));
+ }
+ return obj;
+ }
+
+ /**
+ * Generates a JSON array from the schema's items sub-schema.
+ */
+ @SuppressWarnings({"rawtypes"})
+ private List generateArray(final String fieldName, final Schema> schema, final int depth) {
+ final Schema> items = schema instanceof ArraySchema
+ ? ((ArraySchema) schema).getItems()
+ : schema.getItems();
+ final List list = new ArrayList<>();
+ final int count = arraySize > 0 ? arraySize : 1;
+ for (int i = 0; i < count; i++) {
+ list.add(generateValue(fieldName, items, depth + 1));
+ }
+ return list;
+ }
+
+ // -----------------------------------------------------------------------
+ // Response helpers
+ // -----------------------------------------------------------------------
+
+ private static Response jsonResponse(final Status status, final String body) {
+ final Response response = new Response(status);
+ response.getHeaders().put("Content-Type", "application/json");
+ response.setEntity(body);
+ return response;
+ }
+
+ // -----------------------------------------------------------------------
+ // Heaplet
+ // -----------------------------------------------------------------------
+
+ /**
+ * Creates and initialises an {@link OpenApiMockResponseHandler} in a heap environment.
+ */
+ public static class Heaplet extends GenericHeaplet {
+ @Override
+ public Object create() throws HeapException {
+ final JsonValue evaluatedConfig = config.as(evaluatedWithHeapProperties());
+ final String spec = evaluatedConfig.get("spec").required().asString();
+ final int defaultStatusCode = evaluatedConfig.get("defaultStatusCode").defaultTo(200).asInteger();
+ final int arraySize = evaluatedConfig.get("arraySize").defaultTo(1).asInteger();
+ return new OpenApiMockResponseHandler(spec, defaultStatusCode, arraySize);
+ }
+ }
+}
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 982a7f88..31e9d3ff 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
@@ -61,6 +61,9 @@ public class OpenApiRouteBuilder {
/** Name used to reference the validation filter inside the heap. */
private static final String VALIDATOR_HEAP_NAME = "OpenApiValidator";
+ /** Name used to reference the mock handler inside the heap. */
+ private static final String MOCK_HANDLER_HEAP_NAME = "OpenApiMockHandler";
+
/**
* Builds an OpenIG route {@link JsonValue} for the supplied OpenAPI specification.
*
@@ -74,17 +77,38 @@ public class OpenApiRouteBuilder {
* @return a {@link JsonValue} that can be passed directly to the {@code RouterHandler}'s
* internal route-loading mechanism
*/
-
public JsonValue buildRouteJson(final OpenAPI spec, final File specFile, boolean failOnResponseViolation) {
+ return buildRouteJson(spec, specFile, failOnResponseViolation, false);
+ }
+
+ /**
+ * Builds an OpenIG route {@link JsonValue} for the supplied OpenAPI specification.
+ *
+ * @param spec the parsed OpenAPI model
+ * @param specFile the original spec file on disk (used for the validator config and as a
+ * fallback route name)
+ * @param failOnResponseViolation if {@code true}, the generated
+ * {@code OpenApiValidationFilter} will return
+ * {@code 502 Bad Gateway} when a response violates the spec;
+ * if {@code false} (default), violations are only logged
+ * @param mockMode if {@code true}, the route handler chain terminates at an
+ * {@code OpenApiMockResponseHandler} instead of a {@code ClientHandler};
+ * when {@code false} (default) requests are forwarded upstream
+ * @return a {@link JsonValue} that can be passed directly to the {@code RouterHandler}'s
+ * internal route-loading mechanism
+ */
+ public JsonValue buildRouteJson(final OpenAPI spec, final File specFile,
+ boolean failOnResponseViolation, boolean mockMode) {
final String routeName = deriveRouteName(spec, specFile);
final String condition = buildConditionExpression(spec);
final String baseUri = extractBaseUri(spec);
- logger.info("Building OpenAPI route '{}' from spec file '{}' (condition: {}, baseUri: {}, failOnResponseViolation: {})",
- routeName, specFile.getName(), condition, baseUri != null ? baseUri : "", failOnResponseViolation);
+ logger.info("Building OpenAPI route '{}' from spec file '{}' (condition: {}, baseUri: {}, "
+ + "failOnResponseViolation: {}, mockMode: {})",
+ routeName, specFile.getName(), condition, baseUri != null ? baseUri : "",
+ failOnResponseViolation, mockMode);
-
- // ----- heap: one OpenApiValidationFilter entry -----
+ // ----- heap: OpenApiValidationFilter entry -----
final Map validatorConfig = new LinkedHashMap<>();
validatorConfig.put("spec", "${read('" + specFile.getAbsolutePath() + "')}");
validatorConfig.put("failOnResponseViolation", failOnResponseViolation);
@@ -94,10 +118,29 @@ public JsonValue buildRouteJson(final OpenAPI spec, final File specFile, boolean
validatorHeapObject.put("type", "OpenApiValidationFilter");
validatorHeapObject.put("config", validatorConfig);
- // ----- handler: Chain -> [OpenApiValidationFilter] -> ClientHandler -----
+ final List heapObjects = new ArrayList<>();
+ heapObjects.add(validatorHeapObject);
+
+ final String terminalHandlerName;
+ if (mockMode) {
+ // ----- heap: OpenApiMockResponseHandler entry -----
+ final Map mockConfig = new LinkedHashMap<>();
+ mockConfig.put("spec", "${read('" + specFile.getAbsolutePath() + "')}");
+
+ final Map mockHeapObject = new LinkedHashMap<>();
+ mockHeapObject.put("name", MOCK_HANDLER_HEAP_NAME);
+ mockHeapObject.put("type", "OpenApiMockResponseHandler");
+ mockHeapObject.put("config", mockConfig);
+ heapObjects.add(mockHeapObject);
+ terminalHandlerName = MOCK_HANDLER_HEAP_NAME;
+ } else {
+ terminalHandlerName = "ClientHandler";
+ }
+
+ // ----- handler: Chain -> [OpenApiValidationFilter] -> -----
final Map chainConfig = new LinkedHashMap<>();
chainConfig.put("filters", List.of(VALIDATOR_HEAP_NAME));
- chainConfig.put("handler", "ClientHandler");
+ chainConfig.put("handler", terminalHandlerName);
final Map handlerObject = new LinkedHashMap<>();
handlerObject.put("type", "Chain");
@@ -111,12 +154,12 @@ public JsonValue buildRouteJson(final OpenAPI spec, final File specFile, boolean
routeMap.put("condition", condition);
}
- // Apply baseURI decorator when the spec declares a server URL
- if (baseUri != null) {
+ // Apply baseURI decorator when the spec declares a server URL (not needed in mock mode)
+ if (baseUri != null && !mockMode) {
routeMap.put("baseURI", baseUri);
}
- routeMap.put("heap", List.of(validatorHeapObject));
+ routeMap.put("heap", heapObjects);
routeMap.put("handler", handlerObject);
return json(routeMap);
@@ -198,7 +241,7 @@ private String buildConditionExpression(final OpenAPI spec) {
*
* Transformation rules (applied in order):
*
- * Literal {@code .} → {@code \.} (escape regex metachar)
+ * Literal {@code .} → {@code \\.} (escape regex metachar)
* Literal {@code +} → {@code \+} (escape regex metachar)
* {@code {paramName}} → {@code [^/]+} (path parameter → non-slash segment)
* Prepend {@code ^}, append {@code $} (full-path anchor)
@@ -208,7 +251,7 @@ private String buildConditionExpression(final OpenAPI spec) {
*
* {@code /pets} → {@code ^/pets$}
* {@code /pets/{id}} → {@code ^/pets/[^/]+$}
- * {@code /a.b/{x}/c} → {@code ^/a\.b/[^/]+/c$}
+ * {@code /a.b/{x}/c} → {@code ^/a\\.b/[^/]+/c$}
* {@code /v1/{org}/{repo}/releases} → {@code ^/v1/[^/]+/[^/]+/releases$}
*
*
@@ -221,7 +264,7 @@ static String pathToRegex(final String openApiPath) {
}
String regex = openApiPath;
// 1. Escape literal regex metacharacters that can appear in paths
- regex = regex.replace(".", "\\.");
+ regex = regex.replace(".", "\\\\.");
regex = regex.replace("+", "\\+");
// 2. Replace every {paramName} placeholder with a non-slash segment matcher
regex = regex.replaceAll("\\{[^/{}]+}", "[^/]+");
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 59adfad1..310e787f 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
@@ -88,7 +88,8 @@
* "scanInterval": 2 or "2 seconds"
* "openApiValidation": {
* "enabled": true,
- * "failOnResponseViolation": false
+ * "failOnResponseViolation": false,
+ * "mockMode": false
* }
* }
* }
@@ -106,6 +107,10 @@
*
* 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.
+ * When {@code openApiValidation.mockMode} is {@code true}, auto-generated routes use an
+ * {@link org.forgerock.openig.handler.OpenApiMockResponseHandler} instead of a
+ * {@code ClientHandler}, so requests are served with generated mock data instead of being
+ * forwarded upstream.
*
* @since 2.2
*/
@@ -443,7 +448,8 @@ private void loadOpenApiSpec(final File specFile) {
}
final JsonValue routeJson = openApiRouteBuilder.buildRouteJson(
- specOpt.get(), specFile, openApiValidationSettings.failOnResponseViolation);
+ specOpt.get(), specFile, openApiValidationSettings.failOnResponseViolation,
+ openApiValidationSettings.mockMode);
final String routeId = routeJson.get("name").asString();
try {
@@ -524,8 +530,9 @@ public Object create() throws HeapException {
final boolean openApiEnabled = oaConfig.get("enabled").defaultTo(true).asBoolean();
final boolean failOnResponseViolation = oaConfig.get("failOnResponseViolation")
.defaultTo(false).asBoolean();
+ final boolean mockMode = oaConfig.get("mockMode").defaultTo(false).asBoolean();
final OpenApiValidationSettings openApiValidationSettings =
- new OpenApiValidationSettings(openApiEnabled, failOnResponseViolation);
+ new OpenApiValidationSettings(openApiEnabled, failOnResponseViolation, mockMode);
final RouteBuilder routeBuilder = new RouteBuilder((HeapImpl) heap, qualified, registry);
@@ -621,15 +628,24 @@ public static final class OpenApiValidationSettings {
public final boolean failOnResponseViolation;
+ public final boolean mockMode;
+
public OpenApiValidationSettings(final boolean enabled,
final boolean failOnResponseViolation) {
+ this(enabled, failOnResponseViolation, false);
+ }
+
+ public OpenApiValidationSettings(final boolean enabled,
+ final boolean failOnResponseViolation,
+ final boolean mockMode) {
this.enabled = enabled;
this.failOnResponseViolation = failOnResponseViolation;
+ this.mockMode = mockMode;
}
public OpenApiValidationSettings() {
- this(true, false);
+ this(true, false, false);
}
}
diff --git a/openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java
new file mode 100644
index 00000000..6baf6a3d
--- /dev/null
+++ b/openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java
@@ -0,0 +1,315 @@
+/*
+ * 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;
+
+import io.swagger.v3.oas.models.media.Schema;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class MockDataGeneratorTest {
+
+ // -----------------------------------------------------------------------
+ // normalise
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void normalise_removesUnderscoresHyphensAndDots() {
+ assertThat(MockDataGenerator.normalise("first_name")).isEqualTo("firstname");
+ assertThat(MockDataGenerator.normalise("first-name")).isEqualTo("firstname");
+ assertThat(MockDataGenerator.normalise("first.name")).isEqualTo("firstname");
+ assertThat(MockDataGenerator.normalise("firstName")).isEqualTo("firstname");
+ assertThat(MockDataGenerator.normalise("FirstName")).isEqualTo("firstname");
+ }
+
+ @Test
+ public void normalise_handlesNullAndEmpty() {
+ assertThat(MockDataGenerator.normalise(null)).isEqualTo("");
+ assertThat(MockDataGenerator.normalise("")).isEqualTo("");
+ }
+
+ // -----------------------------------------------------------------------
+ // Format-based generation
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void generate_returnsEmail_forEmailFormat() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("email");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).contains("@");
+ }
+
+ @Test
+ public void generate_returnsIsoDate_forDateFormat() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("date");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).matches("\\d{4}-\\d{2}-\\d{2}");
+ }
+
+ @Test
+ public void generate_returnsIsoDateTime_forDateTimeFormat() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("date-time");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).contains("T");
+ assertThat((String) value).endsWith("Z");
+ }
+
+ @Test
+ public void generate_returnsUuid_forUuidFormat() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("uuid");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
+ }
+
+ @Test
+ public void generate_returnsUrl_forUriFormat() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("uri");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).startsWith("https://");
+ }
+
+ @Test
+ public void generate_returnsIpv4_forIpv4Format() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("ipv4");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).matches("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}");
+ }
+
+ @Test
+ public void generate_returnsPassword_forPasswordFormat() {
+ Schema schema = new Schema<>();
+ schema.setType("string");
+ schema.setFormat("password");
+
+ final Object value = MockDataGenerator.generate("anyField", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).isNotEmpty();
+ }
+
+ // -----------------------------------------------------------------------
+ // Field-name heuristics (Datafaker-backed)
+ // -----------------------------------------------------------------------
+
+ @DataProvider(name = "fieldNameCases")
+ public static Object[][] fieldNameCases() {
+ return new Object[][] {
+ {"firstName"},
+ {"lastName"},
+ {"username"},
+ {"email"},
+ {"phone"},
+ {"city"},
+ {"country"},
+ {"company"},
+ {"role"},
+ {"description"},
+ {"title"},
+ {"url"},
+ };
+ }
+
+ @Test(dataProvider = "fieldNameCases")
+ public void generate_usesFieldNameHeuristics_returnsNonEmptyString(final String fieldName) {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ final Object value = MockDataGenerator.generate(fieldName, schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).isNotEmpty();
+ }
+
+ @Test
+ public void generate_email_containsAtSign() {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ final Object value = MockDataGenerator.generate("email", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).contains("@");
+ }
+
+ @Test
+ public void generate_url_startsWithHttp() {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ final Object value = MockDataGenerator.generate("url", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).matches("https?://.*");
+ }
+
+ @Test
+ public void generate_normalisesFieldNameForLookup() {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ // "first_name" / "first-name" / "FIRSTNAME" should all normalise to "firstname"
+ // and each should return a non-empty string (Datafaker-backed)
+ assertThat(MockDataGenerator.generate("first_name", schema)).isInstanceOf(String.class);
+ assertThat((String) MockDataGenerator.generate("first_name", schema)).isNotEmpty();
+ assertThat(MockDataGenerator.generate("first-name", schema)).isInstanceOf(String.class);
+ assertThat(MockDataGenerator.generate("FIRSTNAME", schema)).isInstanceOf(String.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // Numeric generation with constraints
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void generateInt_respectsMinimumConstraint() {
+ final Schema schema = new Schema<>();
+ schema.setType("integer");
+ schema.setMinimum(BigDecimal.valueOf(500));
+ schema.setMaximum(BigDecimal.valueOf(510));
+
+ final int value = MockDataGenerator.generateInt(schema, 0);
+ assertThat(value).isBetween(500, 510);
+ }
+
+ @Test
+ public void generateInt_respectsMaximumConstraint() {
+ final Schema schema = new Schema<>();
+ schema.setType("integer");
+ schema.setMinimum(BigDecimal.valueOf(1));
+ schema.setMaximum(BigDecimal.valueOf(5));
+
+ for (int i = 0; i < 50; i++) {
+ final int value = MockDataGenerator.generateInt(schema, 0);
+ assertThat(value).isBetween(1, 5);
+ }
+ }
+
+ @Test
+ public void generateDouble_respectsMinMaxConstraint() {
+ final Schema schema = new Schema<>();
+ schema.setType("number");
+ schema.setMinimum(BigDecimal.valueOf(10.0));
+ schema.setMaximum(BigDecimal.valueOf(20.0));
+
+ final double value = MockDataGenerator.generateDouble(schema, 0);
+ assertThat(value).isBetween(10.0, 20.0);
+ }
+
+ // -----------------------------------------------------------------------
+ // Boolean heuristics
+ // -----------------------------------------------------------------------
+
+ @DataProvider(name = "booleanTrueCases")
+ public static Object[][] booleanTrueCases() {
+ return new Object[][] {
+ {"enabled"}, {"active"}, {"verified"}, {"confirmed"}, {"approved"},
+ {"published"}, {"available"}, {"isActive"}, {"hasAccess"},
+ };
+ }
+
+ @Test(dataProvider = "booleanTrueCases")
+ public void generateBoolean_returnsTrue_forPositiveNames(final String fieldName) {
+ assertThat(MockDataGenerator.generateBoolean(fieldName)).isTrue();
+ }
+
+ @DataProvider(name = "booleanFalseCases")
+ public static Object[][] booleanFalseCases() {
+ return new Object[][] {
+ {"deleted"}, {"archived"}, {"banned"}, {"blocked"}, {"disabled"},
+ };
+ }
+
+ @Test(dataProvider = "booleanFalseCases")
+ public void generateBoolean_returnsFalse_forNegativeNames(final String fieldName) {
+ assertThat(MockDataGenerator.generateBoolean(fieldName)).isFalse();
+ }
+
+ @Test
+ public void generateBoolean_returnsTrue_forUnknownFieldName() {
+ assertThat(MockDataGenerator.generateBoolean("unknownField")).isTrue();
+ assertThat(MockDataGenerator.generateBoolean(null)).isTrue();
+ }
+
+ // -----------------------------------------------------------------------
+ // Type-based fallbacks
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void generate_returnsInteger_forIntegerType() {
+ final Schema schema = new Schema<>();
+ schema.setType("integer");
+ final Object value = MockDataGenerator.generate("count", schema);
+ assertThat(value).isInstanceOf(Number.class);
+ }
+
+ @Test
+ public void generate_returnsNumber_forNumberType() {
+ final Schema schema = new Schema<>();
+ schema.setType("number");
+ final Object value = MockDataGenerator.generate("total", schema);
+ assertThat(value).isInstanceOf(Number.class);
+ }
+
+ @Test
+ public void generate_returnsBoolean_forBooleanType() {
+ final Schema schema = new Schema<>();
+ schema.setType("boolean");
+ final Object value = MockDataGenerator.generate("flag", schema);
+ assertThat(value).isInstanceOf(Boolean.class);
+ }
+
+ @Test
+ public void generate_returnsString_forStringType_withUnknownFieldName() {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ final Object value = MockDataGenerator.generate("unknownXyz", schema);
+ assertThat(value).isInstanceOf(String.class);
+ assertThat((String) value).isNotEmpty();
+ }
+
+ @Test
+ public void generate_handlesNullSchema() {
+ final Object value = MockDataGenerator.generate("field", null);
+ assertThat(value).isNotNull();
+ }
+
+ @Test
+ public void generate_handlesNullFieldName() {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ final Object value = MockDataGenerator.generate(null, schema);
+ assertThat(value).isNotNull();
+ }
+}
diff --git a/openig-core/src/test/java/org/forgerock/openig/handler/OpenApiMockResponseHandlerTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/OpenApiMockResponseHandlerTest.java
new file mode 100644
index 00000000..66a637ff
--- /dev/null
+++ b/openig-core/src/test/java/org/forgerock/openig/handler/OpenApiMockResponseHandlerTest.java
@@ -0,0 +1,440 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.parser.OpenAPIParser;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.media.ArraySchema;
+import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.MediaType;
+import io.swagger.v3.oas.models.media.Schema;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import io.swagger.v3.oas.models.responses.ApiResponses;
+import io.swagger.v3.parser.core.models.ParseOptions;
+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.HeapImpl;
+import org.forgerock.openig.heap.Name;
+import org.forgerock.services.context.RootContext;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.forgerock.json.JsonValue.field;
+import static org.forgerock.json.JsonValue.json;
+import static org.forgerock.json.JsonValue.object;
+
+/**
+ * Unit tests for {@link OpenApiMockResponseHandler}.
+ */
+public class OpenApiMockResponseHandlerTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String PETSTORE_SPEC = ""
+ + "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"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: A list of pets\n"
+ + " content:\n"
+ + " application/json:\n"
+ + " schema:\n"
+ + " type: array\n"
+ + " items:\n"
+ + " type: object\n"
+ + " properties:\n"
+ + " id:\n"
+ + " type: integer\n"
+ + " name:\n"
+ + " type: string\n"
+ + " example: doggie\n"
+ + " status:\n"
+ + " type: string\n"
+ + " enum: [available, pending, sold]\n"
+ + " post:\n"
+ + " summary: Create a pet\n"
+ + " responses:\n"
+ + " '201':\n"
+ + " description: Created\n"
+ + " content:\n"
+ + " application/json:\n"
+ + " schema:\n"
+ + " type: object\n"
+ + " properties:\n"
+ + " id:\n"
+ + " type: integer\n"
+ + " name:\n"
+ + " type: string\n"
+ + " /pets/{petId}:\n"
+ + " get:\n"
+ + " summary: Get pet by ID\n"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: A pet\n"
+ + " content:\n"
+ + " application/json:\n"
+ + " schema:\n"
+ + " type: object\n"
+ + " properties:\n"
+ + " id:\n"
+ + " type: integer\n"
+ + " name:\n"
+ + " type: string\n"
+ + " example: doggie\n";
+
+ private OpenApiMockResponseHandler handler;
+ private RootContext context;
+
+ @BeforeMethod
+ public void setUp() {
+ handler = new OpenApiMockResponseHandler(PETSTORE_SPEC, 200, 2);
+ context = new RootContext();
+ }
+
+ // -----------------------------------------------------------------------
+ // Basic path matching
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void handle_returns200_forMatchingGetRequest() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets")).get();
+ assertThat(response.getStatus()).isEqualTo(Status.OK);
+ }
+
+ @Test
+ public void handle_returns404_forUnmatchedPath() throws Exception {
+ final Response response = handler.handle(context, getRequest("/unknown")).get();
+ assertThat(response.getStatus()).isEqualTo(Status.NOT_FOUND);
+ }
+
+ @Test
+ public void handle_returns405_forWrongMethod() throws Exception {
+ final Response response = handler.handle(context, deleteRequest("/pets")).get();
+ assertThat(response.getStatus()).isEqualTo(Status.METHOD_NOT_ALLOWED);
+ }
+
+ // -----------------------------------------------------------------------
+ // Path-parameter matching
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void handle_returns200_forPathWithParameter() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets/42")).get();
+ assertThat(response.getStatus()).isEqualTo(Status.OK);
+ }
+
+ @Test
+ public void handle_returns404_forPathThatDoesNotMatchParameter() throws Exception {
+ // /pets/42/photos is not declared in the spec
+ final Response response = handler.handle(context, getRequest("/pets/42/photos")).get();
+ assertThat(response.getStatus()).isEqualTo(Status.NOT_FOUND);
+ }
+
+ // -----------------------------------------------------------------------
+ // JSON body
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void handle_setsContentTypeHeader() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets")).get();
+ assertThat(response.getHeaders().getFirst("Content-Type")).contains("application/json");
+ }
+
+ @Test
+ public void handle_returnsValidJson() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets")).get();
+ final String body = response.getEntity().getString();
+ assertThat(body).isNotNull().isNotBlank();
+ // Should not throw
+ MAPPER.readTree(body);
+ }
+
+ @Test
+ public void handle_returnsArray_whenSchemaTypeIsArray() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets")).get();
+ final String body = response.getEntity().getString();
+ assertThat(body).startsWith("[");
+ final List> list = MAPPER.readValue(body, List.class);
+ assertThat(list).hasSize(2); // arraySize=2
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void handle_usesExampleValue_fromSchema() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets/42")).get();
+ final String body = response.getEntity().getString();
+ final Map obj = MAPPER.readValue(body, Map.class);
+ // "name" has example: doggie
+ assertThat(obj.get("name")).isEqualTo("doggie");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void handle_usesFirstEnumValue_whenEnumIsDefined() throws Exception {
+ final Response response = handler.handle(context, getRequest("/pets")).get();
+ final String body = response.getEntity().getString();
+ final List> list = MAPPER.readValue(body, List.class);
+ assertThat(list).isNotEmpty();
+ // "status" has enum: [available, pending, sold] - first value should be used
+ assertThat(list.get(0).get("status")).isEqualTo("available");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void handle_returns201_forPostRequest() throws Exception {
+ final OpenApiMockResponseHandler postHandler =
+ new OpenApiMockResponseHandler(PETSTORE_SPEC, 201, 1);
+ final Response response = postHandler.handle(context, postRequest("/pets", "{}")).get();
+ assertThat(response.getStatus()).isEqualTo(Status.valueOf(201));
+ final Map obj = MAPPER.readValue(response.getEntity().getString(), Map.class);
+ assertThat(obj).containsKey("id");
+ assertThat(obj).containsKey("name");
+ }
+
+ // -----------------------------------------------------------------------
+ // pathMatches
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void pathMatches_returnsFalse_forNullArguments() {
+ assertThat(OpenApiMockResponseHandler.pathMatches(null, "/pets")).isFalse();
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets", null)).isFalse();
+ }
+
+ @Test
+ public void pathMatches_returnsTrue_forExactMatch() {
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets", "/pets")).isTrue();
+ }
+
+ @Test
+ public void pathMatches_returnsTrue_forParameterisedTemplate() {
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets/{id}", "/pets/42")).isTrue();
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets/{id}", "/pets/fluffy")).isTrue();
+ }
+
+ @Test
+ public void pathMatches_returnsFalse_forMismatch() {
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets/{id}", "/orders/42")).isFalse();
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets/{id}", "/pets")).isFalse();
+ assertThat(OpenApiMockResponseHandler.pathMatches("/pets/{id}", "/pets/42/photos")).isFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // bestResponseSchema
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void bestResponseSchema_prefers200OverOthers() {
+ final Operation op = buildOperation("200", "201", "202");
+ final Schema> schema = OpenApiMockResponseHandler.bestResponseSchema(op);
+ assertThat(schema).isNotNull();
+ // The 200 schema should be selected
+ assertThat(schema.getDescription()).isEqualTo("schema-200");
+ }
+
+ @Test
+ public void bestResponseSchema_falls_to201_when200IsAbsent() {
+ final Operation op = buildOperation("201", "202");
+ final Schema> schema = OpenApiMockResponseHandler.bestResponseSchema(op);
+ assertThat(schema).isNotNull();
+ assertThat(schema.getDescription()).isEqualTo("schema-201");
+ }
+
+ @Test
+ public void bestResponseSchema_returnsNull_whenNoResponseSchema() {
+ final Operation op = new Operation();
+ final ApiResponses responses = new ApiResponses();
+ responses.addApiResponse("200", new ApiResponse().description("OK")); // no content/schema
+ op.setResponses(responses);
+ assertThat(OpenApiMockResponseHandler.bestResponseSchema(op)).isNull();
+ }
+
+ @Test
+ public void bestResponseSchema_returnsNull_forNullResponses() {
+ final Operation op = new Operation();
+ assertThat(OpenApiMockResponseHandler.bestResponseSchema(op)).isNull();
+ }
+
+ // -----------------------------------------------------------------------
+ // generateBody
+ // -----------------------------------------------------------------------
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void generateBody_generatesObject_fromObjectSchema() {
+ final Schema schema = new Schema<>();
+ schema.setType("object");
+ final Map props = new LinkedHashMap<>();
+ final Schema nameSchema = new Schema<>();
+ nameSchema.setType("string");
+ props.put("name", nameSchema);
+ schema.setProperties(props);
+
+ final Object body = handler.generateBody(schema);
+ assertThat(body).isInstanceOf(Map.class);
+ final Map map = (Map) body;
+ assertThat(map).containsKey("name");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void generateBody_generatesArray_fromArraySchema() {
+ final ArraySchema schema = new ArraySchema();
+ final Schema items = new Schema<>();
+ items.setType("string");
+ schema.setItems(items);
+
+ final Object body = handler.generateBody(schema);
+ assertThat(body).isInstanceOf(List.class);
+ final List> list = (List>) body;
+ assertThat(list).hasSize(2); // arraySize=2
+ }
+
+ @Test
+ public void generateBody_returnsNull_forNullSchema() {
+ assertThat(handler.generateBody(null)).isNull();
+ }
+
+ // -----------------------------------------------------------------------
+ // Heaplet
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void heaplet_createsHandler_withValidSpec() throws Exception {
+ final HeapImpl heap = new HeapImpl(Name.of("test"));
+ final JsonValue config = json(object(
+ field("spec", PETSTORE_SPEC),
+ field("defaultStatusCode", 200),
+ field("arraySize", 3)));
+
+ final Object created = new OpenApiMockResponseHandler.Heaplet()
+ .create(Name.of("testMock"), config, heap);
+
+ assertThat(created).isInstanceOf(OpenApiMockResponseHandler.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // Nested schema generation
+ // -----------------------------------------------------------------------
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void handle_generatesNestedObjects() throws Exception {
+ final String nestedSpec = ""
+ + "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: Nested\n"
+ + " version: '1'\n"
+ + "paths:\n"
+ + " /users:\n"
+ + " get:\n"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: OK\n"
+ + " content:\n"
+ + " application/json:\n"
+ + " schema:\n"
+ + " type: object\n"
+ + " properties:\n"
+ + " id:\n"
+ + " type: integer\n"
+ + " address:\n"
+ + " type: object\n"
+ + " properties:\n"
+ + " city:\n"
+ + " type: string\n"
+ + " zip:\n"
+ + " type: string\n";
+
+ final OpenApiMockResponseHandler nestedHandler =
+ new OpenApiMockResponseHandler(nestedSpec, 200, 1);
+ final Response response = nestedHandler.handle(context, getRequest("/users")).get();
+
+ assertThat(response.getStatus()).isEqualTo(Status.OK);
+ final Map body = MAPPER.readValue(response.getEntity().getString(), Map.class);
+ assertThat(body).containsKey("address");
+ final Map address = (Map) body.get("address");
+ assertThat(address).containsKey("city");
+ assertThat(address).containsKey("zip");
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ private static Request getRequest(final String path) throws Exception {
+ final Request r = new Request();
+ r.setMethod("GET");
+ r.setUri("http://localhost" + path);
+ return r;
+ }
+
+ private static Request deleteRequest(final String path) throws Exception {
+ final Request r = new Request();
+ r.setMethod("DELETE");
+ r.setUri("http://localhost" + path);
+ return r;
+ }
+
+ private static Request postRequest(final String path, final String body) throws Exception {
+ final Request r = new Request();
+ r.setMethod("POST");
+ r.setUri("http://localhost" + path);
+ r.getHeaders().put("Content-Type", "application/json");
+ r.setEntity(body);
+ return r;
+ }
+
+ /**
+ * Builds an Operation that has a response entry for each supplied status code.
+ * Each response's schema has a {@code description} set to {@code "schema-"}
+ * so tests can identify which schema was selected.
+ */
+ private static Operation buildOperation(final String... codes) {
+ final Operation op = new Operation();
+ final ApiResponses responses = new ApiResponses();
+ for (final String code : codes) {
+ final Schema schema = new Schema<>();
+ schema.setDescription("schema-" + code);
+ final MediaType mt = new MediaType();
+ mt.setSchema(schema);
+ final Content content = new Content();
+ content.addMediaType("application/json", mt);
+ final ApiResponse ar = new ApiResponse();
+ ar.setContent(content);
+ responses.addApiResponse(code, ar);
+ }
+ op.setResponses(responses);
+ return op;
+ }
+}
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 83454db5..7b4d40cb 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
@@ -56,8 +56,8 @@ public static Object[][] pathToRegexCases() {
{ "/pets", "^/pets$" },
{ "/pets/{id}", "^/pets/[^/]+$" },
{ "/pets/{petId}/photos", "^/pets/[^/]+/photos$" },
- { "/v1/{org}/{repo}/releases", "^/v1/[^/]+/[^/]+/releases$" },
- { "/a.b/{x}", "^/a\\.b/[^/]+$" },
+ { "/v1/{org}/{repo}/releases", "^/v1/[^/]+/[^/]+/releases$" },
+ { "/a.b/{x}", "^/a\\\\.b/[^/]+$" },
{ "/items/{id+}", "^/items/[^/]+$" },
{ "/users", "^/users$" },
};
@@ -287,6 +287,64 @@ public void buildRouteJson_hasNoBaseUri_whenSpecHasNoServer() throws IOException
assertThat(build(spec).get("baseURI").isNull()).isTrue();
}
+ // ---- mockMode ---------------------------------------------------------
+
+ @Test
+ public void buildRouteJson_mockMode_handlerIsChainWithMockHandler() throws IOException {
+ final File spec = writeYaml("mock.yaml", specWithPaths("/pets"));
+ final JsonValue route = buildMock(spec);
+ final JsonValue handler = route.get("handler");
+ assertThat(handler.get("type").asString()).isEqualTo("Chain");
+ assertThat(handler.get("config").get("handler").asString()).isEqualTo("OpenApiMockHandler");
+ }
+
+ @Test
+ public void buildRouteJson_mockMode_heapContainsMockHandler() throws IOException {
+ final File spec = writeYaml("mock2.yaml", specWithPaths("/items"));
+ final List heap = buildMock(spec).get("heap").asList();
+
+ final boolean hasMock = heap.stream()
+ .filter(o -> o instanceof java.util.Map)
+ .map(o -> (java.util.Map, ?>) o)
+ .anyMatch(m -> "OpenApiMockResponseHandler".equals(m.get("type")));
+ assertThat(hasMock).isTrue();
+ }
+
+ @Test
+ public void buildRouteJson_mockMode_hasNoBaseUri_evenWhenSpecHasServer() throws IOException {
+ final File spec = writeYaml("mock-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");
+ // In mockMode, no baseURI should be set (requests stay local)
+ assertThat(buildMock(spec).get("baseURI").isNull()).isTrue();
+ }
+
+ @Test
+ public void buildRouteJson_mockMode_mockHandlerConfigContainsSpecPath() throws IOException {
+ final File spec = writeYaml("mock3.yaml", specWithPaths("/things"));
+ final List heap = buildMock(spec).get("heap").asList();
+
+ final java.util.Map, ?> mockEntry = heap.stream()
+ .filter(o -> o instanceof java.util.Map)
+ .map(o -> (java.util.Map, ?>) o)
+ .filter(m -> "OpenApiMockResponseHandler".equals(m.get("type")))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No OpenApiMockResponseHandler in heap"));
+
+ final java.util.Map, ?> config = (java.util.Map, ?>) mockEntry.get("config");
+ assertThat(config.get("spec").toString()).contains(spec.getAbsolutePath());
+ }
+
private File writeYaml(final String name, final String content) throws IOException {
final File file = new File(tempDir, name);
@@ -300,6 +358,12 @@ private JsonValue build(final File specFile) {
return routeBuilder.buildRouteJson(api.get(), specFile, true);
}
+ private JsonValue buildMock(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, false, true);
+ }
+
private static String specWithPaths(final String... paths) {
final StringBuilder sb = new StringBuilder()
.append("openapi: '3.0.3'\n")
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 b60f8727..716bb9fc 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
@@ -321,14 +321,14 @@ public void onChanges_deploysRoute_whenOpenApiSpecFileIsAdded() throws Exception
when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true);
when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec));
- when(mockOpenApiRouteBuilder.buildRouteJson(eq(fakeSpec), eq(specFile), anyBoolean())).thenReturn(routeJson);
+ when(mockOpenApiRouteBuilder.buildRouteJson(eq(fakeSpec), eq(specFile), anyBoolean(), anyBoolean())).thenReturn(routeJson);
when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute);
RouterHandler handler = newHandler();
handler.onChanges(addedChangeSet(specFile));
verify(mockSpecLoader).tryLoad(specFile);
- verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, false);
+ verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, false, false);
verify(mockRouteBuilder).build(any(), any(), any());
}
@@ -355,7 +355,7 @@ public void stop_destroysAllRoutes() throws Exception {
when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true);
when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec));
- when(mockOpenApiRouteBuilder.buildRouteJson(eq(fakeSpec), eq(specFile), anyBoolean())).thenReturn(routeJson);
+ when(mockOpenApiRouteBuilder.buildRouteJson(eq(fakeSpec), eq(specFile), anyBoolean(), anyBoolean())).thenReturn(routeJson);
when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute);
RouterHandler handler = newHandler();
@@ -380,7 +380,7 @@ public void onChanges_ignoresOpenApiSpecFile_whenEnabledIsFalse() throws Excepti
// 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(mockOpenApiRouteBuilder, never()).buildRouteJson(any(), any(), any(Boolean.class), any(Boolean.class));
verify(mockRouteBuilder, never()).build(any(), any(), any());
}
@@ -396,7 +396,7 @@ public void buildRouteJson_isCalledWithFalse_whenFailOnResponseViolationIsFalse(
when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true);
when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec));
- when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile, false))
+ when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile, false, false))
.thenReturn(routeJson);
when(mockRouteBuilder.build(any(), any(), any())).thenReturn(mockRoute);
@@ -404,7 +404,7 @@ public void buildRouteJson_isCalledWithFalse_whenFailOnResponseViolationIsFalse(
strictHandler.onChanges(addedChangeSet(specFile));
// Must be called with failOnResponseViolation=false
- verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, false);
+ verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, false, false);
}
@Test
@@ -419,14 +419,14 @@ public void buildRouteJson_isCalledWithTrue_whenFailOnResponseViolationIsTrue()
when(mockSpecLoader.isOpenApiFile(specFile)).thenReturn(true);
when(mockSpecLoader.tryLoad(specFile)).thenReturn(Optional.of(fakeSpec));
- when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile, true))
+ when(mockOpenApiRouteBuilder.buildRouteJson(fakeSpec, specFile, true, false))
.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);
+ verify(mockOpenApiRouteBuilder).buildRouteJson(fakeSpec, specFile, true, false);
}
@Test
diff --git a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
index e24e1d11..0a181eec 100644
--- a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
+++ b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
@@ -720,8 +720,9 @@ xref:filters-conf.adoc#OpenApiValidationFilter[OpenApiValidationFilter(5)].
"directory": expression,
"scanInterval": integer,
"openApiValidation": {
- "enabled": boolean
- "failOnResponseViolation": boolean
+ "enabled": boolean,
+ "failOnResponseViolation": boolean,
+ "mockMode": boolean
}
}
}
@@ -800,6 +801,18 @@ This setting applies only to auto-generated routes. Routes that declare an
+
Default: `false`.
+`"mockMode"`: __boolean, optional__::
+When set to `true`, auto-generated routes terminate at an
+`OpenApiMockResponseHandler` instead of forwarding the request to an upstream
+service via `ClientHandler`.
++
+Use this setting to stand up a fully self-contained mock API from an OpenAPI
+spec without any real upstream service. The mock handler generates a
+JSON response body that conforms to the response schema declared in the spec,
+using realistic test data derived from field names, formats, and examples.
++
+Default: `false`.
+
--
[#d210e3636]
@@ -1295,6 +1308,151 @@ See also xref:expressions-conf.adoc#Expressions[Expressions(5)].
==== Javadoc
link:{apidocs-url}/org/forgerock/openig/handler/SequenceHandler.html[org.forgerock.openig.handler.SequenceHandler, window=\_blank]
+'''
+[#OpenApiMockResponseHandler]
+=== OpenApiMockResponseHandler — generate mock responses from an OpenAPI spec
+
+[#openapi-mock-description]
+==== Description
+An `OpenApiMockResponseHandler` is a handler that generates valid mock HTTP responses
+with realistic test data based on an OpenAPI / Swagger specification.
+
+Instead of forwarding the request to an upstream service, the handler:
+
+. Matches the incoming request path + method against the paths declared in the spec.
+ Path parameter placeholders such as `{userId}` are matched against any non-slash segment.
+. Locates the best response schema for the matched operation.
+ The preference order is: `200` → `201` → first `2xx` → `default`.
+. Recursively generates a JSON body from the schema using the following priority rules for each field:
+ .. If the schema has an `example` value, it is used as-is.
+ .. If the schema has an `enum` list, the first value is used.
+ .. If the field format matches a known format (`date`, `date-time`, `email`, `uri`, `uuid`,
+ `ipv4`, `hostname`, `byte`, `password`, etc.), a format-appropriate value is generated.
+ .. If the field name matches a built-in dictionary entry (e.g. `firstName`, `email`, `phone`,
+ `city`, `company`, `token`), a realistic pre-defined value is returned.
+ .. Otherwise a type-appropriate generic value is generated.
+. Returns the generated body with `Content-Type: application/json`.
+
+If no matching path is found the handler returns `404 Not Found`.
+If the path is matched but the HTTP method is not declared the handler returns `405 Method Not Allowed`.
+
+Use this handler in combination with `OpenApiValidationFilter` to build a fully
+self-contained mock API from an OpenAPI spec, without any real upstream service.
+
+[#openapi-mock-usage]
+==== Usage
+
+[source, javascript]
+----
+{
+ "name": string,
+ "type": "OpenApiMockResponseHandler",
+ "config": {
+ "spec": expression,
+ "defaultStatusCode": integer,
+ "arraySize": integer
+ }
+}
+----
+
+[#openapi-mock-properties]
+==== Properties
+--
+
+`"spec"`: __expression, required__::
+The content of the OpenAPI specification (YAML or JSON) as a string expression.
++
+Typically written as `"${read('/path/to/openapi.yaml')}"` to read the spec from a file on disk.
++
+See also xref:expressions-conf.adoc#Expressions[Expressions(5)].
+
+`"defaultStatusCode"`: __integer, optional__::
+The HTTP status code used for generated responses.
++
+Default: `200`.
+
+`"arraySize"`: __integer, optional__::
+The number of items to generate when the response schema is of type `array`.
++
+Default: `1`.
+
+--
+
+[#openapi-mock-example]
+==== Example
+
+The following configuration creates a standalone mock handler for a Petstore API:
+
+[source, json]
+----
+{
+ "name": "PetstoreMock",
+ "type": "OpenApiMockResponseHandler",
+ "config": {
+ "spec": "${read('/opt/openig/config/specs/petstore.yaml')}",
+ "defaultStatusCode": 200,
+ "arraySize": 3
+ }
+}
+----
+
+To use it in a route with request validation:
+
+[source, json]
+----
+{
+ "name": "petstore-mock",
+ "heap": [
+ {
+ "name": "PetstoreValidator",
+ "type": "OpenApiValidationFilter",
+ "config": {
+ "spec": "${read('/opt/openig/config/specs/petstore.yaml')}",
+ "failOnResponseViolation": false
+ }
+ },
+ {
+ "name": "PetstoreMock",
+ "type": "OpenApiMockResponseHandler",
+ "config": {
+ "spec": "${read('/opt/openig/config/specs/petstore.yaml')}",
+ "arraySize": 3
+ }
+ }
+ ],
+ "handler": {
+ "type": "Chain",
+ "config": {
+ "filters": ["PetstoreValidator"],
+ "handler": "PetstoreMock"
+ }
+ }
+}
+----
+
+You can also enable mock mode globally via the Router's `openApiValidation.mockMode` setting,
+which automatically wires the mock handler for every OpenAPI spec file placed in the routes directory:
+
+[source, json]
+----
+{
+ "name": "Router",
+ "type": "Router",
+ "config": {
+ "directory": "/opt/openig/config/routes",
+ "openApiValidation": {
+ "enabled": true,
+ "failOnResponseViolation": false,
+ "mockMode": true
+ }
+ }
+}
+----
+
+[#openapi-mock-javadoc]
+==== Javadoc
+link:{apidocs-url}/org/forgerock/openig/handler/OpenApiMockResponseHandler.html[org.forgerock.openig.handler.OpenApiMockResponseHandler, window=\_blank]
+
'''
[#StaticResponseHandler]
=== StaticResponseHandler — create static response to a request
diff --git a/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_MockRoute.java b/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_MockRoute.java
new file mode 100644
index 00000000..6bb16368
--- /dev/null
+++ b/openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_MockRoute.java
@@ -0,0 +1,221 @@
+/*
+ * 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 io.restassured.RestAssured;
+import io.restassured.response.Response;
+import org.apache.commons.io.IOUtils;
+import org.hamcrest.Matchers;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Integration test for {@code OpenApiMockResponseHandler}.
+ *
+ * The test drops a route JSON that references the Petstore OpenAPI spec and wires
+ * {@code OpenApiMockResponseHandler} as the terminal handler (no upstream service needed).
+ * It then exercises the mock endpoints and validates:
+ *
+ * Collection responses return a JSON array of the configured {@code arraySize}
+ * Each pet object contains all required fields with realistic (Datafaker-generated) values
+ * Enum fields use a value from the declared enum list
+ * Requests for individual resources return a JSON object
+ * The route is unloaded cleanly when the config file is removed
+ *
+ */
+public class IT_MockRoute {
+
+ private static final Logger logger = LoggerFactory.getLogger(IT_MockRoute.class);
+
+ private static final String ROUTE_ID = "petstore-mock";
+
+ /**
+ * Deploys the mock petstore route, runs assertions, then removes the route and asserts
+ * that the endpoint returns 404 once the route has been unloaded.
+ */
+ @Test
+ public void testMockRoute_petCollection() throws IOException {
+ final String testConfigPath = getTestConfigPath();
+ final String specFile = getSpecFilePath();
+
+ // Prepare the route JSON with the actual spec file path substituted
+ final String routeContents = IOUtils.resourceToString(
+ "routes/petstore-mock.json", StandardCharsets.UTF_8,
+ getClass().getClassLoader())
+ .replace("$$SWAGGER_FILE$$", specFile);
+
+ final Path routeDest = Path.of(testConfigPath, "config", "routes", "petstore-mock.json");
+ Files.createDirectories(routeDest.getParent());
+ Files.writeString(routeDest, routeContents);
+
+ try {
+ // Wait for the route to become active
+ await().pollInterval(3, SECONDS)
+ .atMost(30, SECONDS)
+ .until(() -> routeAvailable(ROUTE_ID));
+
+ // GET /v2/pet/findByStatus?status=available → 200 JSON array
+ final String body = RestAssured
+ .given()
+ .when()
+ .get("/v2/pet/findByStatus?status=available")
+ .then()
+ .statusCode(200)
+ .contentType("application/json")
+ .body("$", Matchers.instanceOf(List.class))
+ .extract().asString();
+
+ logger.info("Mock pet collection response: {}", body);
+
+ // Parse and assert array contents
+ @SuppressWarnings("unchecked")
+ final List> pets =
+ RestAssured.given().when().get("/v2/pet/findByStatus?status=available")
+ .jsonPath().getList("$");
+
+ assertThat(pets).isNotEmpty();
+ assertThat(pets).hasSize(2); // arraySize=2 in petstore-mock.json
+
+ for (final Map pet : pets) {
+ // id must be an integer
+ assertThat(pet).containsKey("id");
+ assertThat(pet.get("id")).isInstanceOf(Integer.class);
+
+ // name has example: "doggie" in spec → should equal "doggie"
+ assertThat(pet).containsKey("name");
+ assertThat(pet.get("name")).isEqualTo("doggie");
+
+ // status is an enum [available, pending, sold] → first value used
+ assertThat(pet).containsKey("status");
+ assertThat(pet.get("status")).isIn("available", "pending", "sold");
+
+ // photoUrls is a required array
+ assertThat(pet).containsKey("photoUrls");
+ }
+ } finally {
+ Files.deleteIfExists(routeDest);
+ }
+
+ // Route should be unloaded after the file is deleted
+ await().pollInterval(3, SECONDS)
+ .atMost(30, SECONDS)
+ .until(() -> !routeAvailable(ROUTE_ID));
+
+ RestAssured.given().when()
+ .get("/v2/pet/findByStatus?status=available")
+ .then()
+ .statusCode(404);
+ }
+
+ /**
+ * Verifies that requesting a single pet by ID returns a JSON object with the
+ * expected fields, and that Datafaker generates realistic string values.
+ */
+ @Test
+ public void testMockRoute_singlePet() throws IOException {
+ final String testConfigPath = getTestConfigPath();
+ final String specFile = getSpecFilePath();
+
+ final String routeContents = IOUtils.resourceToString(
+ "routes/petstore-mock.json", StandardCharsets.UTF_8,
+ getClass().getClassLoader())
+ .replace("$$SWAGGER_FILE$$", specFile);
+
+ final Path routeDest = Path.of(testConfigPath, "config", "routes", "petstore-mock-single.json");
+ final String routeWithDifferentName = routeContents.replace(
+ "\"name\": \"petstore-mock\"", "\"name\": \"petstore-mock-single\"");
+ Files.createDirectories(routeDest.getParent());
+ Files.writeString(routeDest, routeWithDifferentName);
+
+ try {
+ await().pollInterval(3, SECONDS)
+ .atMost(30, SECONDS)
+ .until(() -> routeAvailable("petstore-mock-single"));
+
+ // GET /v2/pet/{petId} → single object
+ @SuppressWarnings("unchecked")
+ final Map pet =
+ RestAssured.given().when().get("/v2/pet/42")
+ .then()
+ .statusCode(200)
+ .contentType("application/json")
+ .extract()
+ .jsonPath().getMap("$");
+
+ logger.info("Mock single pet response: {}", pet);
+
+ assertThat(pet).containsKey("id");
+ assertThat(pet.get("id")).isInstanceOf(Integer.class);
+
+ // name has example: "doggie"
+ assertThat(pet).containsKey("name");
+ assertThat(pet.get("name")).isEqualTo("doggie");
+ } finally {
+ Files.deleteIfExists(routeDest);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ /**
+ * Returns {@code true} if the router admin API reports a route with the given ID.
+ */
+ private boolean routeAvailable(final String routeId) {
+ final Response response = RestAssured.given().when()
+ .get("/openig/api/system/objects/_router/routes?_queryFilter=true");
+ final List ids = response.jsonPath().getList("result._id");
+ return ids != null && ids.contains(routeId);
+ }
+
+ /** Reads the {@code test.config.path} system property set by cargo during integration tests. */
+ private static String getTestConfigPath() {
+ return System.getProperty("test.config.path");
+ }
+
+ /**
+ * Returns the absolute path to the {@code petstore.yaml} resource on the class path.
+ * The file is extracted to a temp location so the mock handler can read it via
+ * {@code read('...')} expression at runtime inside the embedded container.
+ */
+ private static String getSpecFilePath() throws IOException {
+ final Path tmp = Files.createTempFile("petstore-", ".yaml");
+ try (final InputStream in = IT_MockRoute.class.getClassLoader()
+ .getResourceAsStream("routes/petstore.yaml")) {
+ Objects.requireNonNull(in, "routes/petstore.yaml not found on classpath");
+ Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
+ }
+ tmp.toFile().deleteOnExit();
+ return tmp.toAbsolutePath().toString();
+ }
+}
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 b9dea9b3..fa263922 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
@@ -56,7 +56,7 @@ public void setupWireMock() {
wireMockServer.start();
WireMock.configureFor("localhost", wireMockServer.port());
- stubFor(get(urlPathEqualTo("/v2/pet/findByStatus"))
+ stubFor(get(urlPathEqualTo("/v2.1/pet/findByStatus"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
@@ -103,7 +103,7 @@ private void testPetRoute(String routeId, Path destination) throws IOException {
.atMost(15, SECONDS).until(() -> routeAvailable(routeId));
RestAssured
- .given().when().get("/v2/pet/findByStatus?status=available")
+ .given().when().get("/v2.1/pet/findByStatus?status=available")
.then()
.statusCode(200)
.body("[0].id", Matchers.equalTo(1));
@@ -115,7 +115,7 @@ private void testPetRoute(String routeId, Path destination) throws IOException {
.atMost(15, SECONDS).until(() -> !routeAvailable(routeId));
RestAssured
- .given().when().get("/v2/pet/findByStatus?status=available")
+ .given().when().get("/v2.1/pet/findByStatus?status=available")
.then()
.statusCode(404);
}
diff --git a/openig-war/src/test/resources/routes/petstore-mock.json b/openig-war/src/test/resources/routes/petstore-mock.json
new file mode 100644
index 00000000..60dac964
--- /dev/null
+++ b/openig-war/src/test/resources/routes/petstore-mock.json
@@ -0,0 +1,16 @@
+{
+ "name": "petstore-mock",
+ "condition": "${matches(request.uri.path, '^/v2/')}",
+ "heap": [
+ {
+ "name": "PetstoreMock",
+ "type": "OpenApiMockResponseHandler",
+ "config": {
+ "spec": "${read('$$SWAGGER_FILE$$')}",
+ "defaultStatusCode": 200,
+ "arraySize": 2
+ }
+ }
+ ],
+ "handler": "PetstoreMock"
+}
diff --git a/openig-war/src/test/resources/routes/petstore.yaml b/openig-war/src/test/resources/routes/petstore.yaml
index 0e06e5fd..e58b3962 100644
--- a/openig-war/src/test/resources/routes/petstore.yaml
+++ b/openig-war/src/test/resources/routes/petstore.yaml
@@ -1,6 +1,6 @@
openapi: 3.0.0
servers:
- - url: 'http://localhost:8090/v2'
+ - url: 'http://localhost:8090/v2.1'
info:
description: >-
This is a sample server Petstore server. For this sample, you can use the api key