From 0a775b964ad05103a32add98a51ea8bbfe309685 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 09:50:56 +0000
Subject: [PATCH 1/8] Initial plan
From a347515834d5c9a2b02da707b99d67bd705b7796 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:09:04 +0000
Subject: [PATCH 2/8] Add OpenApiMockResponseHandler with MockDataGenerator and
mockMode support
Agent-Logs-Url: https://github.com/OpenIdentityPlatform/OpenIG/sessions/d5ffaefb-3274-41bc-a5c6-bd2f7131e0bb
Co-authored-by: vharseko <6818498+vharseko@users.noreply.github.com>
---
.../openig/alias/CoreClassAliasResolver.java | 2 +
.../openig/handler/MockDataGenerator.java | 467 ++++++++++++++++++
.../handler/OpenApiMockResponseHandler.java | 416 ++++++++++++++++
.../handler/router/OpenApiRouteBuilder.java | 63 ++-
.../openig/handler/router/RouterHandler.java | 24 +-
.../openig/handler/MockDataGeneratorTest.java | 296 +++++++++++
.../OpenApiMockResponseHandlerTest.java | 440 +++++++++++++++++
.../router/OpenApiRouteBuilderTest.java | 64 +++
.../asciidoc/reference/handlers-conf.adoc | 160 +++++-
9 files changed, 1917 insertions(+), 15 deletions(-)
create mode 100644 openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
create mode 100644 openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
create mode 100644 openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java
create mode 100644 openig-core/src/test/java/org/forgerock/openig/handler/OpenApiMockResponseHandlerTest.java
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..5876caee
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
@@ -0,0 +1,467 @@
+/*
+ * 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 java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * 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 dictionary (case-insensitive, separator-agnostic lookup)
+ * Schema {@code type} fallback (generic string / integer / number / boolean)
+ *
+ *
+ * The generator uses a seeded {@link Random} 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 for deterministic output. */
+ private static final Random RNG = new Random(42L);
+
+ /** Normalised-name → realistic value dictionary. */
+ private static final Map FIELD_DICTIONARY = new HashMap<>();
+
+ static {
+ // Personal
+ FIELD_DICTIONARY.put("firstname", "John");
+ FIELD_DICTIONARY.put("lastname", "Doe");
+ FIELD_DICTIONARY.put("fullname", "John Doe");
+ FIELD_DICTIONARY.put("name", "John Doe");
+ FIELD_DICTIONARY.put("username", "johndoe");
+ FIELD_DICTIONARY.put("login", "johndoe");
+ FIELD_DICTIONARY.put("displayname","John Doe");
+
+ // Contact
+ FIELD_DICTIONARY.put("email", "john.doe@example.com");
+ FIELD_DICTIONARY.put("mail", "john.doe@example.com");
+ FIELD_DICTIONARY.put("phone", "+1-555-123-4567");
+ FIELD_DICTIONARY.put("phonenumber","+1-555-123-4567");
+ FIELD_DICTIONARY.put("mobile", "+1-555-123-4567");
+ FIELD_DICTIONARY.put("fax", "+1-555-123-4568");
+
+ // Address
+ FIELD_DICTIONARY.put("address", "123 Main St");
+ FIELD_DICTIONARY.put("street", "123 Main St");
+ FIELD_DICTIONARY.put("city", "Springfield");
+ FIELD_DICTIONARY.put("state", "Illinois");
+ FIELD_DICTIONARY.put("country", "United States");
+ FIELD_DICTIONARY.put("zip", "62701");
+ FIELD_DICTIONARY.put("zipcode", "62701");
+ FIELD_DICTIONARY.put("postalcode", "62701");
+
+ // Internet
+ FIELD_DICTIONARY.put("url", "https://www.example.com");
+ FIELD_DICTIONARY.put("website", "https://www.example.com");
+ FIELD_DICTIONARY.put("homepage", "https://www.example.com");
+ FIELD_DICTIONARY.put("avatar", "https://www.example.com/images/avatar.jpg");
+ FIELD_DICTIONARY.put("photo", "https://www.example.com/images/photo.jpg");
+ FIELD_DICTIONARY.put("picture", "https://www.example.com/images/photo.jpg");
+ FIELD_DICTIONARY.put("image", "https://www.example.com/images/photo.jpg");
+ FIELD_DICTIONARY.put("thumbnail", "https://www.example.com/images/thumb.jpg");
+ FIELD_DICTIONARY.put("gravatar", "https://www.gravatar.com/avatar/00000000000000000000000000000000");
+
+ // Text / content
+ FIELD_DICTIONARY.put("description","Sample description text.");
+ FIELD_DICTIONARY.put("summary", "Sample summary.");
+ FIELD_DICTIONARY.put("content", "Sample content.");
+ FIELD_DICTIONARY.put("body", "Sample body content.");
+ FIELD_DICTIONARY.put("message", "Sample message.");
+ FIELD_DICTIONARY.put("title", "Sample Title");
+ FIELD_DICTIONARY.put("subject", "Sample Subject");
+ FIELD_DICTIONARY.put("label", "Sample Label");
+ FIELD_DICTIONARY.put("note", "Sample note.");
+ FIELD_DICTIONARY.put("comment", "Sample comment.");
+ FIELD_DICTIONARY.put("text", "Sample text.");
+ FIELD_DICTIONARY.put("slug", "sample-slug");
+ FIELD_DICTIONARY.put("tag", "sample-tag");
+ FIELD_DICTIONARY.put("tags", "sample-tag");
+ FIELD_DICTIONARY.put("category", "General");
+ FIELD_DICTIONARY.put("type", "default");
+ FIELD_DICTIONARY.put("status", "active");
+ FIELD_DICTIONARY.put("format", "json");
+ FIELD_DICTIONARY.put("locale", "en-US");
+ FIELD_DICTIONARY.put("language", "en");
+ FIELD_DICTIONARY.put("timezone", "UTC");
+ FIELD_DICTIONARY.put("currency", "USD");
+
+ // Business
+ FIELD_DICTIONARY.put("company", "Acme Corporation");
+ FIELD_DICTIONARY.put("organisation","Acme Corporation");
+ FIELD_DICTIONARY.put("organization","Acme Corporation");
+ FIELD_DICTIONARY.put("department", "Engineering");
+ FIELD_DICTIONARY.put("role", "admin");
+ FIELD_DICTIONARY.put("permission", "read");
+ FIELD_DICTIONARY.put("scope", "openid profile");
+ FIELD_DICTIONARY.put("group", "users");
+ FIELD_DICTIONARY.put("team", "dev-team");
+ FIELD_DICTIONARY.put("project", "My Project");
+ FIELD_DICTIONARY.put("version", "1.0.0");
+ FIELD_DICTIONARY.put("code", "SAMPLE-CODE");
+ FIELD_DICTIONARY.put("reference", "REF-1001");
+ FIELD_DICTIONARY.put("key", "sample-key");
+ FIELD_DICTIONARY.put("value", "sample-value");
+
+ // Auth
+ FIELD_DICTIONARY.put("token", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U");
+ FIELD_DICTIONARY.put("accesstoken","eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U");
+ FIELD_DICTIONARY.put("refreshtoken","dGhpcyBpcyBhIG1vY2sgcmVmcmVzaCB0b2tlbg==");
+ FIELD_DICTIONARY.put("password", "P@ssw0rd123!");
+ FIELD_DICTIONARY.put("secret", "s3cr3t-v4lu3");
+ FIELD_DICTIONARY.put("apikey", "sk-mock-api-key-1234567890abcdef");
+ FIELD_DICTIONARY.put("hash", "5f4dcc3b5aa765d61d8327deb882cf99");
+ FIELD_DICTIONARY.put("salt", "random-salt-value");
+
+ // Numbers (stored as Number so callers can down-cast)
+ FIELD_DICTIONARY.put("id", 1001);
+ FIELD_DICTIONARY.put("uid", 1001);
+ FIELD_DICTIONARY.put("userid", 1001);
+ FIELD_DICTIONARY.put("accountid", 1001);
+ FIELD_DICTIONARY.put("age", 30);
+ FIELD_DICTIONARY.put("year", 2024);
+ FIELD_DICTIONARY.put("month", 6);
+ FIELD_DICTIONARY.put("day", 15);
+ FIELD_DICTIONARY.put("hour", 12);
+ FIELD_DICTIONARY.put("minute", 30);
+ FIELD_DICTIONARY.put("second", 0);
+ FIELD_DICTIONARY.put("count", 10);
+ FIELD_DICTIONARY.put("total", 100);
+ FIELD_DICTIONARY.put("quantity", 5);
+ FIELD_DICTIONARY.put("amount", 99.99);
+ FIELD_DICTIONARY.put("price", 29.99);
+ FIELD_DICTIONARY.put("cost", 19.99);
+ FIELD_DICTIONARY.put("discount", 5.0);
+ FIELD_DICTIONARY.put("tax", 7.5);
+ FIELD_DICTIONARY.put("rating", 4.5);
+ FIELD_DICTIONARY.put("score", 85);
+ FIELD_DICTIONARY.put("rank", 1);
+ FIELD_DICTIONARY.put("index", 0);
+ FIELD_DICTIONARY.put("size", 10);
+ FIELD_DICTIONARY.put("length", 100);
+ FIELD_DICTIONARY.put("width", 800);
+ FIELD_DICTIONARY.put("height", 600);
+ FIELD_DICTIONARY.put("weight", 70.5);
+ FIELD_DICTIONARY.put("limit", 20);
+ FIELD_DICTIONARY.put("offset", 0);
+ FIELD_DICTIONARY.put("page", 1);
+ FIELD_DICTIONARY.put("pagesize", 20);
+ FIELD_DICTIONARY.put("maxresults", 100);
+ FIELD_DICTIONARY.put("duration", 3600);
+ FIELD_DICTIONARY.put("timeout", 30);
+ FIELD_DICTIONARY.put("retries", 3);
+ FIELD_DICTIONARY.put("priority", 1);
+ FIELD_DICTIONARY.put("order", 1);
+ FIELD_DICTIONARY.put("sort", 1);
+ FIELD_DICTIONARY.put("port", 8080);
+ FIELD_DICTIONARY.put("latitude", 37.7749);
+ FIELD_DICTIONARY.put("longitude", -122.4194);
+
+ // Booleans (true group)
+ FIELD_DICTIONARY.put("enabled", true);
+ FIELD_DICTIONARY.put("active", true);
+ FIELD_DICTIONARY.put("verified", true);
+ FIELD_DICTIONARY.put("confirmed", true);
+ FIELD_DICTIONARY.put("approved", true);
+ FIELD_DICTIONARY.put("published", true);
+ FIELD_DICTIONARY.put("available", true);
+ FIELD_DICTIONARY.put("visible", true);
+ FIELD_DICTIONARY.put("public", true);
+ FIELD_DICTIONARY.put("success", true);
+ FIELD_DICTIONARY.put("valid", true);
+ FIELD_DICTIONARY.put("required", true);
+ FIELD_DICTIONARY.put("locked", false);
+
+ // Booleans (false group)
+ FIELD_DICTIONARY.put("deleted", false);
+ FIELD_DICTIONARY.put("archived", false);
+ FIELD_DICTIONARY.put("banned", false);
+ FIELD_DICTIONARY.put("blocked", false);
+ FIELD_DICTIONARY.put("disabled", false);
+ FIELD_DICTIONARY.put("hidden", false);
+ FIELD_DICTIONARY.put("private", false);
+ FIELD_DICTIONARY.put("deprecated", false);
+ FIELD_DICTIONARY.put("failed", false);
+ FIELD_DICTIONARY.put("error", false);
+ }
+
+ 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) {
+ Object formatValue = generateByFormat(format, schema);
+ if (formatValue != null) {
+ return formatValue;
+ }
+ }
+
+ // 2. Field-name-based (dictionary lookup)
+ if (fieldName != null) {
+ final String key = normalise(fieldName);
+ Object dictValue = FIELD_DICTIONARY.get(key);
+ if (dictValue != null) {
+ return coerce(dictValue, type, schema);
+ }
+ }
+
+ // 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 "2024-06-15";
+ case "date-time":
+ return "2024-06-15T12:00:00Z";
+ case "time":
+ return "12:00:00";
+ case "email":
+ return "john.doe@example.com";
+ case "uri":
+ case "url":
+ return "https://www.example.com";
+ case "uri-reference":
+ return "/api/resource/1001";
+ case "uuid":
+ return "550e8400-e29b-41d4-a716-446655440000";
+ case "ipv4":
+ return "192.168.1.1";
+ case "ipv6":
+ return "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
+ case "hostname":
+ return "api.example.com";
+ case "byte":
+ return "SGVsbG8gV29ybGQ=";
+ case "binary":
+ return "binary-data";
+ case "password":
+ return "P@ssw0rd123!";
+ case "int32":
+ return generateInt(schema, 1001);
+ case "int64":
+ return generateLong(schema, 1001L);
+ case "float":
+ case "double":
+ return generateDouble(schema, 29.99);
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Generates a value based on schema type when no format or dictionary match was found.
+ */
+ private static Object generateByType(final String fieldName, final String type, final Schema> schema) {
+ if (type == null) {
+ return fieldName != null ? fieldName + "-value" : "value";
+ }
+ switch (type.toLowerCase()) {
+ case "integer":
+ return generateInt(schema, 1001);
+ case "number":
+ return generateDouble(schema, 29.99);
+ case "boolean":
+ return generateBoolean(fieldName);
+ case "string":
+ return generateString(fieldName, schema);
+ case "array":
+ case "object":
+ default:
+ return fieldName != null ? fieldName + "-value" : "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);
+ final Object dictValue = FIELD_DICTIONARY.get(key);
+ if (dictValue instanceof Boolean) {
+ return (Boolean) dictValue;
+ }
+ // Heuristics for names not in dictionary
+ 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 ? fieldName + "-value" : "sample-value";
+ // Respect minLength
+ Integer minLength = schema.getMinLength();
+ if (minLength != null && base.length() < minLength) {
+ StringBuilder sb = new StringBuilder(base);
+ while (sb.length() < minLength) {
+ sb.append("-x");
+ }
+ base = sb.toString();
+ }
+ // Respect maxLength
+ 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 dictionary 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, 1001);
+ case "number":
+ if (value instanceof Number) {
+ return ((Number) value).doubleValue();
+ }
+ return generateDouble(schema, 29.99);
+ 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 dictionary 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..beaffe58
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
@@ -0,0 +1,416 @@
+/*
+ * 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 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.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.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * 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();
+
+ 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;
+ for (Map.Entry entry : openAPI.getPaths().entrySet()) {
+ if (pathMatches(entry.getKey(), 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();
+ }
+
+ // -----------------------------------------------------------------------
+ // 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..4368a546 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);
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..b2d9c44b
--- /dev/null
+++ b/openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java
@@ -0,0 +1,296 @@
+/*
+ * 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 dictionary
+ // -----------------------------------------------------------------------
+
+ @DataProvider(name = "fieldNameCases")
+ public static Object[][] fieldNameCases() {
+ return new Object[][] {
+ {"firstName", String.class, "John"},
+ {"lastName", String.class, "Doe"},
+ {"username", String.class, "johndoe"},
+ {"email", String.class, "john.doe@example.com"},
+ {"phone", String.class, "+1-555-123-4567"},
+ {"city", String.class, "Springfield"},
+ {"country", String.class, "United States"},
+ {"company", String.class, "Acme Corporation"},
+ {"role", String.class, "admin"},
+ {"description",String.class, "Sample description text."},
+ {"title", String.class, "Sample Title"},
+ {"url", String.class, "https://www.example.com"},
+ };
+ }
+
+ @Test(dataProvider = "fieldNameCases")
+ public void generate_usesFieldNameDictionary(
+ final String fieldName, final Class> expectedType, final Object expectedValue) {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ final Object value = MockDataGenerator.generate(fieldName, schema);
+ assertThat(value).isInstanceOf(expectedType);
+ assertThat(value).isEqualTo(expectedValue);
+ }
+
+ @Test
+ public void generate_normalisesFieldNameForLookup() {
+ final Schema schema = new Schema<>();
+ schema.setType("string");
+ // "first_name" should normalise to "firstname" which maps to "John"
+ assertThat(MockDataGenerator.generate("first_name", schema)).isEqualTo("John");
+ assertThat(MockDataGenerator.generate("first-name", schema)).isEqualTo("John");
+ assertThat(MockDataGenerator.generate("FIRSTNAME", schema)).isEqualTo("John");
+ }
+
+ // -----------------------------------------------------------------------
+ // 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..b292139e 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
@@ -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-doc/src/main/asciidoc/reference/handlers-conf.adoc b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
index e24e1d11..7f491e46 100644
--- a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
+++ b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
@@ -721,7 +721,8 @@ xref:filters-conf.adoc#OpenApiValidationFilter[OpenApiValidationFilter(5)].
"scanInterval": integer,
"openApiValidation": {
"enabled": boolean
- "failOnResponseViolation": 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
From 74a7db9028742f4eec196b1e7e8d315874d7267e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:12:37 +0000
Subject: [PATCH 3/8] Fix missing comma in Router usage example in
handlers-conf.adoc
Agent-Logs-Url: https://github.com/OpenIdentityPlatform/OpenIG/sessions/d5ffaefb-3274-41bc-a5c6-bd2f7131e0bb
Co-authored-by: vharseko <6818498+vharseko@users.noreply.github.com>
---
openig-doc/src/main/asciidoc/reference/handlers-conf.adoc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
index 7f491e46..0a181eec 100644
--- a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
+++ b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc
@@ -720,7 +720,7 @@ xref:filters-conf.adoc#OpenApiValidationFilter[OpenApiValidationFilter(5)].
"directory": expression,
"scanInterval": integer,
"openApiValidation": {
- "enabled": boolean
+ "enabled": boolean,
"failOnResponseViolation": boolean,
"mockMode": boolean
}
From 00ec9803913efa0206d2c8565597c2155aae9214 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:49:38 +0000
Subject: [PATCH 4/8] Integrate datafaker 1.9.0 into MockDataGenerator and add
IT_MockRoute integration test
Agent-Logs-Url: https://github.com/OpenIdentityPlatform/OpenIG/sessions/145b9565-daac-42f5-849e-1a38a73bf63f
Co-authored-by: vharseko <6818498+vharseko@users.noreply.github.com>
---
openig-core/pom.xml | 5 +
.../openig/handler/MockDataGenerator.java | 423 +++++++++---------
.../openig/handler/MockDataGeneratorTest.java | 61 ++-
.../openig/test/integration/IT_MockRoute.java | 221 +++++++++
.../test/resources/routes/petstore-mock.json | 16 +
5 files changed, 499 insertions(+), 227 deletions(-)
create mode 100644 openig-war/src/test/java/org/openidentityplatrform/openig/test/integration/IT_MockRoute.java
create mode 100644 openig-war/src/test/resources/routes/petstore-mock.json
diff --git a/openig-core/pom.xml b/openig-core/pom.xml
index 24c8da93..b372a174 100644
--- a/openig-core/pom.xml
+++ b/openig-core/pom.xml
@@ -166,6 +166,11 @@
swagger-request-validator-core
2.46.0
+
+ net.datafaker
+ datafaker
+ 1.9.0
+
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
index 5876caee..9dafffe4 100644
--- a/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
@@ -17,10 +17,14 @@
package org.forgerock.openig.handler;
import io.swagger.v3.oas.models.media.Schema;
+import net.datafaker.Faker;
-import java.util.HashMap;
-import java.util.Map;
+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.
@@ -28,11 +32,12 @@
* Values are chosen using the following priority order:
*
* Schema {@code format} (date, date-time, email, uri, uuid, ipv4, hostname, byte, password, …)
- * Field-name dictionary (case-insensitive, separator-agnostic lookup)
+ * 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 Random} so results are deterministic and
+ *
The generator uses a seeded {@link Faker} so results are deterministic and
* reproducible across test runs.
*
*
Numeric and string constraints ({@code minimum}, {@code maximum},
@@ -40,173 +45,25 @@
*/
public class MockDataGenerator {
- /** Seeded random for deterministic output. */
+ /** Seeded random shared by the Faker instance and numeric generators for deterministic output. */
private static final Random RNG = new Random(42L);
- /** Normalised-name → realistic value dictionary. */
- private static final Map FIELD_DICTIONARY = new HashMap<>();
+ /** Datafaker instance backed by the same seed. */
+ private static final Faker FAKER = new Faker(Locale.ENGLISH, RNG);
- static {
- // Personal
- FIELD_DICTIONARY.put("firstname", "John");
- FIELD_DICTIONARY.put("lastname", "Doe");
- FIELD_DICTIONARY.put("fullname", "John Doe");
- FIELD_DICTIONARY.put("name", "John Doe");
- FIELD_DICTIONARY.put("username", "johndoe");
- FIELD_DICTIONARY.put("login", "johndoe");
- FIELD_DICTIONARY.put("displayname","John Doe");
-
- // Contact
- FIELD_DICTIONARY.put("email", "john.doe@example.com");
- FIELD_DICTIONARY.put("mail", "john.doe@example.com");
- FIELD_DICTIONARY.put("phone", "+1-555-123-4567");
- FIELD_DICTIONARY.put("phonenumber","+1-555-123-4567");
- FIELD_DICTIONARY.put("mobile", "+1-555-123-4567");
- FIELD_DICTIONARY.put("fax", "+1-555-123-4568");
+ // 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"
+ ));
- // Address
- FIELD_DICTIONARY.put("address", "123 Main St");
- FIELD_DICTIONARY.put("street", "123 Main St");
- FIELD_DICTIONARY.put("city", "Springfield");
- FIELD_DICTIONARY.put("state", "Illinois");
- FIELD_DICTIONARY.put("country", "United States");
- FIELD_DICTIONARY.put("zip", "62701");
- FIELD_DICTIONARY.put("zipcode", "62701");
- FIELD_DICTIONARY.put("postalcode", "62701");
-
- // Internet
- FIELD_DICTIONARY.put("url", "https://www.example.com");
- FIELD_DICTIONARY.put("website", "https://www.example.com");
- FIELD_DICTIONARY.put("homepage", "https://www.example.com");
- FIELD_DICTIONARY.put("avatar", "https://www.example.com/images/avatar.jpg");
- FIELD_DICTIONARY.put("photo", "https://www.example.com/images/photo.jpg");
- FIELD_DICTIONARY.put("picture", "https://www.example.com/images/photo.jpg");
- FIELD_DICTIONARY.put("image", "https://www.example.com/images/photo.jpg");
- FIELD_DICTIONARY.put("thumbnail", "https://www.example.com/images/thumb.jpg");
- FIELD_DICTIONARY.put("gravatar", "https://www.gravatar.com/avatar/00000000000000000000000000000000");
-
- // Text / content
- FIELD_DICTIONARY.put("description","Sample description text.");
- FIELD_DICTIONARY.put("summary", "Sample summary.");
- FIELD_DICTIONARY.put("content", "Sample content.");
- FIELD_DICTIONARY.put("body", "Sample body content.");
- FIELD_DICTIONARY.put("message", "Sample message.");
- FIELD_DICTIONARY.put("title", "Sample Title");
- FIELD_DICTIONARY.put("subject", "Sample Subject");
- FIELD_DICTIONARY.put("label", "Sample Label");
- FIELD_DICTIONARY.put("note", "Sample note.");
- FIELD_DICTIONARY.put("comment", "Sample comment.");
- FIELD_DICTIONARY.put("text", "Sample text.");
- FIELD_DICTIONARY.put("slug", "sample-slug");
- FIELD_DICTIONARY.put("tag", "sample-tag");
- FIELD_DICTIONARY.put("tags", "sample-tag");
- FIELD_DICTIONARY.put("category", "General");
- FIELD_DICTIONARY.put("type", "default");
- FIELD_DICTIONARY.put("status", "active");
- FIELD_DICTIONARY.put("format", "json");
- FIELD_DICTIONARY.put("locale", "en-US");
- FIELD_DICTIONARY.put("language", "en");
- FIELD_DICTIONARY.put("timezone", "UTC");
- FIELD_DICTIONARY.put("currency", "USD");
-
- // Business
- FIELD_DICTIONARY.put("company", "Acme Corporation");
- FIELD_DICTIONARY.put("organisation","Acme Corporation");
- FIELD_DICTIONARY.put("organization","Acme Corporation");
- FIELD_DICTIONARY.put("department", "Engineering");
- FIELD_DICTIONARY.put("role", "admin");
- FIELD_DICTIONARY.put("permission", "read");
- FIELD_DICTIONARY.put("scope", "openid profile");
- FIELD_DICTIONARY.put("group", "users");
- FIELD_DICTIONARY.put("team", "dev-team");
- FIELD_DICTIONARY.put("project", "My Project");
- FIELD_DICTIONARY.put("version", "1.0.0");
- FIELD_DICTIONARY.put("code", "SAMPLE-CODE");
- FIELD_DICTIONARY.put("reference", "REF-1001");
- FIELD_DICTIONARY.put("key", "sample-key");
- FIELD_DICTIONARY.put("value", "sample-value");
-
- // Auth
- FIELD_DICTIONARY.put("token", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U");
- FIELD_DICTIONARY.put("accesstoken","eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U");
- FIELD_DICTIONARY.put("refreshtoken","dGhpcyBpcyBhIG1vY2sgcmVmcmVzaCB0b2tlbg==");
- FIELD_DICTIONARY.put("password", "P@ssw0rd123!");
- FIELD_DICTIONARY.put("secret", "s3cr3t-v4lu3");
- FIELD_DICTIONARY.put("apikey", "sk-mock-api-key-1234567890abcdef");
- FIELD_DICTIONARY.put("hash", "5f4dcc3b5aa765d61d8327deb882cf99");
- FIELD_DICTIONARY.put("salt", "random-salt-value");
-
- // Numbers (stored as Number so callers can down-cast)
- FIELD_DICTIONARY.put("id", 1001);
- FIELD_DICTIONARY.put("uid", 1001);
- FIELD_DICTIONARY.put("userid", 1001);
- FIELD_DICTIONARY.put("accountid", 1001);
- FIELD_DICTIONARY.put("age", 30);
- FIELD_DICTIONARY.put("year", 2024);
- FIELD_DICTIONARY.put("month", 6);
- FIELD_DICTIONARY.put("day", 15);
- FIELD_DICTIONARY.put("hour", 12);
- FIELD_DICTIONARY.put("minute", 30);
- FIELD_DICTIONARY.put("second", 0);
- FIELD_DICTIONARY.put("count", 10);
- FIELD_DICTIONARY.put("total", 100);
- FIELD_DICTIONARY.put("quantity", 5);
- FIELD_DICTIONARY.put("amount", 99.99);
- FIELD_DICTIONARY.put("price", 29.99);
- FIELD_DICTIONARY.put("cost", 19.99);
- FIELD_DICTIONARY.put("discount", 5.0);
- FIELD_DICTIONARY.put("tax", 7.5);
- FIELD_DICTIONARY.put("rating", 4.5);
- FIELD_DICTIONARY.put("score", 85);
- FIELD_DICTIONARY.put("rank", 1);
- FIELD_DICTIONARY.put("index", 0);
- FIELD_DICTIONARY.put("size", 10);
- FIELD_DICTIONARY.put("length", 100);
- FIELD_DICTIONARY.put("width", 800);
- FIELD_DICTIONARY.put("height", 600);
- FIELD_DICTIONARY.put("weight", 70.5);
- FIELD_DICTIONARY.put("limit", 20);
- FIELD_DICTIONARY.put("offset", 0);
- FIELD_DICTIONARY.put("page", 1);
- FIELD_DICTIONARY.put("pagesize", 20);
- FIELD_DICTIONARY.put("maxresults", 100);
- FIELD_DICTIONARY.put("duration", 3600);
- FIELD_DICTIONARY.put("timeout", 30);
- FIELD_DICTIONARY.put("retries", 3);
- FIELD_DICTIONARY.put("priority", 1);
- FIELD_DICTIONARY.put("order", 1);
- FIELD_DICTIONARY.put("sort", 1);
- FIELD_DICTIONARY.put("port", 8080);
- FIELD_DICTIONARY.put("latitude", 37.7749);
- FIELD_DICTIONARY.put("longitude", -122.4194);
-
- // Booleans (true group)
- FIELD_DICTIONARY.put("enabled", true);
- FIELD_DICTIONARY.put("active", true);
- FIELD_DICTIONARY.put("verified", true);
- FIELD_DICTIONARY.put("confirmed", true);
- FIELD_DICTIONARY.put("approved", true);
- FIELD_DICTIONARY.put("published", true);
- FIELD_DICTIONARY.put("available", true);
- FIELD_DICTIONARY.put("visible", true);
- FIELD_DICTIONARY.put("public", true);
- FIELD_DICTIONARY.put("success", true);
- FIELD_DICTIONARY.put("valid", true);
- FIELD_DICTIONARY.put("required", true);
- FIELD_DICTIONARY.put("locked", false);
-
- // Booleans (false group)
- FIELD_DICTIONARY.put("deleted", false);
- FIELD_DICTIONARY.put("archived", false);
- FIELD_DICTIONARY.put("banned", false);
- FIELD_DICTIONARY.put("blocked", false);
- FIELD_DICTIONARY.put("disabled", false);
- FIELD_DICTIONARY.put("hidden", false);
- FIELD_DICTIONARY.put("private", false);
- FIELD_DICTIONARY.put("deprecated", false);
- FIELD_DICTIONARY.put("failed", false);
- FIELD_DICTIONARY.put("error", false);
- }
+ 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
@@ -230,18 +87,17 @@ public static Object generate(final String fieldName, final Schema> schema) {
// 1. Format-based generation
if (format != null) {
- Object formatValue = generateByFormat(format, schema);
+ final Object formatValue = generateByFormat(format, schema);
if (formatValue != null) {
return formatValue;
}
}
- // 2. Field-name-based (dictionary lookup)
+ // 2. Field-name-based (Datafaker)
if (fieldName != null) {
- final String key = normalise(fieldName);
- Object dictValue = FIELD_DICTIONARY.get(key);
- if (dictValue != null) {
- return coerce(dictValue, type, schema);
+ final Object fakerValue = generateByFieldName(fieldName, type, schema);
+ if (fakerValue != null) {
+ return fakerValue;
}
}
@@ -257,56 +113,208 @@ public static Object generate(final String fieldName, final Schema> schema) {
private static Object generateByFormat(final String format, final Schema> schema) {
switch (format.toLowerCase()) {
case "date":
- return "2024-06-15";
+ return LocalDate.now().minusDays(RNG.nextInt(365))
+ .format(DateTimeFormatter.ISO_LOCAL_DATE);
case "date-time":
- return "2024-06-15T12:00:00Z";
+ return LocalDateTime.now().minusDays(RNG.nextInt(365))
+ .format(DateTimeFormatter.ISO_DATE_TIME) + "Z";
case "time":
return "12:00:00";
case "email":
- return "john.doe@example.com";
+ return FAKER.internet().emailAddress();
case "uri":
case "url":
- return "https://www.example.com";
+ return "https://" + FAKER.internet().domainName();
case "uri-reference":
- return "/api/resource/1001";
+ return "/api/resource/" + FAKER.number().numberBetween(1, 9999);
case "uuid":
- return "550e8400-e29b-41d4-a716-446655440000";
+ return UUID.randomUUID().toString();
case "ipv4":
- return "192.168.1.1";
+ return FAKER.internet().ipV4Address();
case "ipv6":
- return "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
+ return FAKER.internet().ipV6Address();
case "hostname":
- return "api.example.com";
+ return FAKER.internet().domainName();
case "byte":
- return "SGVsbG8gV29ybGQ=";
+ return java.util.Base64.getEncoder().encodeToString(
+ FAKER.lorem().word().getBytes(java.nio.charset.StandardCharsets.UTF_8));
case "binary":
return "binary-data";
case "password":
- return "P@ssw0rd123!";
+ return FAKER.internet().password(8, 16, true, true, true);
case "int32":
- return generateInt(schema, 1001);
+ return generateInt(schema, FAKER.number().numberBetween(1, 10000));
case "int64":
- return generateLong(schema, 1001L);
+ return generateLong(schema, FAKER.number().numberBetween(1L, 100000L));
case "float":
case "double":
- return generateDouble(schema, 29.99);
+ return generateDouble(schema, FAKER.number().randomDouble(2, 1, 10000));
default:
return null;
}
}
/**
- * Generates a value based on schema type when no format or dictionary match was found.
+ * 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 ? fieldName + "-value" : "value";
+ return fieldName != null ? FAKER.lorem().word() : "value";
}
switch (type.toLowerCase()) {
case "integer":
- return generateInt(schema, 1001);
+ return generateInt(schema, FAKER.number().numberBetween(1, 10000));
case "number":
- return generateDouble(schema, 29.99);
+ return generateDouble(schema, FAKER.number().randomDouble(2, 1, 10000));
case "boolean":
return generateBoolean(fieldName);
case "string":
@@ -314,7 +322,7 @@ private static Object generateByType(final String fieldName, final String type,
case "array":
case "object":
default:
- return fieldName != null ? fieldName + "-value" : "value";
+ return fieldName != null ? FAKER.lorem().word() : "value";
}
}
@@ -329,12 +337,15 @@ static boolean generateBoolean(final String fieldName) {
return true;
}
final String key = normalise(fieldName);
- final Object dictValue = FIELD_DICTIONARY.get(key);
- if (dictValue instanceof Boolean) {
- return (Boolean) dictValue;
+ if (BOOL_FALSE_NAMES.contains(key)) {
+ return false;
+ }
+ if (BOOL_TRUE_NAMES.contains(key)) {
+ return true;
}
- // Heuristics for names not in dictionary
- if (key.startsWith("is") || key.startsWith("has") || key.startsWith("can") || key.startsWith("should")) {
+ // 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")
@@ -348,18 +359,18 @@ static boolean generateBoolean(final String fieldName) {
* 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 ? fieldName + "-value" : "sample-value";
+ String base = fieldName != null ? FAKER.lorem().word() : FAKER.lorem().word();
// Respect minLength
- Integer minLength = schema.getMinLength();
+ final Integer minLength = schema.getMinLength();
if (minLength != null && base.length() < minLength) {
- StringBuilder sb = new StringBuilder(base);
+ final StringBuilder sb = new StringBuilder(base);
while (sb.length() < minLength) {
- sb.append("-x");
+ sb.append(FAKER.lorem().characters(1));
}
base = sb.toString();
}
// Respect maxLength
- Integer maxLength = schema.getMaxLength();
+ final Integer maxLength = schema.getMaxLength();
if (maxLength != null && base.length() > maxLength) {
base = base.substring(0, maxLength);
}
@@ -421,7 +432,7 @@ static double generateDouble(final Schema> schema, final double defaultValue)
}
/**
- * Coerces a dictionary value to the expected schema type where possible.
+ * 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) {
@@ -432,12 +443,12 @@ private static Object coerce(final Object value, final String type, final Schema
if (value instanceof Number) {
return ((Number) value).intValue();
}
- return generateInt(schema, 1001);
+ return generateInt(schema, FAKER.number().numberBetween(1, 10000));
case "number":
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
- return generateDouble(schema, 29.99);
+ return generateDouble(schema, FAKER.number().randomDouble(2, 1, 10000));
case "boolean":
if (value instanceof Boolean) {
return value;
@@ -456,7 +467,7 @@ private static Object coerce(final Object value, final String type, final Schema
/**
* 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 dictionary key.
+ * and {@code first-name} all map to the same lookup key.
*/
static String normalise(final String name) {
if (name == null) {
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
index b2d9c44b..6baf6a3d 100644
--- a/openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java
+++ b/openig-core/src/test/java/org/forgerock/openig/handler/MockDataGeneratorTest.java
@@ -128,45 +128,64 @@ public void generate_returnsPassword_forPasswordFormat() {
}
// -----------------------------------------------------------------------
- // Field-name dictionary
+ // Field-name heuristics (Datafaker-backed)
// -----------------------------------------------------------------------
@DataProvider(name = "fieldNameCases")
public static Object[][] fieldNameCases() {
return new Object[][] {
- {"firstName", String.class, "John"},
- {"lastName", String.class, "Doe"},
- {"username", String.class, "johndoe"},
- {"email", String.class, "john.doe@example.com"},
- {"phone", String.class, "+1-555-123-4567"},
- {"city", String.class, "Springfield"},
- {"country", String.class, "United States"},
- {"company", String.class, "Acme Corporation"},
- {"role", String.class, "admin"},
- {"description",String.class, "Sample description text."},
- {"title", String.class, "Sample Title"},
- {"url", String.class, "https://www.example.com"},
+ {"firstName"},
+ {"lastName"},
+ {"username"},
+ {"email"},
+ {"phone"},
+ {"city"},
+ {"country"},
+ {"company"},
+ {"role"},
+ {"description"},
+ {"title"},
+ {"url"},
};
}
@Test(dataProvider = "fieldNameCases")
- public void generate_usesFieldNameDictionary(
- final String fieldName, final Class> expectedType, final Object expectedValue) {
+ 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(expectedType);
- assertThat(value).isEqualTo(expectedValue);
+ 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" should normalise to "firstname" which maps to "John"
- assertThat(MockDataGenerator.generate("first_name", schema)).isEqualTo("John");
- assertThat(MockDataGenerator.generate("first-name", schema)).isEqualTo("John");
- assertThat(MockDataGenerator.generate("FIRSTNAME", schema)).isEqualTo("John");
+ // "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);
}
// -----------------------------------------------------------------------
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/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"
+}
From 8d7ff810f08e469ac41f12c769e0b3ce0f780416 Mon Sep 17 00:00:00 2001
From: Maxim Thomas
Date: Thu, 2 Apr 2026 15:22:29 +0300
Subject: [PATCH 5/8] Apply suggestion from @maximthomas
---
.../java/org/forgerock/openig/handler/MockDataGenerator.java | 1 +
1 file changed, 1 insertion(+)
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
index 9dafffe4..46411dd5 100644
--- a/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/MockDataGenerator.java
@@ -128,6 +128,7 @@ private static Object generateByFormat(final String format, final Schema> sche
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();
From ac58d1466dfd68134260f74041b6d05e9498e9b5 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 09:52:10 +0300
Subject: [PATCH 6/8] fix(test): update RouterHandlerTest mocks to 4-arg
buildRouteJson signature (#157)
Co-authored-by: Valery Kharseko
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: vharseko <6818498+vharseko@users.noreply.github.com>
---
.github/workflows/build.yml | 20 +++++++---------
.github/workflows/deploy.yml | 14 +++++------
.github/workflows/release.yml | 24 +++++++++----------
.../handler/router/RouterHandlerTest.java | 16 ++++++-------
4 files changed, 36 insertions(+), 38 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b58c0c33..33b7db7e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,8 +4,6 @@ on:
push:
branches: [ 'sustaining/5.4.x','master' ]
pull_request:
- branches: [ 'sustaining/5.4.x','master' ]
-
jobs:
build-maven:
runs-on: ${{ matrix.os }}
@@ -15,17 +13,17 @@ jobs:
os: [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ]
fail-fast: false
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Java ${{ matrix.Java }} (${{ matrix.os }})
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: ${{ matrix.java }}
distribution: 'zulu'
- name: Cache Maven packages
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-repository-${{ hashFiles('**/pom.xml') }}
@@ -35,7 +33,7 @@ jobs:
MAVEN_OPTS: -Dhttps.protocols=TLSv1.2 -Dmaven.wagon.httpconnectionManager.ttlSeconds=120 -Dmaven.wagon.http.retryHandler.requestSentEnabled=true -Dmaven.wagon.http.retryHandler.count=10
run: mvn --batch-mode --errors --update-snapshots package --file pom.xml
- name: Upload artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-${{ matrix.java }}
retention-days: 5
@@ -52,7 +50,7 @@ jobs:
- 5000:5000
steps:
- name: Download artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: ubuntu-latest-11
- name: Get latest release version
@@ -62,7 +60,7 @@ jobs:
echo "release_version=$git_version_last" >> $GITHUB_ENV
- name: Docker meta
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: |
localhost:5000/${{ github.repository }}
@@ -70,16 +68,16 @@ jobs:
type=raw,value=latest
type=raw,value=${{ env.release_version }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Prepare Dockerfile
shell: bash
run: sed -i -E '/^#COPY openig-war\//s/^#//' ./openig-docker/target/Dockerfile
- name: Build image
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v7
continue-on-error: true
with:
context: .
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 3cd5ef5a..d09f6967 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -15,13 +15,13 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJSON(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event.workflow_run.head_branch }}
- name: Set up Java for publishing to Maven Central Repository OSS
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: ${{ github.event.workflow_run.head_branch == 'sustaining/5.4.x' && '8' || '11'}}
distribution: 'temurin'
@@ -29,7 +29,7 @@ jobs:
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Cache Maven packages
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-repository-${{ hashFiles('**/pom.xml') }}
@@ -58,12 +58,12 @@ jobs:
- name: Build Javadoc
run: mvn javadoc:aggregate -pl '!openig-war' -pl '!openig-ui'
- name: Upload artifacts OpenIG Server Only Component
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: OpenIG Server
path: openig-war/target/*.war
- name: Upload artifacts OpenIG Dockerfile
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: OpenIG Dockerfile
path: openig-docker/target/Dockerfile*
@@ -72,7 +72,7 @@ jobs:
git config --global user.name "Open Identity Platform Community"
git config --global user.email "open-identity-platform-opendj@googlegroups.com"
cd ..
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
continue-on-error: true
with:
repository: ${{ github.repository }}.wiki
@@ -92,7 +92,7 @@ jobs:
git commit -a -m "upload docs after deploy ${{ github.sha }}"
git push --quiet --force
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
continue-on-error: true
with:
repository: OpenIdentityPlatform/doc.openidentityplatform.org
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f90ff99f..6037d0e3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,12 +19,12 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJSON(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Set up Java for publishing to Maven Central Repository OSS
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: ${{ github.event.workflow_run.head_branch == 'sustaining/5.4.x' && '8' || '11'}}
distribution: 'temurin'
@@ -32,7 +32,7 @@ jobs:
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Cache Maven packages
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-repository-${{ hashFiles('**/pom.xml') }}
@@ -71,7 +71,7 @@ jobs:
target/checkout/openig-war/target/*.war
target/checkout/openig-docker/target/Dockerfile*
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
continue-on-error: true
with:
repository: ${{ github.repository }}.wiki
@@ -93,7 +93,7 @@ jobs:
git tag -f ${{ github.event.inputs.releaseVersion }}
git push --quiet --force
git push --quiet --force origin ${{ github.event.inputs.releaseVersion }}
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
continue-on-error: true
with:
repository: OpenIdentityPlatform/doc.openidentityplatform.org
@@ -113,14 +113,14 @@ jobs:
needs:
- release-maven
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.releaseVersion }}
fetch-depth: 1
submodules: recursive
- name: Docker meta
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
@@ -129,22 +129,22 @@ jobs:
type=raw,value=latest
type=raw,value=${{ github.event.inputs.releaseVersion }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v7
continue-on-error: true
with:
context: ./openig-docker
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
From 289260ac22b5691650ccd845b8b53a061e41caf4 Mon Sep 17 00:00:00 2001
From: maximthomas
Date: Fri, 3 Apr 2026 17:38:41 +0300
Subject: [PATCH 7/8] Fix LocalDateTime serialization & dot in path regex
---
openig-core/pom.xml | 5 +++
.../handler/OpenApiMockResponseHandler.java | 37 ++++++++++++++++++-
.../handler/router/OpenApiRouteBuilder.java | 6 +--
.../router/OpenApiRouteBuilderTest.java | 4 +-
.../test/integration/IT_SwaggerRoute.java | 6 +--
.../src/test/resources/routes/petstore.yaml | 2 +-
6 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/openig-core/pom.xml b/openig-core/pom.xml
index b372a174..28395b39 100644
--- a/openig-core/pom.xml
+++ b/openig-core/pom.xml
@@ -52,6 +52,11 @@
com.fasterxml.jackson.core
jackson-databind
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ 2.18.6
+
org.openidentityplatform.commons
util
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
index beaffe58..6d781117 100644
--- a/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
@@ -18,6 +18,7 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+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;
@@ -29,6 +30,7 @@
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;
@@ -45,11 +47,12 @@
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;
-import java.util.regex.Pattern;
/**
* A {@link Handler} that generates valid mock HTTP responses with realistic test data
@@ -96,6 +99,9 @@ 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());
+ }
private final OpenAPI openAPI;
@@ -145,8 +151,12 @@ public Promise handle(final Context context, fin
// Find matching path template
PathItem matchedPathItem = null;
String matchedTemplate = null;
+ final String basePath = getBasePath(openAPI);
for (Map.Entry entry : openAPI.getPaths().entrySet()) {
- if (pathMatches(entry.getKey(), requestPath)) {
+ final String entryPath = basePath.isEmpty()
+ ? entry.getKey()
+ : basePath.concat(entry.getKey());
+ if (pathMatches(entryPath, requestPath)) {
matchedPathItem = entry.getValue();
matchedTemplate = entry.getKey();
break;
@@ -274,6 +284,29 @@ private static Schema> schemaFromResponse(final ApiResponse apiResponse) {
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
// -----------------------------------------------------------------------
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 4368a546..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
@@ -241,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)
@@ -251,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$}
*
*
@@ -264,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/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java
index b292139e..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$" },
};
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.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
From 49519f46b1cebdfed9168f9f5f5c0f93ab345ae4 Mon Sep 17 00:00:00 2001
From: maximthomas
Date: Fri, 3 Apr 2026 17:50:37 +0300
Subject: [PATCH 8/8] Fix date serialization format
---
.../forgerock/openig/handler/OpenApiMockResponseHandler.java | 2 ++
1 file changed, 2 insertions(+)
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
index 6d781117..89741d52 100644
--- a/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/OpenApiMockResponseHandler.java
@@ -18,6 +18,7 @@
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;
@@ -101,6 +102,7 @@ public class OpenApiMockResponseHandler implements Handler {
private static final ObjectMapper MAPPER = new ObjectMapper();
static {
MAPPER.registerModule(new JavaTimeModule());
+ MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
private final OpenAPI openAPI;