Skip to content

Commit 67a637f

Browse files
authored
Support numeric exclusiveMinimum/exclusiveMaximum in OpenAPI 3.1 (#22981)
* fix: support numeric exclusiveMinimum/exclusiveMaximum in OpenAPI 3.1 (#22943) * preserving the stricter constraint when both bounds are defined. (#22943)
1 parent 561aa2e commit 67a637f

File tree

4 files changed

+309
-0
lines changed

4 files changed

+309
-0
lines changed

modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.slf4j.LoggerFactory;
3434

3535
import java.lang.reflect.Constructor;
36+
import java.math.BigDecimal;
3637
import java.util.*;
3738
import java.util.function.Function;
3839
import java.util.stream.Collectors;
@@ -1789,6 +1790,8 @@ protected Schema processNormalize31Spec(Schema schema, Set<Schema> visitedSchema
17891790
return null;
17901791
}
17911792

1793+
normalizeExclusiveMinMax31(schema);
1794+
17921795
if (schema instanceof JsonSchema &&
17931796
schema.get$schema() == null &&
17941797
schema.getTypes() == null && schema.getType() == null) {
@@ -1883,6 +1886,51 @@ protected Schema processNormalize31Spec(Schema schema, Set<Schema> visitedSchema
18831886
return schema;
18841887
}
18851888

1889+
private void normalizeExclusiveMinMax31(Schema<?> schema) {
1890+
if (schema == null || schema.get$ref() != null) return;
1891+
1892+
// OAS 3.1 numeric exclusiveMinimum
1893+
BigDecimal exclusiveMinValue = schema.getExclusiveMinimumValue();
1894+
if (exclusiveMinValue != null) {
1895+
BigDecimal minimum = schema.getMinimum();
1896+
1897+
if (minimum == null) {
1898+
schema.setMinimum(exclusiveMinValue);
1899+
schema.setExclusiveMinimum(Boolean.TRUE);
1900+
} else {
1901+
int cmp = exclusiveMinValue.compareTo(minimum);
1902+
1903+
if (cmp > 0) {
1904+
schema.setMinimum(exclusiveMinValue);
1905+
schema.setExclusiveMinimum(Boolean.TRUE);
1906+
} else if (cmp == 0) {
1907+
schema.setExclusiveMinimum(Boolean.TRUE);
1908+
}
1909+
}
1910+
}
1911+
1912+
// OAS 3.1 numeric exclusiveMaximum
1913+
BigDecimal exclusiveMaxValue = schema.getExclusiveMaximumValue();
1914+
if (exclusiveMaxValue != null) {
1915+
BigDecimal maximum = schema.getMaximum();
1916+
1917+
if (maximum == null) {
1918+
schema.setMaximum(exclusiveMaxValue);
1919+
schema.setExclusiveMaximum(Boolean.TRUE);
1920+
} else {
1921+
int cmp = exclusiveMaxValue.compareTo(maximum);
1922+
1923+
if (cmp < 0) {
1924+
schema.setMaximum(exclusiveMaxValue);
1925+
schema.setExclusiveMaximum(Boolean.TRUE);
1926+
} else if (cmp == 0) {
1927+
schema.setExclusiveMaximum(Boolean.TRUE);
1928+
}
1929+
}
1930+
}
1931+
}
1932+
1933+
18861934
// ===================== end of rules =====================
18871935

18881936
protected static class Filter {

modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.testng.annotations.Test;
2828

2929
import java.lang.reflect.Array;
30+
import java.math.BigDecimal;
3031
import java.util.*;
3132

3233
import static org.testng.Assert.*;
@@ -619,6 +620,131 @@ public void testNormalize31Parameters() {
619620
assertNotNull(pathItem.getDelete().getParameters().get(0).getSchema().getTypes());
620621
}
621622

623+
@Test
624+
public void testNormalize31ExclusiveMinMaxNumericOnly() {
625+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml");
626+
627+
OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true"));
628+
n.normalize();
629+
630+
Schema<?> schema = openAPI.getPaths()
631+
.get("/x")
632+
.getGet()
633+
.getParameters()
634+
.get(0)
635+
.getSchema();
636+
637+
// exclusiveMinimum: 0
638+
assertEquals(new BigDecimal("0"), schema.getExclusiveMinimumValue());
639+
assertEquals(new BigDecimal("0"), schema.getMinimum());
640+
assertEquals(Boolean.TRUE, schema.getExclusiveMinimum());
641+
642+
// exclusiveMaximum: 10
643+
assertEquals(new BigDecimal("10"), schema.getExclusiveMaximumValue());
644+
assertEquals(new BigDecimal("10"), schema.getMaximum());
645+
assertEquals(Boolean.TRUE, schema.getExclusiveMaximum());
646+
}
647+
648+
@Test
649+
public void testNormalize31ExclusiveMinMaxStricterThanMinMax() {
650+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml");
651+
652+
OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true"));
653+
n.normalize();
654+
655+
Schema<?> schema = openAPI.getPaths()
656+
.get("/foo")
657+
.getGet()
658+
.getParameters()
659+
.get(0)
660+
.getSchema();
661+
662+
assertEquals(new BigDecimal("1"), schema.getExclusiveMinimumValue());
663+
assertEquals(new BigDecimal("1"), schema.getMinimum());
664+
assertEquals(Boolean.TRUE, schema.getExclusiveMinimum());
665+
666+
assertEquals(new BigDecimal("10"), schema.getExclusiveMaximumValue());
667+
assertEquals(new BigDecimal("10"), schema.getMaximum());
668+
assertEquals(Boolean.TRUE, schema.getExclusiveMaximum());
669+
}
670+
671+
@Test
672+
public void testNormalize31ExclusiveMinMaxEqualToMinMax() {
673+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml");
674+
675+
OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true"));
676+
n.normalize();
677+
678+
Schema<?> schema = openAPI.getPaths()
679+
.get("/bar")
680+
.getGet()
681+
.getParameters()
682+
.get(0)
683+
.getSchema();
684+
685+
// minimum: 0 + exclusiveMinimum: 0 → must remain exclusive
686+
assertEquals(new BigDecimal("0"), schema.getExclusiveMinimumValue());
687+
assertEquals(new BigDecimal("0"), schema.getMinimum());
688+
assertEquals(Boolean.TRUE, schema.getExclusiveMinimum());
689+
690+
// maximum: 10 + exclusiveMaximum: 10 → must remain exclusive
691+
assertEquals(new BigDecimal("10"), schema.getExclusiveMaximumValue());
692+
assertEquals(new BigDecimal("10"), schema.getMaximum());
693+
assertEquals(Boolean.TRUE, schema.getExclusiveMaximum());
694+
}
695+
696+
@Test
697+
public void testNormalize31ExclusiveMinMaxInclusiveStricterThanExclusiveValue() {
698+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml");
699+
700+
OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true"));
701+
n.normalize();
702+
703+
Schema<?> schema = openAPI.getPaths()
704+
.get("/baz")
705+
.getGet()
706+
.getParameters()
707+
.get(0)
708+
.getSchema();
709+
710+
// minimum: 5 is stricter than exclusiveMinimum: 0 (x >= 5 dominates x > 0)
711+
assertEquals(new BigDecimal("0"), schema.getExclusiveMinimumValue());
712+
assertEquals(new BigDecimal("5"), schema.getMinimum());
713+
assertNull(schema.getExclusiveMinimum());
714+
715+
// maximum: 10 is stricter than exclusiveMaximum: 11 (x <= 10 dominates x < 11)
716+
assertEquals(new BigDecimal("11"), schema.getExclusiveMaximumValue());
717+
assertEquals(new BigDecimal("10"), schema.getMaximum());
718+
assertNull(schema.getExclusiveMaximum());
719+
}
720+
721+
@Test
722+
public void testNormalize31ExclusiveMinMaxBooleanExclusiveAlreadySet() {
723+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/exclusive-min-max.yaml");
724+
725+
OpenAPINormalizer n = new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true"));
726+
n.normalize();
727+
728+
Schema<?> schema = openAPI.getPaths()
729+
.get("/old")
730+
.getGet()
731+
.getParameters()
732+
.get(0)
733+
.getSchema();
734+
735+
// 3.0-style boolean exclusive flags should remain intact
736+
assertEquals(new BigDecimal("0"), schema.getMinimum());
737+
assertNull(schema.getExclusiveMinimum());
738+
739+
assertEquals(new BigDecimal("10"), schema.getMaximum());
740+
assertNull(schema.getExclusiveMaximum());
741+
742+
// Ensure numeric 3.1 value fields are not unexpectedly set by normalization
743+
assertNull(schema.getExclusiveMinimumValue());
744+
assertNull(schema.getExclusiveMaximumValue());
745+
}
746+
747+
622748
@Test
623749
public void testRemoveXInternal() {
624750
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/enableKeepOnlyFirstTagInOperation_test.yaml");

modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,60 @@ public void shouldApiNameSuffixForApiClassname() throws IOException {
935935
assertThat(notExisting).isNull();
936936
}
937937

938+
@Test
939+
public void shouldGenerateExclusiveMinMaxForOAS31() throws IOException {
940+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
941+
output.deleteOnExit();
942+
943+
OpenAPI openAPI = new OpenAPIParser()
944+
.readLocation("src/test/resources/3_1/exclusive-min-max.yaml", null, new ParseOptions())
945+
.getOpenAPI();
946+
947+
SpringCodegen codegen = new SpringCodegen();
948+
codegen.setLibrary(SPRING_CLOUD_LIBRARY);
949+
codegen.setOutputDir(output.getAbsolutePath());
950+
codegen.additionalProperties().put(INTERFACE_ONLY, "true");
951+
codegen.additionalProperties().put(CodegenConstants.API_PACKAGE, "xyz.controller");
952+
codegen.additionalProperties().put(CodegenConstants.MODEL_PACKAGE, "xyz.model");
953+
954+
ClientOptInput input = new ClientOptInput().openAPI(openAPI).config(codegen);
955+
956+
DefaultGenerator generator = new DefaultGenerator();
957+
generator.setGenerateMetadata(false);
958+
Map<String, File> files = generator.opts(input).generate().stream()
959+
.collect(Collectors.toMap(File::getName, Function.identity()));
960+
961+
System.out.println("Generated files:");
962+
files.keySet().stream().sorted().forEach(System.out::println);
963+
964+
965+
File apiFile = files.get("XApi.java");
966+
assertThat(apiFile).isNotNull();
967+
968+
String content = Files.readString(apiFile.toPath());
969+
970+
var param = openAPI.getPaths()
971+
.get("/x").getGet().getParameters().get(0);
972+
973+
var schema = (io.swagger.v3.oas.models.media.Schema<?>) param.getSchema();
974+
975+
System.out.println("minimum=" + schema.getMinimum());
976+
System.out.println("maximum=" + schema.getMaximum());
977+
System.out.println("exclusiveMinimum=" + schema.getExclusiveMinimum());
978+
System.out.println("exclusiveMaximum=" + schema.getExclusiveMaximum());
979+
System.out.println("exclusiveMinimum class=" + (schema.getExclusiveMinimum() == null ? null : schema.getExclusiveMinimum().getClass()));
980+
981+
System.out.println("schema extensions=" + schema.getExtensions());
982+
983+
assertThat(content).contains("@DecimalMin");
984+
assertThat(content).contains("\"0\"");
985+
assertThat(content).contains("@DecimalMax");
986+
assertThat(content).contains("\"10\"");
987+
assertThat(content).contains("inclusive = false");
988+
assertThat(content).doesNotContain("inclusive = true");
989+
}
990+
991+
938992
@Test
939993
public void shouldUseTagsForClassname() throws IOException {
940994
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
openapi: 3.1.0
2+
info: { title: t, version: 1.0.0 }
3+
paths:
4+
/x:
5+
get:
6+
operationId: getX
7+
parameters:
8+
- name: price
9+
in: query
10+
required: true
11+
schema:
12+
type: number
13+
exclusiveMinimum: 0
14+
exclusiveMaximum: 10
15+
responses:
16+
"200":
17+
description: ok
18+
/foo:
19+
get:
20+
operationId: getFoo
21+
parameters:
22+
- name: foo
23+
in: query
24+
required: true
25+
schema:
26+
type: number
27+
minimum: 0
28+
exclusiveMinimum: 1
29+
maximum: 11
30+
exclusiveMaximum: 10
31+
responses:
32+
"200":
33+
description: ok
34+
/bar:
35+
get:
36+
operationId: getBar
37+
parameters:
38+
- name: bar
39+
in: query
40+
required: true
41+
schema:
42+
type: number
43+
minimum: 0
44+
exclusiveMinimum: 0
45+
maximum: 10
46+
exclusiveMaximum: 10
47+
responses:
48+
"200":
49+
description: ok
50+
/baz:
51+
get:
52+
operationId: getBaz
53+
parameters:
54+
- name: baz
55+
in: query
56+
required: true
57+
schema:
58+
type: number
59+
minimum: 5
60+
exclusiveMinimum: 0
61+
maximum: 10
62+
exclusiveMaximum: 11
63+
responses:
64+
"200":
65+
description: ok
66+
/old:
67+
get:
68+
operationId: getOld
69+
parameters:
70+
- name: old
71+
in: query
72+
required: true
73+
schema:
74+
type: number
75+
minimum: 0
76+
exclusiveMinimum: true
77+
maximum: 10
78+
exclusiveMaximum: true
79+
responses:
80+
"200":
81+
description: ok

0 commit comments

Comments
 (0)