diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 84d8cf484f73..db66e279eb85 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1093,6 +1093,39 @@ public static List getAllSchemas(OpenAPI openAPI) { return allSchemas; } + /** + * Return the list of all schemas in the entire OpenAPI document, including inline schemas + * defined in path operations (request bodies, responses, parameters, headers, callbacks) + * and schemas under components/schemas. Results are deduplicated by identity. + * This is a superset of {@link #getAllSchemas(OpenAPI)}. + * + * @param openAPI specification + * @return schemas a deduplicated list of all schemas in the document + */ + public static List getAllSchemasInDocument(OpenAPI openAPI) { + List allSchemas = new ArrayList(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + + // Visit schemas reachable from paths (inline + $ref targets) + visitOpenAPI(openAPI, (s, mimeType) -> { + if (seen.add(s)) { + allSchemas.add(s); + } + }); + + // Also visit components/schemas entries not reachable from any path + List refSchemas = new ArrayList(); + getSchemas(openAPI).forEach((key, schema) -> { + visitSchema(openAPI, schema, null, refSchemas, (s, mimeType) -> { + if (seen.add(s)) { + allSchemas.add(s); + } + }); + }); + + return allSchemas; + } + /** * If a RequestBody contains a reference to another RequestBody with '$ref', returns the referenced RequestBody if it is found or the actual RequestBody in the other cases. * diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java index 56097b74d0fd..ced0a74cf7cc 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java @@ -56,6 +56,29 @@ public ValidationResult validate(OpenAPI specification) { validationResult.consume(schemaValidations.validate(wrapper)); }); + // Per-occurrence check: default value not in enum. + // Uses getAllSchemasInDocument to also cover inline schemas in path operations. + if (ruleConfiguration.isEnableRecommendations() + && ruleConfiguration.isEnableDefaultNotInEnumRecommendation()) { + ValidationRule defaultNotInEnumRule = ValidationRule.create(Severity.WARNING, + "Schema has default value not in enum", + "While technically valid, a default outside the enum may cause " + + "generators to emit incorrect default values.", + s -> ValidationRule.Pass.empty()); + for (Schema schema : ModelUtils.getAllSchemasInDocument(specification)) { + List enumList = schema.getEnum(); + Object defaultValue = schema.getDefault(); + if (enumList != null && !enumList.isEmpty() + && defaultValue != null + && !enumList.contains(defaultValue)) { + validationResult.addResult(Validated.invalid(defaultNotInEnumRule, + String.format(Locale.ROOT, + "Schema has default value '%s' not in enum %s", + defaultValue, enumList))); + } + } + } + List parameters = new ArrayList<>(50); Paths paths = specification.getPaths(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java index 6c7f41b9904d..febee8024975 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java @@ -138,6 +138,20 @@ public class RuleConfiguration { * @param enableApiRequestUriWithBodyRecommendation true to enable, false to disable */ private boolean enableApiRequestUriWithBodyRecommendation = defaultedBoolean(propertyPrefix + ".anti-patterns.uri-unexpected-body", true); + /** + * -- GETTER -- + * Gets whether the recommendation check for default values not in enum is enabled. + *

+ * JSON Schema treats 'default' as an annotation keyword — it is RECOMMENDED to validate + * against the schema but not required. A default outside the enum is technically valid + * but causes generators to emit incorrect default values. + * + * @return true if enabled, false if disabled + * -- SETTER -- + * Enable or Disable the recommendation check for default values not in enum. + * @param enableDefaultNotInEnumRecommendation true to enable, false to disable + */ + private boolean enableDefaultNotInEnumRecommendation = defaultedBoolean(propertyPrefix + ".default-not-in-enum", true); @SuppressWarnings("SameParameterValue") private static boolean defaultedBoolean(String key, boolean defaultValue) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java new file mode 100644 index 000000000000..e882fd21e638 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java @@ -0,0 +1,286 @@ +package org.openapitools.codegen.validations.oas; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.openapitools.codegen.validation.Invalid; +import org.openapitools.codegen.validation.ValidationResult; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class OpenApiEvaluatorTest { + + private static OpenAPI buildSpecWithEnumDefault(List enumValues, Object defaultValue) { + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(enumValues.stream() + .filter(v -> v instanceof String) + .map(v -> (String) v) + .collect(Collectors.toList())); + prop.setDefault(defaultValue); + obj.addProperty("protocol", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + return openAPI; + } + + private static List getDefaultNotInEnumWarnings(ValidationResult result) { + return result.getWarnings().stream() + .filter(i -> i.getMessage().contains("not in enum")) + .collect(Collectors.toList()); + } + + @Test(description = "warn when default is not in enum") + public void testDefaultNotInEnum() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("udp", "tcp"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'http'")); + Assert.assertTrue(warnings.get(0).getMessage().contains("[udp, tcp]")); + } + + @Test(description = "no warning when default is in enum") + public void testDefaultInEnum() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("http", "https"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "no warning when rule is disabled individually") + public void testDefaultNotInEnumDisabledRule() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + config.setEnableDefaultNotInEnumRecommendation(false); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("udp"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "no warning when all recommendations are disabled") + public void testDefaultNotInEnumRecommendationsOff() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(false); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("udp"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "multiple schemas with default not in enum produce separate warnings") + public void testDefaultNotInEnumMultipleOccurrences() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + + ObjectSchema udpConfig = new ObjectSchema(); + StringSchema proto1 = new StringSchema(); + proto1.setEnum(Arrays.asList("udp")); + proto1.setDefault("http"); + udpConfig.addProperty("protocol", proto1); + + ObjectSchema tcpConfig = new ObjectSchema(); + StringSchema proto2 = new StringSchema(); + proto2.setEnum(Arrays.asList("tcp")); + proto2.setDefault("http"); + tcpConfig.addProperty("protocol", proto2); + + components.addSchemas("UdpConfig", udpConfig); + components.addSchemas("TcpConfig", tcpConfig); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + // Two property schemas with distinct enum values → two unique messages + Assert.assertEquals(warnings.size(), 2); + } + + @Test(description = "warn for integer default not in integer enum") + public void testDefaultNotInEnumInteger() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + IntegerSchema prop = new IntegerSchema(); + prop.setEnum(Arrays.asList(1, 2, 3)); + prop.setDefault(99); + obj.addProperty("code", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'99'")); + } + + @Test(description = "no warning when schema has no enum") + public void testNoEnum() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setDefault("http"); + obj.addProperty("protocol", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "no warning when schema has no default") + public void testNoDefault() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(Arrays.asList("udp", "tcp")); + obj.addProperty("protocol", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "warn for default not in enum in inline request body schema") + public void testDefaultNotInEnumInlineRequestBody() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + + // Build an inline schema in a request body (not in components/schemas) + ObjectSchema bodySchema = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(Arrays.asList("udp", "tcp")); + prop.setDefault("http"); + bodySchema.addProperty("protocol", prop); + + MediaType mediaType = new MediaType(); + mediaType.setSchema(bodySchema); + Content content = new Content(); + content.addMediaType("application/json", mediaType); + RequestBody requestBody = new RequestBody(); + requestBody.setContent(content); + + Operation operation = new Operation(); + operation.setRequestBody(requestBody); + operation.setResponses(new ApiResponses()); + + PathItem pathItem = new PathItem(); + pathItem.setPost(operation); + Paths paths = new Paths(); + paths.addPathItem("/test", pathItem); + openAPI.setPaths(paths); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'http'")); + } + + @Test(description = "warn for default not in enum in inline response schema") + public void testDefaultNotInEnumInlineResponse() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + + // Build an inline schema in a response (not in components/schemas) + ObjectSchema responseSchema = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(Arrays.asList("tcp")); + prop.setDefault("http"); + responseSchema.addProperty("protocol", prop); + + MediaType mediaType = new MediaType(); + mediaType.setSchema(responseSchema); + Content content = new Content(); + content.addMediaType("application/json", mediaType); + ApiResponse apiResponse = new ApiResponse(); + apiResponse.setContent(content); + ApiResponses responses = new ApiResponses(); + responses.addApiResponse("200", apiResponse); + + Operation operation = new Operation(); + operation.setResponses(responses); + + PathItem pathItem = new PathItem(); + pathItem.setGet(operation); + Paths paths = new Paths(); + paths.addPathItem("/test", pathItem); + openAPI.setPaths(paths); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'http'")); + } +}