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..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 @@ -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,51 @@ 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 (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 (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); + } + } + } + } + + // ===================== 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..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 @@ -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,131 @@ public void testNormalize31Parameters() { assertNotNull(pathItem.getDelete().getParameters().get(0).getSchema().getTypes()); } + @Test + 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")); + n.normalize(); + + Schema schema = openAPI.getPaths() + .get("/x") + .getGet() + .getParameters() + .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() { 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..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 @@ -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"); + 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..8bfefd24d83b --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/exclusive-min-max.yaml @@ -0,0 +1,81 @@ +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 + /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