diff --git a/docs/customization.md b/docs/customization.md index e8ef4ca6b852..218cbb2527e0 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -401,6 +401,16 @@ or --import-mappings Pet=my.models.MyPet --import-mappings Order=my.models.MyOrder ``` +## Generated numeric examples + +Generators that use the shared example generator, including HTML documentation and Spring server stubs, +respect `minimum`, `maximum`, `exclusiveMinimum`, and `exclusiveMaximum` when creating numeric examples. +This includes boolean exclusive bounds in OpenAPI 3.0 and numeric exclusive bounds in OpenAPI 3.1. +For example, an integer with `maximum: -5` produces a value at most `-5`, while an integer with +`minimum: 0` and `exclusiveMinimum: true` produces a value greater than `0`. +The bounds must allow a value representable by the property's numeric type and format. +An explicit `example` in the schema takes precedence over generated values. + ## Default Values To customize the default values for containers, one can leverage the option `defaultToEmptyContainer` to customize what to initalize for array/set/map by respecting the default values in the spec diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/examples/ExampleGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/examples/ExampleGenerator.java index a50f6bbbd443..49e9cd7cd7c9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/examples/ExampleGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/examples/ExampleGenerator.java @@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory; import java.math.BigDecimal; +import java.math.RoundingMode; import java.text.SimpleDateFormat; import java.util.*; @@ -265,25 +266,23 @@ private Object resolvePropertyToExample(String propertyName, String mediaType, S } else if (ModelUtils.isDateTimeSchema(property)) { return "2000-01-23T04:56:07.000+00:00"; } else if (ModelUtils.isNumberSchema(property)) { - Double min = getPropertyValue(property.getMinimum()); - Double max = getPropertyValue(property.getMaximum()); + BigDecimal value = randomNumber(property); if (ModelUtils.isFloatSchema(property)) { // float - return (float) randomNumber(min, max); + return value.floatValue(); } else if (ModelUtils.isDoubleSchema(property)) { // decimal/double - return BigDecimal.valueOf(randomNumber(min, max)); + return value; } else { // no format defined - return randomNumber(min, max); + return value.doubleValue(); } } else if (ModelUtils.isFileSchema(property)) { return ""; // TODO } else if (ModelUtils.isIntegerSchema(property)) { - Double min = getPropertyValue(property.getMinimum()); - Double max = getPropertyValue(property.getMaximum()); + BigDecimal value = randomNumber(property); if (ModelUtils.isLongSchema(property)) { - return (long) randomNumber(min, max); + return value.max(BigDecimal.valueOf(Long.MIN_VALUE)).min(BigDecimal.valueOf(Long.MAX_VALUE)).longValue(); } - return (int) randomNumber(min, max); + return value.max(BigDecimal.valueOf(Integer.MIN_VALUE)).min(BigDecimal.valueOf(Integer.MAX_VALUE)).intValue(); } else if (ModelUtils.isMapSchema(property)) { Map mp = new HashMap(); if (property.getName() != null) { @@ -333,6 +332,53 @@ private Double getPropertyValue(BigDecimal propertyValue) { return propertyValue == null ? null : propertyValue.doubleValue(); } + private BigDecimal randomNumber(Schema property) { + ModelUtils.ResolvedMinBound minBound = ModelUtils.resolveMinimumBound(openAPI, property); + ModelUtils.ResolvedMaxBound maxBound = ModelUtils.resolveMaximumBound(openAPI, property); + BigDecimal min = minBound == null ? null : numericBound(property, minBound.minBound, minBound.exclusive, true); + BigDecimal max = maxBound == null ? null : numericBound(property, maxBound.maxBound, maxBound.exclusive, false); + double generated = randomNumber(getPropertyValue(min), getPropertyValue(max)); + BigDecimal value; + if (ModelUtils.isIntegerSchema(property)) { + value = BigDecimal.valueOf(ModelUtils.isLongSchema(property) ? (long) generated : (int) generated); + } else { + value = BigDecimal.valueOf(generated); + } + // Keep rounding and integer conversion from moving the example outside the schema's bounds. + if (min != null) { + value = value.max(min); + } + if (max != null) { + value = value.min(max); + } + return value; + } + + private BigDecimal numericBound(Schema property, BigDecimal bound, boolean exclusive, boolean minimum) { + if (ModelUtils.isIntegerSchema(property)) { + BigDecimal rounded = bound.setScale(0, minimum ? RoundingMode.CEILING : RoundingMode.FLOOR); + if (exclusive && rounded.compareTo(bound) == 0) { + rounded = rounded.add(minimum ? BigDecimal.ONE : BigDecimal.ONE.negate()); + } + return rounded; + } + + // Use the serialized float/double value when finding a representable inclusive bound. + boolean isFloat = ModelUtils.isFloatSchema(property); + if (isFloat && (minimum ? bound.floatValue() == Float.NEGATIVE_INFINITY : bound.floatValue() == Float.POSITIVE_INFINITY)) { + return new BigDecimal(Float.toString(minimum ? -Float.MAX_VALUE : Float.MAX_VALUE)); + } + BigDecimal rounded = isFloat ? new BigDecimal(Float.toString(bound.floatValue())) : BigDecimal.valueOf(bound.doubleValue()); + int comparison = rounded.compareTo(bound); + if ((minimum ? comparison < 0 : comparison > 0) || (exclusive && comparison == 0)) { + if (isFloat) { + return new BigDecimal(Float.toString(minimum ? Math.nextUp(bound.floatValue()) : Math.nextDown(bound.floatValue()))); + } + return BigDecimal.valueOf(minimum ? Math.nextUp(bound.doubleValue()) : Math.nextDown(bound.doubleValue())); + } + return rounded; + } + private double randomNumber(Double min, Double max) { if (min != null && max != null) { double range = max - min; @@ -340,7 +386,7 @@ private double randomNumber(Double min, Double max) { } else if (min != null) { return random.nextDouble() + min; } else if (max != null) { - return random.nextDouble() * max; + return max < 0 ? max - random.nextDouble() : random.nextDouble() * max; } else { return random.nextDouble() * 10; } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ExampleGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ExampleGeneratorTest.java index ef9895b63e13..fa5933730592 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ExampleGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ExampleGeneratorTest.java @@ -12,6 +12,7 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import java.math.BigDecimal; import java.util.*; import com.fasterxml.jackson.databind.JsonNode; @@ -22,6 +23,103 @@ import static org.testng.AssertJUnit.assertTrue; public class ExampleGeneratorTest { + @DataProvider(name = "numericPrecision") + public Object[][] numericPrecision() { + return new Object[][] { + {new IntegerSchema().format("int64").minimum(BigDecimal.valueOf(Long.MAX_VALUE)), "9223372036854775807"}, + {new IntegerSchema().maximum(new BigDecimal("1e400")), "2147483647"}, + {new IntegerSchema().format("int64").minimum(new BigDecimal("-1e400")), "-9223372036854775808"}, + {new IntegerSchema().format("int64").minimum(new BigDecimal("9007199254740993")) + .maximum(new BigDecimal("9007199254740993")), "9007199254740993"}, + {new Schema<>().type("number").format("float").minimum(BigDecimal.ONE).exclusiveMinimum(true) + .maximum(new BigDecimal("1.0000001")), "1.0000001"}, + {new Schema<>().type("number").format("float").minimum(new BigDecimal("0.99999994")) + .maximum(BigDecimal.ONE).exclusiveMaximum(true), "0.99999994"}, + {new Schema<>().type("number").format("double").minimum(BigDecimal.ONE).exclusiveMinimum(true) + .maximum(new BigDecimal("1.0000000000000002")), "1.0000000000000002"}, + {new Schema<>().type("number").format("double").minimum(new BigDecimal("0.9999999999999999")) + .maximum(BigDecimal.ONE).exclusiveMaximum(true), "0.9999999999999999"}, + {new IntegerSchema().minimum(BigDecimal.valueOf(-2)).exclusiveMinimum(true) + .maximum(BigDecimal.ZERO).exclusiveMaximum(true), "-1"}, + {new IntegerSchema().minimum(BigDecimal.valueOf(3)).exclusiveMinimumValue(BigDecimal.ONE) + .maximum(BigDecimal.valueOf(3)), "3"}, + {new IntegerSchema().minimum(BigDecimal.ONE).exclusiveMinimumValue(BigDecimal.ONE) + .maximum(BigDecimal.valueOf(2)), "2"}, + {new IntegerSchema().minimum(BigDecimal.valueOf(-2)).maximum(BigDecimal.valueOf(-2)) + .exclusiveMaximumValue(BigDecimal.valueOf(-1)), "-2"}, + {new IntegerSchema().minimum(BigDecimal.valueOf(-2)).maximum(BigDecimal.valueOf(-1)) + .exclusiveMaximumValue(BigDecimal.valueOf(-1)), "-2"} + }; + } + + @Test(dataProvider = "numericPrecision") + public void generateNumericExamplePreservesPrecision(Schema schema, String expected) throws Exception { + ExampleGenerator generator = new ExampleGenerator(Collections.emptyMap(), new OpenAPI()); + List> examples = generator.generate(null, Collections.singletonList("application/json"), schema); + BigDecimal example = new BigDecimal(examples.get(0).get("example")); + + assertEquals(0, example.compareTo(new BigDecimal(expected))); + } + + @DataProvider(name = "numericBounds") + public Object[][] numericBounds() { + List cases = new ArrayList<>(); + String[][] formats = {{"integer", null}, {"integer", "int32"}, {"integer", "int64"}, + {"number", null}, {"number", "float"}, {"number", "double"}}; + Object[][] bounds = {{null, "-5", false, false}, {"0", null, true, false}, + {null, "0", false, true}, {"-5", "-4", true, false}, + {"1", "2", false, true}, {"1.2", null, false, false}, + {null, "-1.2", false, false}, {"-1.2", "1.2", true, true}, + {null, "1e40", false, true}, {"-1e40", null, true, false}}; + for (String[] format : formats) { + for (Object[] bound : bounds) { + for (boolean openAPI31 : new boolean[]{false, true}) { + cases.add(new Object[]{format[0], format[1], bound[0], bound[1], bound[2], bound[3], openAPI31}); + } + } + } + return cases.toArray(new Object[0][]); + } + + @Test(dataProvider = "numericBounds") + public void generateNumericExampleWithinBounds(String type, String format, String minimum, String maximum, + boolean exclusiveMinimum, boolean exclusiveMaximum, + boolean openAPI31) throws Exception { + BigDecimal min = minimum == null ? null : new BigDecimal(minimum); + BigDecimal max = maximum == null ? null : new BigDecimal(maximum); + Schema schema = new Schema<>().type(type).format(format); + if (openAPI31 && exclusiveMinimum) { + schema.setExclusiveMinimumValue(min); + } else { + schema.setMinimum(min); + schema.setExclusiveMinimum(exclusiveMinimum); + } + if (openAPI31 && exclusiveMaximum) { + schema.setExclusiveMaximumValue(max); + } else { + schema.setMaximum(max); + schema.setExclusiveMaximum(exclusiveMaximum); + } + ExampleGenerator generator = new ExampleGenerator(Collections.emptyMap(), new OpenAPI()); + for (int i = 0; i < 10; i++) { + List> examples = generator.generate(null, Collections.singletonList("application/json"), schema); + JsonNode example = new ObjectMapper().readTree(examples.get(0).get("example")); + assertTrue(example.isNumber()); + if ("integer".equals(type)) { + assertTrue(example.isIntegralNumber()); + } + BigDecimal value = new BigDecimal(example.asText()); + if (min != null) { + assertTrue(value + " violates minimum " + min, + exclusiveMinimum ? value.compareTo(min) > 0 : value.compareTo(min) >= 0); + } + if (max != null) { + assertTrue(value + " violates maximum " + max, + exclusiveMaximum ? value.compareTo(max) < 0 : value.compareTo(max) <= 0); + } + } + } + @DataProvider(name = "namedMapSchemas") public Object[][] namedMapSchemas() { return new Object[][] {