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: + *

    + *
  1. Schema {@code format} (date, date-time, email, uri, uuid, ipv4, hostname, byte, password, …)
  2. + *
  3. Field-name dictionary (case-insensitive, separator-agnostic lookup)
  4. + *
  5. Schema {@code type} fallback (generic string / integer / number / boolean)
  6. + *
+ * + *

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: + *

    + *
  1. Matches the incoming request path + method against the paths declared in the spec.
  2. + *
  3. Locates the best response schema (prefers 200, then 201, then first 2xx, then + * {@code default}).
  4. + *
  5. Recursively generates a JSON body from that schema using {@link MockDataGenerator}.
  6. + *
  7. Returns the generated body with {@code Content-Type: application/json}.
  8. + *
+ * + *

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
PropertyTypeRequiredDefaultDescription
specStringYesOpenAPI spec content (YAML or JSON)
defaultStatusCodeIntegerNo200HTTP status code to use for generated responses
arraySizeIntegerNo1Number 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: *

    *
  1. Schema {@code format} (date, date-time, email, uri, uuid, ipv4, hostname, byte, password, …)
  2. - *
  3. Field-name dictionary (case-insensitive, separator-agnostic lookup)
  4. + *
  5. Field-name heuristic powered by Datafaker + * (case-insensitive, separator-agnostic lookup)
  6. *
  7. Schema {@code type} fallback (generic string / integer / number / boolean)
  8. *
* - *

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): *

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