From 71475565e209af0323d1d16ec82147790f6c61d8 Mon Sep 17 00:00:00 2001 From: jnbdz Date: Sun, 9 Aug 2026 15:19:13 -0400 Subject: [PATCH] fix: accept whole numbers for the formats float and double The formats float and double define the precision of a number, not its representation. Jackson parses JSON numbers without a decimal part into Integer or Long, so values like 10 were rejected with 'Number does not match the format' although they are valid numbers with float or double precision. Whole numbers are now accepted for the formats float and double, as long as they can be represented exactly with the related precision (2^24 for float, 2^53 for double). The behaviour for all other instance types is unchanged. Fixes #112 --- .../openapi/impl/OpenAPIFormatValidator.java | 27 +++++++++++++++---- .../impl/RequestValidatorImplTest.java | 22 ++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/vertx/openapi/impl/OpenAPIFormatValidator.java b/src/main/java/io/vertx/openapi/impl/OpenAPIFormatValidator.java index aa30951..be31716 100644 --- a/src/main/java/io/vertx/openapi/impl/OpenAPIFormatValidator.java +++ b/src/main/java/io/vertx/openapi/impl/OpenAPIFormatValidator.java @@ -4,6 +4,11 @@ public class OpenAPIFormatValidator implements JsonFormatValidator { + // The formats "float" and "double" define the precision of a number, not its representation. Whole numbers + // are valid, as long as they can be represented exactly with the related precision. + private static final long MAX_EXACT_FLOAT_INTEGER = 1L << 24; + private static final long MAX_EXACT_DOUBLE_INTEGER = 1L << 53; + @Override public String validateFormat(String instanceType, String format, Object instance) { if ("int32".equalsIgnoreCase(format) && !(instance instanceof Integer)) { @@ -15,16 +20,24 @@ public String validateFormat(String instanceType, String format, Object instance } if ("float".equalsIgnoreCase(format)) { - // Behind the scenes we use jackson, so even floats are converted into doubles for us. - // So now we will down cast the float back into a double, and check the usual isInfinite and isNan. - if (!(instance instanceof Double) || ((Float) ((Double) instance).floatValue()).isInfinite() + if (instance instanceof Integer || instance instanceof Long) { + if (!isExactlyRepresentable(((Number) instance).longValue(), MAX_EXACT_FLOAT_INTEGER)) { + return getMessage(format); + } + // Behind the scenes we use jackson, so even floats are converted into doubles for us. + // So now we will down cast the float back into a double, and check the usual isInfinite and isNan. + } else if (!(instance instanceof Double) || ((Float) ((Double) instance).floatValue()).isInfinite() || ((Float) ((Double) instance).floatValue()).isNaN()) { return getMessage(format); } } - if ("float".equalsIgnoreCase(format) || "double".equalsIgnoreCase(format)) { - if (!(instance instanceof Double) || ((Double) instance).isInfinite() || ((Double) instance).isNaN()) { + if ("double".equalsIgnoreCase(format)) { + if (instance instanceof Integer || instance instanceof Long) { + if (!isExactlyRepresentable(((Number) instance).longValue(), MAX_EXACT_DOUBLE_INTEGER)) { + return getMessage(format); + } + } else if (!(instance instanceof Double) || ((Double) instance).isInfinite() || ((Double) instance).isNaN()) { return getMessage(format); } } @@ -32,6 +45,10 @@ public String validateFormat(String instanceType, String format, Object instance return null; } + private static boolean isExactlyRepresentable(long value, long maxExactInteger) { + return value >= -maxExactInteger && value <= maxExactInteger; + } + private String getMessage(String format) { String type = "int32".equalsIgnoreCase(format) || "int64".equalsIgnoreCase(format) ? "Integer" : "Number"; return String.format("%s does not match the format \"%s\"", type, format); diff --git a/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java b/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java index ae36f00..bc5e32e 100644 --- a/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java @@ -178,6 +178,18 @@ private static Stream getBadlyFormattedParameters() { Arguments.of("Double", numberSchema().toJson().put("format", "double"), "71" + Double.MAX_VALUE, "The value of path parameter Double is invalid. Reason: Number does not match the format \"double\""), Arguments.of("Float", numberSchema().toJson().put("format", "float"), "71" + Float.MAX_VALUE, + "The value of path parameter Float is invalid. Reason: Number does not match the format \"float\""), + Arguments.of("Double", numberSchema().toJson().put("format", "double"), 9007199254740993L, + "The value of path parameter Double is invalid. Reason: Number does not match the format \"double\""), + Arguments.of("Double", numberSchema().toJson().put("format", "double"), -9007199254740993L, + "The value of path parameter Double is invalid. Reason: Number does not match the format \"double\""), + Arguments.of("Double", numberSchema().toJson().put("format", "double"), Long.MAX_VALUE, + "The value of path parameter Double is invalid. Reason: Number does not match the format \"double\""), + Arguments.of("Float", numberSchema().toJson().put("format", "float"), 16777217, + "The value of path parameter Float is invalid. Reason: Number does not match the format \"float\""), + Arguments.of("Float", numberSchema().toJson().put("format", "float"), -16777217, + "The value of path parameter Float is invalid. Reason: Number does not match the format \"float\""), + Arguments.of("Float", numberSchema().toJson().put("format", "float"), Integer.MAX_VALUE, "The value of path parameter Float is invalid. Reason: Number does not match the format \"float\"")); } @@ -195,9 +207,17 @@ private static Stream getCorrectlyFormattedParameters() { Arguments.of("Double min double", numberSchema().toJson().put("format", "double"), Double.MIN_VALUE), Arguments.of("Double max float", numberSchema().toJson().put("format", "double"), Float.MAX_VALUE), Arguments.of("Double normal", numberSchema().toJson().put("format", "double"), 123.456), + Arguments.of("Double without decimal part", numberSchema().toJson().put("format", "double"), 10), + Arguments.of("Double max exact integer", numberSchema().toJson().put("format", "double"), + 9007199254740992L), + Arguments.of("Double min exact integer", numberSchema().toJson().put("format", "double"), + -9007199254740992L), Arguments.of("Float max float", numberSchema().toJson().put("format", "float"), Float.MAX_VALUE), Arguments.of("Float min float", numberSchema().toJson().put("format", "float"), Float.MIN_VALUE), - Arguments.of("Float normal", numberSchema().toJson().put("format", "float"), 123.456)); + Arguments.of("Float normal", numberSchema().toJson().put("format", "float"), 123.456), + Arguments.of("Float without decimal part", numberSchema().toJson().put("format", "float"), 10), + Arguments.of("Float max exact integer", numberSchema().toJson().put("format", "float"), 16777216), + Arguments.of("Float min exact integer", numberSchema().toJson().put("format", "float"), -16777216)); } @BeforeEach