diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java index c2f6dcf11..d1e839e20 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java @@ -3,6 +3,7 @@ import static org.jboss.jandex.DotName.createComponentized; import java.math.BigDecimal; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -258,7 +259,7 @@ private void applyStringConstraints(AnnotationTarget target, decimalMax(target, schema); decimalMin(target, schema); pattern(target, schema); - digits(target, schema); + digitsString(target, schema); notBlank(target, schema, propertyKey, handler); notNull(target, propertyKey, handler); notNullKotlin(target, propertyKey, handler); @@ -301,7 +302,7 @@ private void applyNumberConstraints(AnnotationTarget target, RequirementHandler handler) { decimalMax(target, schema); decimalMin(target, schema); - digits(target, schema); + digitsNumber(target, schema); max(target, schema); min(target, schema); negative(target, schema); @@ -340,8 +341,8 @@ void decimalMax(AnnotationTarget target, Schema schema) { } else { schema.setMaximum(decimal); } - } catch (@SuppressWarnings("unused") NumberFormatException e) { - DataObjectLogging.logger.invalidAnnotationFormat(decimalValue); + } catch (NumberFormatException e) { + DataObjectLogging.logger.invalidAnnotationFormat(decimalValue, e.getMessage()); } } } @@ -360,14 +361,14 @@ void decimalMin(AnnotationTarget target, Schema schema) { } else { schema.setMinimum(decimal); } - } catch (@SuppressWarnings("unused") NumberFormatException e) { - DataObjectLogging.logger.invalidAnnotationFormat(decimalValue); + } catch (NumberFormatException e) { + DataObjectLogging.logger.invalidAnnotationFormat(decimalValue, e.getMessage()); } } } - void digits(AnnotationTarget target, Schema schema) { + void digitsString(AnnotationTarget target, Schema schema) { AnnotationInstance constraint = getConstraint(target, BV_DIGITS); if (constraint != null && schema.getPattern() == null) { @@ -401,6 +402,24 @@ void digits(AnnotationTarget target, Schema schema) { } } + void digitsNumber(AnnotationTarget target, Schema schema) { + AnnotationInstance constraint = getConstraint(target, BV_DIGITS); + + if (constraint != null && schema.getMultipleOf() == null) { + // `fraction` attribute is required - safe to use primitive. + final int fractionPart = context.annotations().value(constraint, "fraction"); + BigDecimal multipleOf; + + if (fractionPart > 0) { + multipleOf = new BigDecimal(BigInteger.ONE, fractionPart); + } else { + multipleOf = BigDecimal.ONE; + } + + schema.setMultipleOf(multipleOf); + } + } + void max(AnnotationTarget target, Schema schema) { AnnotationInstance constraint = getConstraint(target, BV_MAX); diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/DataObjectLogging.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/DataObjectLogging.java index fd8568004..02e91bb1b 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/DataObjectLogging.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/DataObjectLogging.java @@ -1,5 +1,7 @@ package io.smallrye.openapi.runtime.scanner.dataobject; +import static java.lang.invoke.MethodHandles.lookup; + import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; @@ -13,15 +15,16 @@ @MessageLogger(projectCode = "SROAP", length = 5) interface DataObjectLogging extends BasicLogger { - DataObjectLogging logger = Logger.getMessageLogger(DataObjectLogging.class, DataObjectLogging.class.getPackage().getName()); + DataObjectLogging logger = Logger.getMessageLogger(lookup(), DataObjectLogging.class, + DataObjectLogging.class.getPackage().getName()); @LogMessage(level = Logger.Level.DEBUG) @Message(id = 6000, value = "Processing @Schema annotation %s on a field %s") void processingFieldAnnotation(AnnotationInstance annotation, String propertyKey); @LogMessage(level = Logger.Level.DEBUG) - @Message(id = 6001, value = "Annotation value has invalid format: %s") - void invalidAnnotationFormat(String decimalValue); + @Message(id = 6001, value = "Annotation value has invalid format: %s. Exception: %s") + void invalidAnnotationFormat(String decimalValue, String exceptionMessage); @LogMessage(level = Logger.Level.DEBUG) @Message(id = 6002, value = "Possible cycle was detected at: %s. Will not search further.") diff --git a/core/src/test/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScannerTest.java b/core/src/test/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScannerTest.java index 08dbb0126..bebf3528a 100644 --- a/core/src/test/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScannerTest.java +++ b/core/src/test/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScannerTest.java @@ -5,10 +5,12 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; +import java.io.IOException; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.math.BigDecimal; +import java.math.BigInteger; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; @@ -295,11 +297,11 @@ void testJakartaDecimalMaxPrimaryDigits() { void testDecimalMaxPrimaryDigits(FieldInfo targetField) { testTarget.decimalMax(targetField, schema); - testTarget.digits(targetField, schema); + testTarget.digitsNumber(targetField, schema); assertEquals(new BigDecimal("200.00"), schema.getMaximum()); + assertEquals(new BigDecimal("0.01"), schema.getMultipleOf()); assertEquals(null, schema.getExclusiveMaximum()); - assertEquals("^\\d{1,3}([.]\\d{1,2})?$", schema.getPattern()); } @Test @@ -344,9 +346,9 @@ void testJakartaDecimalMaxExclusiveDigits() { void testDecimalMaxExclusiveDigits(FieldInfo targetField) { testTarget.decimalMax(targetField, schema); - testTarget.digits(targetField, schema); + testTarget.digitsNumber(targetField, schema); assertEquals(new BigDecimal("201.0"), schema.getExclusiveMaximum()); - assertEquals("^\\d{1,3}([.]\\d)?$", schema.getPattern()); + assertEquals(new BigDecimal("0.1"), schema.getMultipleOf()); } @Test @@ -421,10 +423,10 @@ void testJakartaDecimalMinExclusiveDigits() { void testDecimalMinExclusiveDigits(FieldInfo targetField) { testTarget.decimalMin(targetField, schema); - testTarget.digits(targetField, schema); + testTarget.digitsNumber(targetField, schema); assertEquals(new BigDecimal("9.00"), schema.getExclusiveMinimum()); - assertEquals("^\\d([.]\\d{1,2})?$", schema.getPattern()); + assertEquals(new BigDecimal("0.01"), schema.getMultipleOf()); } @Test @@ -571,7 +573,7 @@ void testStringNotBlankDigits(FieldInfo targetField) { Schema parentSchema = OASFactory.createSchema(); String propertyKey = targetField.name(); - testTarget.digits(targetField, schema); + testTarget.digitsString(targetField, schema); testTarget.notBlank(targetField, schema, propertyKey, requirementHandler(parentSchema)); assertEquals("^\\d{1,8}([.]\\d{1,10})?$", schema.getPattern()); @@ -694,4 +696,57 @@ void testPatternFields(FieldInfo targetField, String expectedPattern) { testTarget.pattern(targetField, schema); assertEquals(expectedPattern, schema.getPattern()); } + + @Test + void testNumberDigits() throws IOException { + class Numbers { + @jakarta.validation.constraints.Digits(integer = 9, fraction = 0) + int int32; + @jakarta.validation.constraints.Digits(integer = 18, fraction = 0) + int int64; + @jakarta.validation.constraints.Digits(integer = 5, fraction = 3) + float float32; + @jakarta.validation.constraints.Digits(integer = 10, fraction = 6) + double float64; + @jakarta.validation.constraints.Digits(integer = 20, fraction = 10) + BigDecimal decimal; + @jakarta.validation.constraints.Digits(integer = 20, fraction = 0) + BigInteger integer; + @jakarta.validation.constraints.Digits(integer = 20, fraction = 0) + @org.eclipse.microprofile.openapi.annotations.media.Schema(multipleOf = 1000) + BigInteger customInteger; + } + + Index index = Index.of(Numbers.class); + ClassInfo numbers = index.getClassByName(Numbers.class); + + schema.setMultipleOf(null); + testTarget.digitsNumber(numbers.field("int32"), schema); + assertEquals(BigDecimal.ONE, schema.getMultipleOf()); + + schema.setMultipleOf(null); + testTarget.digitsNumber(numbers.field("int64"), schema); + assertEquals(BigDecimal.ONE, schema.getMultipleOf()); + + schema.setMultipleOf(null); + testTarget.digitsNumber(numbers.field("float32"), schema); + assertEquals(new BigDecimal("0.001"), schema.getMultipleOf()); + + schema.setMultipleOf(null); + testTarget.digitsNumber(numbers.field("float64"), schema); + assertEquals(new BigDecimal("0.000001"), schema.getMultipleOf()); + + schema.setMultipleOf(null); + testTarget.digitsNumber(numbers.field("decimal"), schema); + assertEquals(new BigDecimal("0.0000000001"), schema.getMultipleOf()); + + schema.setMultipleOf(null); + testTarget.digitsNumber(numbers.field("integer"), schema); + assertEquals(BigDecimal.ONE, schema.getMultipleOf()); + + // Normally set by AnnotationTargetProcessor + SchemaFactory + schema.setMultipleOf(new BigDecimal("1000")); + testTarget.digitsNumber(numbers.field("customInteger"), schema); + assertEquals(new BigDecimal("1000"), schema.getMultipleOf()); + } } diff --git a/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/dataobject/resource.testBeanValidationDocument.json b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/dataobject/resource.testBeanValidationDocument.json index 0748bed27..5950a0871 100644 --- a/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/dataobject/resource.testBeanValidationDocument.json +++ b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/dataobject/resource.testBeanValidationDocument.json @@ -1,229 +1,210 @@ { - "openapi": "3.1.0", - "tags": [ - { - "name": "Test", - "description": "Testing the container" - } - ], - "paths": { - "/bv/test-container": { - "post": { - "tags": [ - "Test" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BVTestResourceEntity" - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BVTestContainer" - } - } - } + "openapi" : "3.1.0", + "components" : { + "schemas" : { + "BVTestContainer" : { + "type" : "object", + "required" : [ "arrayListNotNullAndNotEmptyAndMaxItems", "arrayListNullableAndMinItemsAndMaxItems", "mapObjectNotNullAndNotEmptyAndMaxProperties", "mapObjectNullableAndMinPropertiesAndMaxProperties", "stringNotBlankNotNull", "stringNotBlankDigits", "stringNotEmptyMaxSize", "stringNotEmptySizeRange", "booleanNotNull", "jacksonRequiredTrueString" ], + "properties" : { + "arrayListNotNullAndNotEmptyAndMaxItems" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "maxItems" : 20, + "minItems" : 1 + }, + "arrayListNullableAndMinItemsAndMaxItems" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "minItems" : 5, + "maxItems" : 20 + }, + "mapObjectNotNullAndNotEmptyAndMaxProperties" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + }, + "maxProperties" : 20, + "minProperties" : 1 + }, + "mapObjectNullableAndMinPropertiesAndMaxProperties" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + }, + "minProperties" : 5, + "maxProperties" : 20 + }, + "decimalMaxBigDecimalPrimaryDigits" : { + "type" : "number", + "maximum" : 200.00, + "multipleOf" : 0.01 + }, + "decimalMaxBigDecimalNoConstraint" : { + "type" : "number" + }, + "decimalMaxBigDecimalInvalidValue" : { + "type" : "number" + }, + "decimalMaxBigDecimalExclusiveDigits" : { + "type" : "number", + "exclusiveMaximum" : 201.0, + "multipleOf" : 0.1 + }, + "decimalMaxBigDecimalInclusive" : { + "type" : "number", + "maximum" : 201.00 + }, + "decimalMinBigDecimalPrimary" : { + "type" : "number", + "minimum" : 10.0 + }, + "decimalMinBigDecimalNoConstraint" : { + "type" : "number" + }, + "decimalMinBigDecimalInvalidValue" : { + "type" : "number" + }, + "decimalMinBigDecimalExclusiveDigits" : { + "type" : "number", + "exclusiveMinimum" : 9.00, + "multipleOf" : 0.01 + }, + "decimalMinBigDecimalInclusive" : { + "type" : "number", + "minimum" : 9.00 + }, + "integerPositiveNotZeroMaxValue" : { + "type" : "integer", + "format" : "int64", + "maximum" : 1000, + "exclusiveMinimum" : 0 + }, + "integerPositiveOrZeroMaxValue" : { + "type" : "integer", + "format" : "int32", + "maximum" : 999, + "minimum" : 0 + }, + "integerNegativeNotZeroMinValue" : { + "type" : "integer", + "format" : "int64", + "minimum" : -1000000, + "exclusiveMaximum" : 0 + }, + "integerNegativeOrZeroMinValue" : { + "type" : "integer", + "format" : "int32", + "minimum" : -999, + "maximum" : 0 + }, + "stringNotBlankNotNull" : { + "type" : "string", + "pattern" : "\\S" + }, + "stringNotBlankDigits" : { + "type" : "string", + "pattern" : "^\\d{1,8}([.]\\d{1,10})?$" + }, + "stringNotEmptyMaxSize" : { + "type" : "string", + "maxLength" : 2000, + "minLength" : 1 + }, + "stringNotEmptySizeRange" : { + "type" : "string", + "minLength" : 100, + "maxLength" : 2000 + }, + "booleanNotNull" : { + "type" : "boolean" + }, + "jacksonRequiredTrueString" : { + "type" : "string" + }, + "jacksonDefaultString" : { + "type" : "string" + }, + "patternFromBV" : { + "type" : "string", + "pattern" : "^something$" } } - } - } - }, - "components": { - "schemas": { - "TestEnum": { - "enum": [ - "ABC", - "DEF" - ], - "type": "string" }, - "BVTestResourceEntity": { - "type": "object", - "required": [ - "enumValue" - ], - "properties": { - "string_no_bean_constraints": { - "type": ["string", "null"], - "minLength": 10, - "maxLength": 101 - }, - "big_int_no_bean_constraints": { - "type": ["integer", "null"], - "minimum": 101, - "maximum": 101.999, - "pattern": "^\\d{1,3}([.]\\d{1,3})?$" - }, - "list_no_bean_constraints": { - "type": ["array", "null"], - "minItems": 0, - "maxItems": 100, - "items": { - "type": "string" - } - }, - "map_no_bean_constraints": { - "type": ["object", "null"], - "minProperties": 0, - "maxProperties": 100, - "additionalProperties": { - "type": "string" - } - }, - "enumValue": { - "$ref": "#/components/schemas/TestEnum" + "BVTestResourceEntity" : { + "type" : "object", + "properties" : { + "string_no_bean_constraints" : { + "type" : [ "string", "null" ], + "maxLength" : 101, + "minLength" : 10 + }, + "big_int_no_bean_constraints" : { + "type" : [ "integer", "null" ], + "maximum" : 101.999, + "minimum" : 101, + "pattern" : "^\\d{1,3}([.]\\d{1,3})?$", + "multipleOf" : 1E-100 + }, + "list_no_bean_constraints" : { + "type" : [ "array", "null" ], + "items" : { + "type" : "string" + }, + "maxItems" : 100, + "minItems" : 0 + }, + "map_no_bean_constraints" : { + "type" : [ "object", "null" ], + "additionalProperties" : { + "type" : "string" + }, + "maxProperties" : 100, + "minProperties" : 0 + }, + "enumValue" : { + "$ref" : "#/components/schemas/TestEnum" } - } + }, + "required" : [ "enumValue" ] }, - "BVTestContainer": { - "type": "object", - "required": [ - "arrayListNotNullAndNotEmptyAndMaxItems", - "arrayListNullableAndMinItemsAndMaxItems", - "mapObjectNotNullAndNotEmptyAndMaxProperties", - "mapObjectNullableAndMinPropertiesAndMaxProperties", - "stringNotBlankNotNull", - "stringNotBlankDigits", - "stringNotEmptyMaxSize", - "stringNotEmptySizeRange", - "booleanNotNull", - "jacksonRequiredTrueString" - ], - "properties": { - "arrayListNotNullAndNotEmptyAndMaxItems": { - "type": "array", - "minItems": 1, - "maxItems": 20, - "items": { - "type": "string" - } - }, - "arrayListNullableAndMinItemsAndMaxItems": { - "type": "array", - "minItems": 5, - "maxItems": 20, - "items": { - "type": "string" - } - }, - "booleanNotNull": { - "type": "boolean" - }, - "decimalMaxBigDecimalExclusiveDigits": { - "type": "number", - "exclusiveMaximum": 201.0, - "pattern": "^\\d{1,3}([.]\\d)?$" - }, - "decimalMaxBigDecimalInclusive": { - "type": "number", - "maximum": 201.0 - }, - "decimalMaxBigDecimalInvalidValue": { - "type": "number" - }, - "decimalMaxBigDecimalNoConstraint": { - "type": "number" - }, - "decimalMaxBigDecimalPrimaryDigits": { - "type": "number", - "maximum": 200.0, - "pattern": "^\\d{1,3}([.]\\d{1,2})?$" - }, - "decimalMinBigDecimalExclusiveDigits": { - "type": "number", - "exclusiveMinimum": 9, - "pattern": "^\\d([.]\\d{1,2})?$" - }, - "decimalMinBigDecimalInclusive": { - "type": "number", - "minimum": 9 - }, - "decimalMinBigDecimalInvalidValue": { - "type": "number" - }, - "decimalMinBigDecimalNoConstraint": { - "type": "number" - }, - "decimalMinBigDecimalPrimary": { - "type": "number", - "minimum": 10 - }, - "integerNegativeNotZeroMinValue": { - "type": "integer", - "format": "int64", - "exclusiveMaximum": 0, - "minimum": -1000000 - }, - "integerNegativeOrZeroMinValue": { - "type": "integer", - "format": "int32", - "maximum": 0, - "minimum": -999 - }, - "integerPositiveNotZeroMaxValue": { - "type": "integer", - "format": "int64", - "maximum": 1000, - "exclusiveMinimum": 0 - }, - "integerPositiveOrZeroMaxValue": { - "type": "integer", - "format": "int32", - "maximum": 999, - "minimum": 0 - }, - "mapObjectNotNullAndNotEmptyAndMaxProperties": { - "type": "object", - "minProperties": 1, - "maxProperties": 20, - "additionalProperties": { - "type": "string" + "TestEnum" : { + "type" : "string", + "enum" : [ "ABC", "DEF" ] + } + } + }, + "tags" : [ { + "name" : "Test", + "description" : "Testing the container" + } ], + "paths" : { + "/bv/test-container" : { + "post" : { + "tags" : [ "Test" ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/BVTestResourceEntity" + } } }, - "mapObjectNullableAndMinPropertiesAndMaxProperties": { - "type": "object", - "minProperties": 5, - "maxProperties": 20, - "additionalProperties": { - "type": "string" + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/BVTestContainer" + } + } } - }, - "stringNotBlankDigits": { - "type": "string", - "pattern": "^\\d{1,8}([.]\\d{1,10})?$" - }, - "stringNotBlankNotNull": { - "type": "string", - "pattern": "\\S" - }, - "stringNotEmptyMaxSize": { - "type": "string", - "minLength": 1, - "maxLength": 2000 - }, - "stringNotEmptySizeRange": { - "type": "string", - "minLength": 100, - "maxLength": 2000 - }, - "jacksonRequiredTrueString": { - "type": "string" - }, - "jacksonDefaultString": { - "type": "string" - }, - "patternFromBV": { - "type": "string", - "pattern": "^something$" } } }