From 1b6f408552519343c427c76ed58d415681abbe20 Mon Sep 17 00:00:00 2001 From: "johannes.breit" Date: Sat, 14 Feb 2026 21:22:32 +0100 Subject: [PATCH 1/2] fix: support numeric exclusiveMinimum/exclusiveMaximum in OpenAPI 3.1 (#22943) --- .../codegen/OpenAPINormalizer.java | 22 ++++++++ .../codegen/OpenAPINormalizerTest.java | 23 ++++++++ .../java/spring/SpringCodegenTest.java | 54 +++++++++++++++++++ .../test/resources/3_1/exclusive-min-max.yaml | 17 ++++++ 4 files changed, 116 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 39537dfc4b9c..38659f179dee 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -33,6 +33,7 @@ import org.slf4j.LoggerFactory; import java.lang.reflect.Constructor; +import java.math.BigDecimal; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -1789,6 +1790,8 @@ protected Schema processNormalize31Spec(Schema schema, Set visitedSchema return null; } + normalizeExclusiveMinMax31(schema); + if (schema instanceof JsonSchema && schema.get$schema() == null && schema.getTypes() == null && schema.getType() == null) { @@ -1883,6 +1886,25 @@ protected Schema processNormalize31Spec(Schema schema, Set visitedSchema return schema; } + private void normalizeExclusiveMinMax31(Schema schema) { + if (schema == null || schema.get$ref() != null) return; + + // OAS 3.1 numeric exclusiveMinimum + BigDecimal exclusiveMinValue = schema.getExclusiveMinimumValue(); + if (schema.getMinimum() == null && exclusiveMinValue != null) { + schema.setMinimum(exclusiveMinValue); + schema.setExclusiveMinimum(Boolean.TRUE); + } + + // OAS 3.1 numeric exclusiveMaximum + BigDecimal exclusiveMaxValue = schema.getExclusiveMaximumValue(); + if (schema.getMaximum() == null && exclusiveMaxValue != null) { + schema.setMaximum(exclusiveMaxValue); + schema.setExclusiveMaximum(Boolean.TRUE); + } + } + + // ===================== end of rules ===================== protected static class Filter { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index e38373e8d81c..3f99a5368f09 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -27,6 +27,7 @@ import org.testng.annotations.Test; import java.lang.reflect.Array; +import java.math.BigDecimal; import java.util.*; import static org.testng.Assert.*; @@ -619,6 +620,28 @@ public void testNormalize31Parameters() { assertNotNull(pathItem.getDelete().getParameters().get(0).getSchema().getTypes()); } + @Test + public void testNormalize31ExclusiveMinMaxNumeric() { + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml"); + + OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")); + n.normalize(); + + Schema schema = openAPI.getPaths() + .get("/x") + .getGet() + .getParameters() + .get(0) + .getSchema(); + + assertEquals(new BigDecimal("0"), schema.getMinimum()); + assertEquals(Boolean.TRUE, schema.getExclusiveMinimum()); + + assertEquals(new BigDecimal("10"), schema.getMaximum()); + assertEquals(Boolean.TRUE, schema.getExclusiveMaximum()); + } + + @Test public void testRemoveXInternal() { OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/enableKeepOnlyFirstTagInOperation_test.yaml"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index ac819033d71e..fd3c8f101311 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -934,6 +934,60 @@ public void shouldApiNameSuffixForApiClassname() throws IOException { assertThat(notExisting).isNull(); } + @Test + public void shouldGenerateExclusiveMinMaxForOAS31() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + OpenAPI openAPI = new OpenAPIParser() + .readLocation("src/test/resources/3_1/exclusive-min-max.yaml", null, new ParseOptions()) + .getOpenAPI(); + + SpringCodegen codegen = new SpringCodegen(); + codegen.setLibrary(SPRING_CLOUD_LIBRARY); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(INTERFACE_ONLY, "true"); + codegen.additionalProperties().put(CodegenConstants.API_PACKAGE, "xyz.controller"); + codegen.additionalProperties().put(CodegenConstants.MODEL_PACKAGE, "xyz.model"); + + ClientOptInput input = new ClientOptInput().openAPI(openAPI).config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + System.out.println("Generated files:"); + files.keySet().stream().sorted().forEach(System.out::println); + + + File apiFile = files.get("XApi.java"); // oder XApi.java je nach Tag/operation grouping + assertThat(apiFile).isNotNull(); + + String content = Files.readString(apiFile.toPath()); + + var param = openAPI.getPaths() + .get("/x").getGet().getParameters().get(0); + + var schema = (io.swagger.v3.oas.models.media.Schema) param.getSchema(); + + System.out.println("minimum=" + schema.getMinimum()); + System.out.println("maximum=" + schema.getMaximum()); + System.out.println("exclusiveMinimum=" + schema.getExclusiveMinimum()); + System.out.println("exclusiveMaximum=" + schema.getExclusiveMaximum()); + System.out.println("exclusiveMinimum class=" + (schema.getExclusiveMinimum() == null ? null : schema.getExclusiveMinimum().getClass())); + + System.out.println("schema extensions=" + schema.getExtensions()); + + assertThat(content).contains("@DecimalMin"); + assertThat(content).contains("\"0\""); + assertThat(content).contains("@DecimalMax"); + assertThat(content).contains("\"10\""); + assertThat(content).contains("inclusive = false"); + assertThat(content).doesNotContain("inclusive = true"); + } + + @Test public void shouldUseTagsForClassname() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); diff --git a/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml b/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml new file mode 100644 index 000000000000..0ec76433f409 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: { title: t, version: 1.0.0 } +paths: + /x: + get: + operationId: getX + parameters: + - name: price + in: query + required: true + schema: + type: number + exclusiveMinimum: 0 + exclusiveMaximum: 10 + responses: + "200": + description: ok From 87795c7fcfcdc3022ab41669c5805afc0e66b974 Mon Sep 17 00:00:00 2001 From: "johannes.breit" Date: Sun, 15 Feb 2026 20:02:18 +0100 Subject: [PATCH 2/2] preserving the stricter constraint when both bounds are defined. (#22943) --- .../codegen/OpenAPINormalizer.java | 38 ++++++- .../codegen/OpenAPINormalizerTest.java | 105 +++++++++++++++++- .../java/spring/SpringCodegenTest.java | 2 +- .../test/resources/3_1/exclusive-min-max.yaml | 64 +++++++++++ 4 files changed, 201 insertions(+), 8 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 38659f179dee..ed279831c7ef 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -1891,16 +1891,42 @@ private void normalizeExclusiveMinMax31(Schema schema) { // OAS 3.1 numeric exclusiveMinimum BigDecimal exclusiveMinValue = schema.getExclusiveMinimumValue(); - if (schema.getMinimum() == null && exclusiveMinValue != null) { - schema.setMinimum(exclusiveMinValue); - schema.setExclusiveMinimum(Boolean.TRUE); + if (exclusiveMinValue != null) { + BigDecimal minimum = schema.getMinimum(); + + if (minimum == null) { + schema.setMinimum(exclusiveMinValue); + schema.setExclusiveMinimum(Boolean.TRUE); + } else { + int cmp = exclusiveMinValue.compareTo(minimum); + + if (cmp > 0) { + schema.setMinimum(exclusiveMinValue); + schema.setExclusiveMinimum(Boolean.TRUE); + } else if (cmp == 0) { + schema.setExclusiveMinimum(Boolean.TRUE); + } + } } // OAS 3.1 numeric exclusiveMaximum BigDecimal exclusiveMaxValue = schema.getExclusiveMaximumValue(); - if (schema.getMaximum() == null && exclusiveMaxValue != null) { - schema.setMaximum(exclusiveMaxValue); - schema.setExclusiveMaximum(Boolean.TRUE); + if (exclusiveMaxValue != null) { + BigDecimal maximum = schema.getMaximum(); + + if (maximum == null) { + schema.setMaximum(exclusiveMaxValue); + schema.setExclusiveMaximum(Boolean.TRUE); + } else { + int cmp = exclusiveMaxValue.compareTo(maximum); + + if (cmp < 0) { + schema.setMaximum(exclusiveMaxValue); + schema.setExclusiveMaximum(Boolean.TRUE); + } else if (cmp == 0) { + schema.setExclusiveMaximum(Boolean.TRUE); + } + } } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 3f99a5368f09..735b0ea67268 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -621,7 +621,7 @@ public void testNormalize31Parameters() { } @Test - public void testNormalize31ExclusiveMinMaxNumeric() { + public void testNormalize31ExclusiveMinMaxNumericOnly() { OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml"); OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")); @@ -634,13 +634,116 @@ public void testNormalize31ExclusiveMinMaxNumeric() { .get(0) .getSchema(); + // exclusiveMinimum: 0 + assertEquals(new BigDecimal("0"), schema.getExclusiveMinimumValue()); assertEquals(new BigDecimal("0"), schema.getMinimum()); assertEquals(Boolean.TRUE, schema.getExclusiveMinimum()); + // exclusiveMaximum: 10 + assertEquals(new BigDecimal("10"), schema.getExclusiveMaximumValue()); assertEquals(new BigDecimal("10"), schema.getMaximum()); assertEquals(Boolean.TRUE, schema.getExclusiveMaximum()); } + @Test + public void testNormalize31ExclusiveMinMaxStricterThanMinMax() { + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml"); + + OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")); + n.normalize(); + + Schema schema = openAPI.getPaths() + .get("/foo") + .getGet() + .getParameters() + .get(0) + .getSchema(); + + assertEquals(new BigDecimal("1"), schema.getExclusiveMinimumValue()); + assertEquals(new BigDecimal("1"), schema.getMinimum()); + assertEquals(Boolean.TRUE, schema.getExclusiveMinimum()); + + assertEquals(new BigDecimal("10"), schema.getExclusiveMaximumValue()); + assertEquals(new BigDecimal("10"), schema.getMaximum()); + assertEquals(Boolean.TRUE, schema.getExclusiveMaximum()); + } + + @Test + public void testNormalize31ExclusiveMinMaxEqualToMinMax() { + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml"); + + OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")); + n.normalize(); + + Schema schema = openAPI.getPaths() + .get("/bar") + .getGet() + .getParameters() + .get(0) + .getSchema(); + + // minimum: 0 + exclusiveMinimum: 0 → must remain exclusive + assertEquals(new BigDecimal("0"), schema.getExclusiveMinimumValue()); + assertEquals(new BigDecimal("0"), schema.getMinimum()); + assertEquals(Boolean.TRUE, schema.getExclusiveMinimum()); + + // maximum: 10 + exclusiveMaximum: 10 → must remain exclusive + assertEquals(new BigDecimal("10"), schema.getExclusiveMaximumValue()); + assertEquals(new BigDecimal("10"), schema.getMaximum()); + assertEquals(Boolean.TRUE, schema.getExclusiveMaximum()); + } + + @Test + public void testNormalize31ExclusiveMinMaxInclusiveStricterThanExclusiveValue() { + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml"); + + OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")); + n.normalize(); + + Schema schema = openAPI.getPaths() + .get("/baz") + .getGet() + .getParameters() + .get(0) + .getSchema(); + + // minimum: 5 is stricter than exclusiveMinimum: 0 (x >= 5 dominates x > 0) + assertEquals(new BigDecimal("0"), schema.getExclusiveMinimumValue()); + assertEquals(new BigDecimal("5"), schema.getMinimum()); + assertNull(schema.getExclusiveMinimum()); + + // maximum: 10 is stricter than exclusiveMaximum: 11 (x <= 10 dominates x < 11) + assertEquals(new BigDecimal("11"), schema.getExclusiveMaximumValue()); + assertEquals(new BigDecimal("10"), schema.getMaximum()); + assertNull(schema.getExclusiveMaximum()); + } + + @Test + public void testNormalize31ExclusiveMinMaxBooleanExclusiveAlreadySet() { + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml"); + + OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")); + n.normalize(); + + Schema schema = openAPI.getPaths() + .get("/old") + .getGet() + .getParameters() + .get(0) + .getSchema(); + + // 3.0-style boolean exclusive flags should remain intact + assertEquals(new BigDecimal("0"), schema.getMinimum()); + assertNull(schema.getExclusiveMinimum()); + + assertEquals(new BigDecimal("10"), schema.getMaximum()); + assertNull(schema.getExclusiveMaximum()); + + // Ensure numeric 3.1 value fields are not unexpectedly set by normalization + assertNull(schema.getExclusiveMinimumValue()); + assertNull(schema.getExclusiveMaximumValue()); + } + @Test public void testRemoveXInternal() { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index fd3c8f101311..914e782c3cd6 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -961,7 +961,7 @@ public void shouldGenerateExclusiveMinMaxForOAS31() throws IOException { files.keySet().stream().sorted().forEach(System.out::println); - File apiFile = files.get("XApi.java"); // oder XApi.java je nach Tag/operation grouping + File apiFile = files.get("XApi.java"); assertThat(apiFile).isNotNull(); String content = Files.readString(apiFile.toPath()); diff --git a/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml b/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml index 0ec76433f409..8bfefd24d83b 100644 --- a/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml +++ b/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml @@ -15,3 +15,67 @@ paths: responses: "200": description: ok + /foo: + get: + operationId: getFoo + parameters: + - name: foo + in: query + required: true + schema: + type: number + minimum: 0 + exclusiveMinimum: 1 + maximum: 11 + exclusiveMaximum: 10 + responses: + "200": + description: ok + /bar: + get: + operationId: getBar + parameters: + - name: bar + in: query + required: true + schema: + type: number + minimum: 0 + exclusiveMinimum: 0 + maximum: 10 + exclusiveMaximum: 10 + responses: + "200": + description: ok + /baz: + get: + operationId: getBaz + parameters: + - name: baz + in: query + required: true + schema: + type: number + minimum: 5 + exclusiveMinimum: 0 + maximum: 10 + exclusiveMaximum: 11 + responses: + "200": + description: ok + /old: + get: + operationId: getOld + parameters: + - name: old + in: query + required: true + schema: + type: number + minimum: 0 + exclusiveMinimum: true + maximum: 10 + exclusiveMaximum: true + responses: + "200": + description: ok \ No newline at end of file