diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/SimpleTypeNameMapper.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/SimpleTypeNameMapper.java
new file mode 100644
index 00000000..d4177c42
--- /dev/null
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/SimpleTypeNameMapper.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.web;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Implementation of {@link TypeNameMapper} that maps simple Java types to common, human-readable
+ * type names.
+ */
+public class SimpleTypeNameMapper implements TypeNameMapper {
+
+ /** Common type name for all integer types. */
+ protected static final String INTEGER_TYPE = "integer";
+
+ /** Common type name for all decimal types. */
+ protected static final String NUMBER_TYPE = "number";
+
+ /** Common type name for all boolean types. */
+ protected static final String BOOLEAN_TYPE = "boolean";
+
+ /** Common type name for string types, including {@code String} and {@code Enum} types. */
+ protected static final String STRING_TYPE = "string";
+
+ /** Common type name for array and collection types. */
+ protected static final String ARRAY_TYPE = "array";
+
+ /**
+ * Maps a Java type to a common type name. Supported mappings include:
+ *
+ *
+ * - Integer types ({@code int}, {@code long}, {@code short}, {@code byte}, their wrapper
+ * classes, {@code BigInteger}, {@code AtomicInteger} and {@code AtomicLong}) to {@code
+ * "integer"}
+ *
- Decimal types ({@code double}, {@code float}, their wrapper classes and {@code
+ * BigDecimal} ) to {@code "number"}
+ *
- Boolean types ({@code boolean} and {@code Boolean}) to {@code "boolean"}
+ *
- String type ({@code String}) and {@code Enum} types to {@code "string"}
+ *
- {@code Array} and {@code Collection} types to {@code "array"}
+ *
+ *
+ * @param type the Java type to map
+ * @return an {@code Optional} containing the mapped string name, or empty if the type cannot be
+ * mapped
+ */
+ @Override
+ public Optional map(Class> type) {
+ if (type == null) {
+ return Optional.empty();
+ }
+ if (isInteger(type)) {
+ return Optional.of(INTEGER_TYPE);
+ } else if (isDecimal(type)) {
+ return Optional.of(NUMBER_TYPE);
+ } else if (isBoolean(type)) {
+ return Optional.of(BOOLEAN_TYPE);
+ } else if (isString(type)) {
+ return Optional.of(STRING_TYPE);
+ } else if (isArray(type)) {
+ return Optional.of(ARRAY_TYPE);
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Determines if the given class represents an integer type.
+ *
+ * @param clazz the class to check
+ * @return {@code true} if the class is an integer type, {@code false} otherwise
+ */
+ protected boolean isInteger(Class> clazz) {
+ return clazz == int.class
+ || clazz == Integer.class
+ || clazz == long.class
+ || clazz == Long.class
+ || clazz == BigInteger.class
+ || clazz == AtomicLong.class
+ || clazz == AtomicInteger.class
+ || clazz == short.class
+ || clazz == Short.class
+ || clazz == byte.class
+ || clazz == Byte.class;
+ }
+
+ /**
+ * Determines if the given class represents a decimal type.
+ *
+ * @param clazz the class to check
+ * @return {@code true} if the class is a decimal type, {@code false} otherwise
+ */
+ protected boolean isDecimal(Class> clazz) {
+ return clazz == double.class
+ || clazz == Double.class
+ || clazz == BigDecimal.class
+ || clazz == float.class
+ || clazz == Float.class;
+ }
+
+ /**
+ * Determines if the given class represents a boolean type.
+ *
+ * @param clazz the class to check
+ * @return {@code true} if the class is a boolean type, {@code false} otherwise
+ */
+ protected boolean isBoolean(Class> clazz) {
+ return clazz == boolean.class || clazz == Boolean.class;
+ }
+
+ /**
+ * Determines if the given class represents a string type, which includes {@code String} and
+ * {@code Enum} types.
+ *
+ * @param type the class to check
+ * @return {@code true} if the class is a string
+ */
+ protected boolean isString(Class> type) {
+ return type == String.class || type.isEnum();
+ }
+
+ /**
+ * Determines if the given type is an array or a collection.
+ *
+ * @param type the class to check
+ * @return {@code true} if the type is an array or a collection, {@code false} otherwise
+ */
+ protected boolean isArray(Class> type) {
+ return type.isArray() || Collection.class.isAssignableFrom(type);
+ }
+}
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/TypeNameMapper.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/TypeNameMapper.java
new file mode 100644
index 00000000..4851e6c4
--- /dev/null
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/TypeNameMapper.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.web;
+
+import java.util.Optional;
+
+/**
+ * Maps a Java type to a string name. Used to hide implementation details of the Java type system
+ * from the API consumer. For example, both {@code String[]} {@code List} might be mapped to
+ * {@code "array"}, as JSON does not distinguish between different collection types.
+ */
+public interface TypeNameMapper {
+
+ /**
+ * Maps the given Java type to a string name. In terms of unknown type mapping, it will return an
+ * empty {@code Optional} and it's up to the caller to decide how to handle it (e.g. return a
+ * default name, throw an exception or not return type information at all).
+ *
+ * @param type the Java type to map
+ * @return an {@code Optional} containing the mapped string name, or empty if the type cannot be
+ * mapped
+ */
+ Optional map(Class> type);
+}
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemAutoConfiguration.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemAutoConfiguration.java
index 2a450f87..842a7122 100644
--- a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemAutoConfiguration.java
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemAutoConfiguration.java
@@ -31,6 +31,8 @@
import io.github.problem4j.spring.web.ProblemFormat;
import io.github.problem4j.spring.web.ProblemPostProcessor;
import io.github.problem4j.spring.web.ProblemResolverStore;
+import io.github.problem4j.spring.web.SimpleTypeNameMapper;
+import io.github.problem4j.spring.web.TypeNameMapper;
import io.github.problem4j.spring.web.resolver.ProblemResolver;
import java.util.List;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -138,6 +140,19 @@ ProblemResolverStore problemResolverStore(
return problemResolverStore;
}
+ /**
+ * Provides a {@link TypeNameMapper} that maps Java types to string names for inclusion in problem
+ * responses. The default implementation, {@link SimpleTypeNameMapper} supports simple Java types,
+ * such as primitives, numbers, strings, arrays and lists.
+ *
+ * @return a new {@link SimpleTypeNameMapper}
+ */
+ @ConditionalOnMissingBean(TypeNameMapper.class)
+ @Bean
+ TypeNameMapper problemTypeNameMapper() {
+ return new SimpleTypeNameMapper();
+ }
+
@ConditionalOnClass({ProblemModule.class, SimpleModule.class})
@Configuration(proxyBeanMethods = false)
static class ProblemModuleConfiguration {
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemResolverConfiguration.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemResolverConfiguration.java
index 3a377ec6..07f097f1 100644
--- a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemResolverConfiguration.java
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/autoconfigure/ProblemResolverConfiguration.java
@@ -22,6 +22,7 @@
package io.github.problem4j.spring.web.autoconfigure;
import io.github.problem4j.spring.web.ProblemFormat;
+import io.github.problem4j.spring.web.TypeNameMapper;
import io.github.problem4j.spring.web.parameter.BindingResultSupport;
import io.github.problem4j.spring.web.parameter.MethodParameterSupport;
import io.github.problem4j.spring.web.parameter.MethodValidationResultSupport;
@@ -47,6 +48,7 @@
import io.github.problem4j.spring.web.resolver.WebExchangeBindProblemResolver;
import jakarta.validation.ConstraintViolationException;
import org.springframework.beans.TypeMismatchException;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
@@ -169,8 +171,8 @@ static class HttpMessageNotReadableProblemConfiguration {
@ConditionalOnMissingBean(HttpMessageNotReadableProblemResolver.class)
@Bean
HttpMessageNotReadableProblemResolver httpMessageNotReadableProblemResolver(
- ProblemFormat problemFormat) {
- return new HttpMessageNotReadableProblemResolver(problemFormat);
+ ProblemFormat problemFormat, TypeNameMapper problemTypeNameMapper) {
+ return new HttpMessageNotReadableProblemResolver(problemFormat, problemTypeNameMapper);
}
}
@@ -262,11 +264,19 @@ ServerErrorProblemResolver serverErrorProblemResolver(ProblemFormat problemForma
@ConditionalOnClass(ServerWebInputException.class)
@Configuration(proxyBeanMethods = false)
static class ServerWebInputProblemConfiguration {
+ @ConditionalOnBean(TypeMismatchProblemResolver.class)
@ConditionalOnMissingBean(ServerWebInputProblemResolver.class)
@Bean
ServerWebInputProblemResolver serverWebInputProblemResolver(
- ProblemFormat problemFormat, MethodParameterSupport methodParameterSupport) {
- return new ServerWebInputProblemResolver(problemFormat, methodParameterSupport);
+ ProblemFormat problemFormat,
+ TypeMismatchProblemResolver typeMismatchProblemResolver,
+ MethodParameterSupport methodParameterSupport,
+ TypeNameMapper problemTypeNameMapper) {
+ return new ServerWebInputProblemResolver(
+ problemFormat,
+ typeMismatchProblemResolver,
+ methodParameterSupport,
+ problemTypeNameMapper);
}
}
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/HttpMessageNotReadableProblemResolver.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/HttpMessageNotReadableProblemResolver.java
index 6c892043..5fa0378d 100644
--- a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/HttpMessageNotReadableProblemResolver.java
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/HttpMessageNotReadableProblemResolver.java
@@ -21,11 +21,14 @@
package io.github.problem4j.spring.web.resolver;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import io.github.problem4j.core.Problem;
import io.github.problem4j.core.ProblemBuilder;
import io.github.problem4j.core.ProblemContext;
import io.github.problem4j.spring.web.IdentityProblemFormat;
import io.github.problem4j.spring.web.ProblemFormat;
+import io.github.problem4j.spring.web.SimpleTypeNameMapper;
+import io.github.problem4j.spring.web.TypeNameMapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
@@ -45,6 +48,8 @@
*/
public class HttpMessageNotReadableProblemResolver extends AbstractProblemResolver {
+ private final TypeNameMapper typeNameMapper;
+
/** Creates a new {@link HttpMessageNotReadableProblemResolver} with default problem format. */
public HttpMessageNotReadableProblemResolver() {
this(new IdentityProblemFormat());
@@ -56,7 +61,20 @@ public HttpMessageNotReadableProblemResolver() {
* @param problemFormat the problem format to use
*/
public HttpMessageNotReadableProblemResolver(ProblemFormat problemFormat) {
+ this(problemFormat, new SimpleTypeNameMapper());
+ }
+
+ /**
+ * Creates a new {@link HttpMessageNotReadableProblemResolver} with the specified problem format
+ * and type name mapper.
+ *
+ * @param problemFormat the problem format to use
+ * @param typeNameMapper the type mapper to use
+ */
+ public HttpMessageNotReadableProblemResolver(
+ ProblemFormat problemFormat, TypeNameMapper typeNameMapper) {
super(HttpMessageNotReadableException.class, problemFormat);
+ this.typeNameMapper = typeNameMapper;
}
/**
@@ -73,6 +91,9 @@ public HttpMessageNotReadableProblemResolver(ProblemFormat problemFormat) {
@Override
public ProblemBuilder resolveBuilder(
ProblemContext context, Exception ex, HttpHeaders headers, HttpStatusCode status) {
+ if (ex.getCause() instanceof MismatchedInputException e) {
+ return JacksonErrorHelper.resolveMismatchedInput(e, typeNameMapper);
+ }
return Problem.builder().status(HttpStatus.BAD_REQUEST.value());
}
}
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/JacksonErrorHelper.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/JacksonErrorHelper.java
new file mode 100644
index 00000000..51631e35
--- /dev/null
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/JacksonErrorHelper.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.web.resolver;
+
+import static io.github.problem4j.spring.web.ProblemSupport.KIND_EXTENSION;
+import static io.github.problem4j.spring.web.ProblemSupport.PROPERTY_EXTENSION;
+import static io.github.problem4j.spring.web.ProblemSupport.TYPE_MISMATCH_DETAIL;
+
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
+import io.github.problem4j.core.Problem;
+import io.github.problem4j.core.ProblemBuilder;
+import io.github.problem4j.spring.web.TypeNameMapper;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.springframework.http.HttpStatus;
+import org.springframework.util.StringUtils;
+
+final class JacksonErrorHelper {
+
+ static ProblemBuilder resolveMismatchedInput(
+ MismatchedInputException e, TypeNameMapper typeNameMapper) {
+ Optional property = resolvePropertyPath(e);
+ Optional kind = typeNameMapper.map(e.getTargetType());
+
+ ProblemBuilder builder = Problem.builder().status(HttpStatus.BAD_REQUEST.value());
+
+ property.ifPresent(
+ it -> {
+ builder.detail(TYPE_MISMATCH_DETAIL);
+ builder.extension(PROPERTY_EXTENSION, it);
+ builder.extension(KIND_EXTENSION, kind.orElse(null));
+ });
+
+ return builder;
+ }
+
+ private static Optional resolvePropertyPath(MismatchedInputException e) {
+ String property =
+ e.getPath().stream()
+ .map(JsonMappingException.Reference::getFieldName)
+ .filter(StringUtils::hasLength)
+ .collect(Collectors.joining("."));
+
+ return StringUtils.hasLength(property) ? Optional.of(property) : Optional.empty();
+ }
+
+ private JacksonErrorHelper() {}
+}
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/ServerWebInputProblemResolver.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/ServerWebInputProblemResolver.java
index 2f95a194..54863550 100644
--- a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/ServerWebInputProblemResolver.java
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/ServerWebInputProblemResolver.java
@@ -23,17 +23,22 @@
import static io.github.problem4j.spring.web.ProblemSupport.PROPERTY_EXTENSION;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import io.github.problem4j.core.Problem;
import io.github.problem4j.core.ProblemBuilder;
import io.github.problem4j.core.ProblemContext;
import io.github.problem4j.spring.web.IdentityProblemFormat;
import io.github.problem4j.spring.web.ProblemFormat;
+import io.github.problem4j.spring.web.SimpleTypeNameMapper;
+import io.github.problem4j.spring.web.TypeNameMapper;
import io.github.problem4j.spring.web.parameter.DefaultMethodParameterSupport;
import io.github.problem4j.spring.web.parameter.MethodParameterSupport;
import java.util.Optional;
import org.springframework.beans.TypeMismatchException;
import org.springframework.core.MethodParameter;
+import org.springframework.core.codec.DecodingException;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.web.server.ServerWebInputException;
@@ -51,6 +56,7 @@ public class ServerWebInputProblemResolver extends AbstractProblemResolver {
private final TypeMismatchProblemResolver typeMismatchProblemResolver;
private final MethodParameterSupport methodParameterSupport;
+ private final TypeNameMapper typeNameMapper;
/** Creates a new {@link ServerWebInputProblemResolver} with default problem format. */
public ServerWebInputProblemResolver() {
@@ -75,9 +81,31 @@ public ServerWebInputProblemResolver(ProblemFormat problemFormat) {
*/
public ServerWebInputProblemResolver(
ProblemFormat problemFormat, MethodParameterSupport methodParameterSupport) {
+ this(
+ problemFormat,
+ new TypeMismatchProblemResolver(problemFormat),
+ methodParameterSupport,
+ new SimpleTypeNameMapper());
+ }
+
+ /**
+ * Creates a new {@link ServerWebInputProblemResolver} with the specified problem format, type
+ * mismatch resolver, and method parameter support.
+ *
+ * @param problemFormat the problem format to use
+ * @param typeMismatchProblemResolver the resolver to use
+ * @param methodParameterSupport the support for extracting parameter names
+ * @param typeNameMapper the type name mapper to use for decoding exceptions
+ */
+ public ServerWebInputProblemResolver(
+ ProblemFormat problemFormat,
+ TypeMismatchProblemResolver typeMismatchProblemResolver,
+ MethodParameterSupport methodParameterSupport,
+ TypeNameMapper typeNameMapper) {
super(ServerWebInputException.class, problemFormat);
+ this.typeMismatchProblemResolver = typeMismatchProblemResolver;
this.methodParameterSupport = methodParameterSupport;
- typeMismatchProblemResolver = new TypeMismatchProblemResolver(problemFormat);
+ this.typeNameMapper = typeNameMapper;
}
/**
@@ -99,18 +127,29 @@ public ProblemBuilder resolveBuilder(
ProblemContext context, Exception ex, HttpHeaders headers, HttpStatusCode status) {
ServerWebInputException swie = (ServerWebInputException) ex;
- if (ex.getCause() instanceof TypeMismatchException tme) {
- ProblemBuilder builder =
- typeMismatchProblemResolver.resolveBuilder(context, tme, headers, status);
- if (!builder.build().hasExtension(PROPERTY_EXTENSION)) {
- return tryAppendingPropertyFromMethodParameter(swie.getMethodParameter(), builder);
- }
- return builder;
+ if (swie.getCause() instanceof TypeMismatchException tme) {
+ return resolveTypeMismatchException(context, headers, status, swie, tme);
+ } else if (swie.getCause() instanceof DecodingException de) {
+ return resolveDecodingException(de);
}
return Problem.builder().status(swie.getStatusCode().value());
}
+ private ProblemBuilder resolveTypeMismatchException(
+ ProblemContext context,
+ HttpHeaders headers,
+ HttpStatusCode status,
+ ServerWebInputException swie,
+ TypeMismatchException tme) {
+ ProblemBuilder builder =
+ typeMismatchProblemResolver.resolveBuilder(context, tme, headers, status);
+ if (!builder.build().hasExtension(PROPERTY_EXTENSION)) {
+ return tryAppendingPropertyFromMethodParameter(swie.getMethodParameter(), builder);
+ }
+ return builder;
+ }
+
private ProblemBuilder tryAppendingPropertyFromMethodParameter(
MethodParameter parameter, ProblemBuilder builder) {
Optional optionalProperty = methodParameterSupport.findParameterName(parameter);
@@ -119,4 +158,11 @@ private ProblemBuilder tryAppendingPropertyFromMethodParameter(
}
return builder;
}
+
+ private ProblemBuilder resolveDecodingException(DecodingException ex) {
+ if (ex.getCause() instanceof MismatchedInputException e) {
+ return JacksonErrorHelper.resolveMismatchedInput(e, typeNameMapper);
+ }
+ return Problem.builder().status(HttpStatus.BAD_REQUEST.value());
+ }
}
diff --git a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/TypeMismatchProblemResolver.java b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/TypeMismatchProblemResolver.java
index dca01c1a..1f8c01de 100644
--- a/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/TypeMismatchProblemResolver.java
+++ b/problem4j-spring-web/src/main/java/io/github/problem4j/spring/web/resolver/TypeMismatchProblemResolver.java
@@ -30,7 +30,8 @@
import io.github.problem4j.core.ProblemContext;
import io.github.problem4j.spring.web.IdentityProblemFormat;
import io.github.problem4j.spring.web.ProblemFormat;
-import java.util.Locale;
+import io.github.problem4j.spring.web.SimpleTypeNameMapper;
+import io.github.problem4j.spring.web.TypeNameMapper;
import org.springframework.beans.TypeMismatchException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@@ -49,6 +50,8 @@
*/
public class TypeMismatchProblemResolver extends AbstractProblemResolver {
+ private final TypeNameMapper typeNameMapper;
+
/** Creates a new {@link TypeMismatchProblemResolver} with default problem format. */
public TypeMismatchProblemResolver() {
this(new IdentityProblemFormat());
@@ -60,7 +63,19 @@ public TypeMismatchProblemResolver() {
* @param problemFormat the problem format to use
*/
public TypeMismatchProblemResolver(ProblemFormat problemFormat) {
+ this(problemFormat, new SimpleTypeNameMapper());
+ }
+
+ /**
+ * Creates a new {@link TypeMismatchProblemResolver} with the specified problem format and type
+ * name mapper.
+ *
+ * @param problemFormat the problem format to use
+ * @param typeNameMapper the type name mapper to use
+ */
+ public TypeMismatchProblemResolver(ProblemFormat problemFormat, TypeNameMapper typeNameMapper) {
super(TypeMismatchException.class, problemFormat);
+ this.typeNameMapper = typeNameMapper;
}
/**
@@ -97,10 +112,7 @@ public ProblemBuilder resolveBuilder(
TypeMismatchException ex1 = (TypeMismatchException) ex;
String property = ex1.getPropertyName();
- String kind =
- ex1.getRequiredType() != null
- ? ex1.getRequiredType().getSimpleName().toLowerCase(Locale.ROOT)
- : null;
+ String kind = typeNameMapper.map(ex1.getRequiredType()).orElse(null);
// could happen in some early 3.0.x versions of Spring Boot, cannot add tests for it as newer
// versions assign it to propertyName in constructor
diff --git a/problem4j-spring-web/src/test/java/io/github/problem4j/spring/web/SimpleTypeNameMapperTest.java b/problem4j-spring-web/src/test/java/io/github/problem4j/spring/web/SimpleTypeNameMapperTest.java
new file mode 100644
index 00000000..abaf503f
--- /dev/null
+++ b/problem4j-spring-web/src/test/java/io/github/problem4j/spring/web/SimpleTypeNameMapperTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.web;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class SimpleTypeNameMapperTest {
+
+ private final TypeNameMapper mapper = new SimpleTypeNameMapper();
+
+ private enum TestEnum {
+ A,
+ B
+ }
+
+ @ParameterizedTest
+ @MethodSource("supportedTypeMappings")
+ void givenSupportedType_whenMap_thenReturnExpectedName(Class> type, String expected) {
+ assertThat(mapper.map(type)).contains(expected);
+ }
+
+ @Test
+ void givenUnsupportedType_whenMap_thenReturnEmpty() {
+ assertThat(mapper.map(Thread.class)).isEmpty();
+ }
+
+ @Test
+ void givenNull_whenMap_thenReturnEmpty() {
+ assertThat(mapper.map(null)).isEmpty();
+ }
+
+ static Stream supportedTypeMappings() {
+ return Stream.of(
+ Arguments.of(int.class, "integer"),
+ Arguments.of(Integer.class, "integer"),
+ Arguments.of(long.class, "integer"),
+ Arguments.of(Long.class, "integer"),
+ Arguments.of(BigInteger.class, "integer"),
+ Arguments.of(AtomicLong.class, "integer"),
+ Arguments.of(AtomicInteger.class, "integer"),
+ Arguments.of(short.class, "integer"),
+ Arguments.of(Short.class, "integer"),
+ Arguments.of(byte.class, "integer"),
+ Arguments.of(Byte.class, "integer"),
+ Arguments.of(double.class, "number"),
+ Arguments.of(Double.class, "number"),
+ Arguments.of(BigDecimal.class, "number"),
+ Arguments.of(float.class, "number"),
+ Arguments.of(Float.class, "number"),
+ Arguments.of(boolean.class, "boolean"),
+ Arguments.of(Boolean.class, "boolean"),
+ Arguments.of(String.class, "string"),
+ Arguments.of(TestEnum.class, "string"),
+ Arguments.of(String[].class, "array"),
+ Arguments.of(Collection.class, "array"),
+ Arguments.of(List.class, "array"),
+ Arguments.of(ArrayList.class, "array"),
+ Arguments.of(LinkedList.class, "array"),
+ Arguments.of(Set.class, "array"),
+ Arguments.of(HashSet.class, "array"),
+ Arguments.of(TreeSet.class, "array"));
+ }
+}
diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/model/PrimitiveModel.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/model/PrimitiveModel.java
new file mode 100644
index 00000000..35c477f8
--- /dev/null
+++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/model/PrimitiveModel.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.webflux.app.model;
+
+public interface PrimitiveModel {
+
+ record IntRequest(int value) {}
+
+ record LongRequest(long value) {}
+
+ record ShortRequest(short value) {}
+
+ record ByteRequest(byte value) {}
+
+ record FloatRequest(float value) {}
+
+ record DoubleRequest(double value) {}
+
+ record BooleanRequest(boolean value) {}
+
+ record NestedIntRequest(IntRequest nested) {}
+
+ record NestedLongRequest(LongRequest nested) {}
+
+ record NestedShortRequest(ShortRequest nested) {}
+
+ record NestedByteRequest(ByteRequest nested) {}
+
+ record NestedFloatRequest(FloatRequest nested) {}
+
+ record NestedDoubleRequest(DoubleRequest nested) {}
+
+ record NestedBooleanRequest(BooleanRequest nested) {}
+
+ record ComplexRequest(boolean flag, long timestamp, double amount, ShortRequest shortNested) {}
+}
diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingPrimitiveController.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingPrimitiveController.java
new file mode 100644
index 00000000..81c9fc8e
--- /dev/null
+++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/app/rest/BindingPrimitiveController.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.webflux.app.rest;
+
+import io.github.problem4j.spring.webflux.app.model.PrimitiveModel;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/binding-primitive")
+public class BindingPrimitiveController {
+
+ @PostMapping(path = "/int", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String intValue(@RequestBody PrimitiveModel.IntRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/long", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String longValue(@RequestBody PrimitiveModel.LongRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/short", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String shortValue(@RequestBody PrimitiveModel.ShortRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/byte", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String byteValue(@RequestBody PrimitiveModel.ByteRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/float", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String floatValue(@RequestBody PrimitiveModel.FloatRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/double", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String doubleValue(@RequestBody PrimitiveModel.DoubleRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/boolean", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String booleanValue(@RequestBody PrimitiveModel.BooleanRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/int", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedInt(@RequestBody PrimitiveModel.NestedIntRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/long", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedLong(@RequestBody PrimitiveModel.NestedLongRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/short", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedShort(@RequestBody PrimitiveModel.NestedShortRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/byte", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedByte(@RequestBody PrimitiveModel.NestedByteRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/float", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedFloat(@RequestBody PrimitiveModel.NestedFloatRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/double", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedDouble(@RequestBody PrimitiveModel.NestedDoubleRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/boolean", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedBoolean(@RequestBody PrimitiveModel.NestedBooleanRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/complex", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String complex(@RequestBody PrimitiveModel.ComplexRequest request) {
+ return "OK";
+ }
+}
diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingPrimitiveWebFluxTest.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingPrimitiveWebFluxTest.java
new file mode 100644
index 00000000..adbff332
--- /dev/null
+++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/BindingPrimitiveWebFluxTest.java
@@ -0,0 +1,419 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.webflux.integration;
+
+import static io.github.problem4j.spring.web.ProblemSupport.KIND_EXTENSION;
+import static io.github.problem4j.spring.web.ProblemSupport.PROPERTY_EXTENSION;
+import static io.github.problem4j.spring.web.ProblemSupport.TYPE_MISMATCH_DETAIL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.github.problem4j.core.Problem;
+import io.github.problem4j.spring.webflux.app.WebFluxTestApp;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+@SpringBootTest(
+ classes = {WebFluxTestApp.class},
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {"spring.jackson.deserialization.fail-on-null-for-primitives=true"})
+class BindingPrimitiveWebFluxTest {
+
+ @Autowired private WebTestClient webTestClient;
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": 42 }",
+ "/binding-primitive/long | { \"value\": 9223372036854775807 }",
+ "/binding-primitive/short | { \"value\": 123 }",
+ "/binding-primitive/byte | { \"value\": 12 }",
+ "/binding-primitive/float | { \"value\": 3.14 }",
+ "/binding-primitive/double | { \"value\": 2.71828 }",
+ "/binding-primitive/boolean | { \"value\": true }"
+ })
+ void givenValidPrimitive_whenPost_thenReturnOk(String path, String json) {
+ webTestClient
+ .post()
+ .uri(path)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(String.class)
+ .isEqualTo("OK");
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": \"notInt\" }",
+ "/binding-primitive/int | { \"value\": [\"notInt\"] }",
+ "/binding-primitive/int | { \"value\": { \"notInt\": true } }",
+ "/binding-primitive/int | { \"value\": null }",
+ "/binding-primitive/int | { }",
+ "/binding-primitive/long | { \"value\": \"notLong\" }",
+ "/binding-primitive/long | { \"value\": [\"notLong\"] }",
+ "/binding-primitive/long | { \"value\": { \"notLong\": true } }",
+ "/binding-primitive/long | { \"value\": null }",
+ "/binding-primitive/long | { }",
+ "/binding-primitive/short | { \"value\": \"notShort\" }",
+ "/binding-primitive/short | { \"value\": [\"notShort\"] }",
+ "/binding-primitive/short | { \"value\": { \"notShort\":true } }",
+ "/binding-primitive/short | { \"value\": null }",
+ "/binding-primitive/short | { }",
+ "/binding-primitive/byte | { \"value\": \"notByte\" }",
+ "/binding-primitive/byte | { \"value\": [\"notByte\"] }",
+ "/binding-primitive/byte | { \"value\": { \"notByte\": true } }",
+ "/binding-primitive/byte | { \"value\": null }",
+ "/binding-primitive/byte | { }",
+ "/binding-primitive/float | { \"value\": \"notFloat\" }",
+ "/binding-primitive/float | { \"value\": [\"notFloat\"] }",
+ "/binding-primitive/float | { \"value\": { \"notFloat\": true } }",
+ "/binding-primitive/float | { \"value\": null }",
+ "/binding-primitive/float | { }",
+ "/binding-primitive/double | { \"value\": \"notDouble\" }",
+ "/binding-primitive/double | { \"value\": [\"notDouble\"] }",
+ "/binding-primitive/double | { \"value\": { \"notDouble\": true } }",
+ "/binding-primitive/double | { \"value\": null }",
+ "/binding-primitive/double | { }",
+ "/binding-primitive/boolean | { \"value\": \"notBool\" }",
+ "/binding-primitive/boolean | { \"value\": [\"notBool\"] }",
+ "/binding-primitive/boolean | { \"value\": { \"notBool\": true } }",
+ "/binding-primitive/boolean | { \"value\": null }",
+ "/binding-primitive/boolean | { }",
+ })
+ void givenMalformedPrimitive_whenPost_thenReturnProblem(String path, String json) {
+ webTestClient
+ .post()
+ .uri(path)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.BAD_REQUEST)
+ .expectHeader()
+ .contentType(Problem.CONTENT_TYPE)
+ .expectBody(Problem.class)
+ .value(
+ problem -> {
+ String expectedKind;
+ if (path.endsWith("/boolean")) {
+ expectedKind = "boolean";
+ } else if (path.endsWith("/float") || path.endsWith("/double")) {
+ expectedKind = "number";
+ } else {
+ expectedKind = "integer";
+ }
+
+ Problem expected =
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, expectedKind)
+ .build();
+
+ if (!problem.equals(expected)) {
+ assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value()));
+ }
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/nested/int | { \"value\": { \"value\": 42 } }",
+ "/binding-primitive/nested/long | { \"value\": { \"value\": 9223372036854775807 } }",
+ "/binding-primitive/nested/short | { \"value\": { \"value\": 123 } }",
+ "/binding-primitive/nested/byte | { \"value\": { \"value\": 12 } }",
+ "/binding-primitive/nested/float | { \"value\": { \"value\": 3.14 } }",
+ "/binding-primitive/nested/double | { \"value\": { \"value\": 2.71828 } }",
+ "/binding-primitive/nested/boolean | { \"value\": { \"value\": true } }"
+ })
+ void givenValidNested_whenPost_thenReturnOk(String path, String json) {
+ webTestClient
+ .post()
+ .uri(path)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(String.class)
+ .isEqualTo("OK");
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": \"notInt\" } }",
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": [\"notInt\"] } }",
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": { \"notInt\": true } } }",
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/int | { \"nested\": { } } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": \"notLong\" } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": [\"notLong\"] } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": { \"notLong\": true } } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/long | { \"nested\": { } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": \"notShort\" } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": [\"notShort\"] } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": { \"notShort\":true } } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/short | { \"nested\": { } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": \"notByte\" } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": [\"notByte\"] } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": { \"notByte\": true } } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/byte | { \"nested\": { } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": \"notFloat\" } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": [\"notFloat\"] } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": { \"notFloat\": true } } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/float | { \"nested\": { } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": \"notDouble\" } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": [\"notDouble\"] } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": { \"notDouble\": true } } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": { \"notDouble\": null } } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": { } } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": \"notBool\" } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": [\"notBool\"] } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": { \"notBool\": true } } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": { \"notBool\": null } } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": { } } }",
+ })
+ void givenMalformedNested_whenPost_thenReturnProblem(String path, String json) {
+ webTestClient
+ .post()
+ .uri(path)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.BAD_REQUEST)
+ .expectHeader()
+ .contentType(Problem.CONTENT_TYPE)
+ .expectBody(Problem.class)
+ .value(
+ problem -> {
+ String expectedKind;
+ if (path.endsWith("/boolean")) {
+ expectedKind = "boolean";
+ } else if (path.endsWith("/float") || path.endsWith("/double")) {
+ expectedKind = "number";
+ } else {
+ expectedKind = "integer";
+ }
+
+ assertThat(problem)
+ .isEqualTo(
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "nested.value")
+ .extension(KIND_EXTENSION, expectedKind)
+ .build());
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": \"\" }",
+ "/binding-primitive/long | { \"value\": \"\" }",
+ "/binding-primitive/short | { \"value\": \"\" }",
+ "/binding-primitive/byte | { \"value\": \"\" }",
+ "/binding-primitive/float | { \"value\": \"\" }",
+ "/binding-primitive/double | { \"value\": \"\" }",
+ "/binding-primitive/boolean | { \"value\": \"\" }",
+ })
+ void givenEmptyStringPrimitive_whenPost_thenReturnProblem(String path, String json) {
+ webTestClient
+ .post()
+ .uri(path)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.BAD_REQUEST)
+ .expectHeader()
+ .contentType(Problem.CONTENT_TYPE)
+ .expectBody(Problem.class)
+ .value(
+ problem -> {
+ String expectedKind;
+ if (path.endsWith("/boolean")) {
+ expectedKind = "boolean";
+ } else if (path.endsWith("/float") || path.endsWith("/double")) {
+ expectedKind = "number";
+ } else {
+ expectedKind = "integer";
+ }
+
+ assertThat(problem)
+ .isEqualTo(
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, expectedKind)
+ .build());
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": 2147483648 }",
+ "/binding-primitive/long | { \"value\": 9223372036854775808 }",
+ "/binding-primitive/short | { \"value\": 40000 }",
+ "/binding-primitive/byte | { \"value\": 256 }",
+ })
+ void givenOverflowPrimitive_whenPost_thenReturnProblem(String path, String json) {
+ webTestClient
+ .post()
+ .uri(path)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.BAD_REQUEST)
+ .expectHeader()
+ .contentType(Problem.CONTENT_TYPE)
+ .expectBody(Problem.class)
+ .value(
+ problem -> {
+ Problem expected =
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, "integer")
+ .build();
+
+ if (!problem.equals(expected)) {
+ assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value()));
+ }
+ });
+ }
+
+ @Test
+ void givenNullPrimitive_whenPost_thenReturnProblem() {
+ webTestClient
+ .post()
+ .uri("/binding-primitive/int")
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue("{ \"value\": null }")
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.BAD_REQUEST)
+ .expectHeader()
+ .contentType(Problem.CONTENT_TYPE)
+ .expectBody(Problem.class)
+ .value(
+ problem -> {
+ Problem expected =
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, "integer")
+ .build();
+
+ if (!problem.equals(expected)) {
+ assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value()));
+ }
+ });
+ }
+
+ @Test
+ void givenValidComplexRoot_whenPost_thenReturnOk() {
+ String json =
+ """
+ {
+ "flag": true,
+ "timestamp": 1672531200000,
+ "amount": 12.34,
+ "shortNested": { "value": 3 }
+ }
+ """;
+
+ webTestClient
+ .post()
+ .uri("/binding-primitive/complex")
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(String.class)
+ .isEqualTo("OK");
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "{ \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | flag | boolean",
+ "{ \"timestamp\": \"notLong\", \"flag\": \"notBool\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | timestamp | integer",
+ "{ \"amount\": \"notFloat\", \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"shortNested\": { \"value\": \"notShort\" } } | amount | number",
+ "{ \"shortNested\": { \"value\": \"notShort\" }, \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\" } | shortNested.value | integer"
+ })
+ void givenMalformedComplexObject_whenPost_thenReturnProblemWithFirstInvalidFieldAsProperty(
+ String json, String expectedProperty, String expectedKind) {
+
+ webTestClient
+ .post()
+ .uri("/binding-primitive/complex")
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(json)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.BAD_REQUEST)
+ .expectHeader()
+ .contentType(Problem.CONTENT_TYPE)
+ .expectBody(Problem.class)
+ .value(
+ problem ->
+ assertThat(problem)
+ .isEqualTo(
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, expectedProperty)
+ .extension(KIND_EXTENSION, expectedKind)
+ .build()));
+ }
+}
diff --git a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/ValidateRequestBodyWebFluxTest.java b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/ValidateRequestBodyWebFluxTest.java
index e43486b0..bd769468 100644
--- a/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/ValidateRequestBodyWebFluxTest.java
+++ b/problem4j-spring-webflux/src/test/java/io/github/problem4j/spring/webflux/integration/ValidateRequestBodyWebFluxTest.java
@@ -107,7 +107,7 @@ void givenGlobalValidationViolation_shouldReturnProblemWithoutFieldName() {
@ParameterizedTest
@ValueSource(
- strings = {"{ \"name\": \"Alice\"", "{ \"name\": \"Alice\", \"age\": \"too young\"}", ""})
+ strings = {"{ \"name\": \"Alice\"", "not a json", "123", "[1, 2, 3]", "\"just a string\""})
@NullSource
void givenMalformedRequestBody_shouldReturnProblem(String json) {
WebTestClient.RequestBodySpec spec =
diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/model/PrimitiveModel.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/model/PrimitiveModel.java
new file mode 100644
index 00000000..f67f2d56
--- /dev/null
+++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/model/PrimitiveModel.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.webmvc.app.model;
+
+public interface PrimitiveModel {
+
+ record IntRequest(int value) {}
+
+ record LongRequest(long value) {}
+
+ record ShortRequest(short value) {}
+
+ record ByteRequest(byte value) {}
+
+ record FloatRequest(float value) {}
+
+ record DoubleRequest(double value) {}
+
+ record BooleanRequest(boolean value) {}
+
+ record NestedIntRequest(IntRequest nested) {}
+
+ record NestedLongRequest(LongRequest nested) {}
+
+ record NestedShortRequest(ShortRequest nested) {}
+
+ record NestedByteRequest(ByteRequest nested) {}
+
+ record NestedFloatRequest(FloatRequest nested) {}
+
+ record NestedDoubleRequest(DoubleRequest nested) {}
+
+ record NestedBooleanRequest(BooleanRequest nested) {}
+
+ record ComplexRequest(boolean flag, long timestamp, double amount, ShortRequest shortNested) {}
+}
diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingPrimitiveController.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingPrimitiveController.java
new file mode 100644
index 00000000..12593b1d
--- /dev/null
+++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/app/rest/BindingPrimitiveController.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.webmvc.app.rest;
+
+import io.github.problem4j.spring.webmvc.app.model.PrimitiveModel;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/binding-primitive")
+public class BindingPrimitiveController {
+
+ @PostMapping(path = "/int", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String intValue(@RequestBody PrimitiveModel.IntRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/long", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String longValue(@RequestBody PrimitiveModel.LongRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/short", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String shortValue(@RequestBody PrimitiveModel.ShortRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/byte", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String byteValue(@RequestBody PrimitiveModel.ByteRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/float", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String floatValue(@RequestBody PrimitiveModel.FloatRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/double", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String doubleValue(@RequestBody PrimitiveModel.DoubleRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/boolean", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String booleanValue(@RequestBody PrimitiveModel.BooleanRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/int", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedInt(@RequestBody PrimitiveModel.NestedIntRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/long", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedLong(@RequestBody PrimitiveModel.NestedLongRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/short", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedShort(@RequestBody PrimitiveModel.NestedShortRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/byte", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedByte(@RequestBody PrimitiveModel.NestedByteRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/float", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedFloat(@RequestBody PrimitiveModel.NestedFloatRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/double", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedDouble(@RequestBody PrimitiveModel.NestedDoubleRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/nested/boolean", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String nestedBoolean(@RequestBody PrimitiveModel.NestedBooleanRequest request) {
+ return "OK";
+ }
+
+ @PostMapping(path = "/complex", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public String complex(@RequestBody PrimitiveModel.ComplexRequest request) {
+ return "OK";
+ }
+}
diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingPrimitiveWebMvcTest.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingPrimitiveWebMvcTest.java
new file mode 100644
index 00000000..3af2d8e5
--- /dev/null
+++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/BindingPrimitiveWebMvcTest.java
@@ -0,0 +1,410 @@
+/*
+ * Copyright (c) 2025-2026 The Problem4J Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.github.problem4j.spring.webmvc.integration;
+
+import static io.github.problem4j.spring.web.ProblemSupport.KIND_EXTENSION;
+import static io.github.problem4j.spring.web.ProblemSupport.PROPERTY_EXTENSION;
+import static io.github.problem4j.spring.web.ProblemSupport.TYPE_MISMATCH_DETAIL;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.github.problem4j.core.Problem;
+import io.github.problem4j.spring.webmvc.app.WebMvcTestApp;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+@SpringBootTest(
+ classes = {WebMvcTestApp.class},
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {"spring.jackson.deserialization.fail-on-null-for-primitives=true"})
+class BindingPrimitiveWebMvcTest {
+
+ @Autowired private TestRestTemplate restTemplate;
+ @Autowired private ObjectMapper objectMapper;
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": 42 }",
+ "/binding-primitive/long | { \"value\": 9223372036854775807 }",
+ "/binding-primitive/short | { \"value\": 123 }",
+ "/binding-primitive/byte | { \"value\": 12 }",
+ "/binding-primitive/float | { \"value\": 3.14 }",
+ "/binding-primitive/double | { \"value\": 2.71828 }",
+ "/binding-primitive/boolean | { \"value\": true }"
+ })
+ void givenValidPrimitive_whenPost_thenReturnOk(String path, String json) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("OK");
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": \"notInt\" }",
+ "/binding-primitive/int | { \"value\": [\"notInt\"] }",
+ "/binding-primitive/int | { \"value\": { \"notInt\": true } }",
+ "/binding-primitive/int | { \"value\": null }",
+ "/binding-primitive/int | { }",
+ "/binding-primitive/long | { \"value\": \"notLong\" }",
+ "/binding-primitive/long | { \"value\": [\"notLong\"] }",
+ "/binding-primitive/long | { \"value\": { \"notLong\": true } }",
+ "/binding-primitive/long | { \"value\": null }",
+ "/binding-primitive/long | { }",
+ "/binding-primitive/short | { \"value\": \"notShort\" }",
+ "/binding-primitive/short | { \"value\": [\"notShort\"] }",
+ "/binding-primitive/short | { \"value\": { \"notShort\":true } }",
+ "/binding-primitive/short | { \"value\": null }",
+ "/binding-primitive/short | { }",
+ "/binding-primitive/byte | { \"value\": \"notByte\" }",
+ "/binding-primitive/byte | { \"value\": [\"notByte\"] }",
+ "/binding-primitive/byte | { \"value\": { \"notByte\": true } }",
+ "/binding-primitive/byte | { \"value\": null }",
+ "/binding-primitive/byte | { }",
+ "/binding-primitive/float | { \"value\": \"notFloat\" }",
+ "/binding-primitive/float | { \"value\": [\"notFloat\"] }",
+ "/binding-primitive/float | { \"value\": { \"notFloat\": true } }",
+ "/binding-primitive/float | { \"value\": null }",
+ "/binding-primitive/float | { }",
+ "/binding-primitive/double | { \"value\": \"notDouble\" }",
+ "/binding-primitive/double | { \"value\": [\"notDouble\"] }",
+ "/binding-primitive/double | { \"value\": { \"notDouble\": true } }",
+ "/binding-primitive/double | { \"value\": null }",
+ "/binding-primitive/double | { }",
+ "/binding-primitive/boolean | { \"value\": \"notBool\" }",
+ "/binding-primitive/boolean | { \"value\": [\"notBool\"] }",
+ "/binding-primitive/boolean | { \"value\": { \"notBool\": true } }",
+ "/binding-primitive/boolean | { \"value\": null }",
+ "/binding-primitive/boolean | { }",
+ })
+ void givenMalformedPrimitive_whenPost_thenReturnProblem(String path, String json)
+ throws JsonProcessingException {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE);
+
+ Problem problem = objectMapper.readValue(response.getBody(), Problem.class);
+
+ String expectedKind;
+ if (path.endsWith("/boolean")) {
+ expectedKind = "boolean";
+ } else if (path.endsWith("/float") || path.endsWith("/double")) {
+ expectedKind = "number";
+ } else {
+ expectedKind = "integer";
+ }
+
+ Problem expected =
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, expectedKind)
+ .build();
+
+ if (!problem.equals(expected)) {
+ assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value()));
+ }
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/nested/int | { \"value\": { \"value\": 42 } }",
+ "/binding-primitive/nested/long | { \"value\": { \"value\": 9223372036854775807 } }",
+ "/binding-primitive/nested/short | { \"value\": { \"value\": 123 } }",
+ "/binding-primitive/nested/byte | { \"value\": { \"value\": 12 } }",
+ "/binding-primitive/nested/float | { \"value\": { \"value\": 3.14 } }",
+ "/binding-primitive/nested/double | { \"value\": { \"value\": 2.71828 } }",
+ "/binding-primitive/nested/boolean | { \"value\": { \"value\": true } }"
+ })
+ void givenValidNested_whenPost_thenReturnOk(String path, String json) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("OK");
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": \"notInt\" } }",
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": [\"notInt\"] } }",
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": { \"notInt\": true } } }",
+ "/binding-primitive/nested/int | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/int | { \"nested\": { } } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": \"notLong\" } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": [\"notLong\"] } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": { \"notLong\": true } } }",
+ "/binding-primitive/nested/long | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/long | { \"nested\": { } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": \"notShort\" } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": [\"notShort\"] } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": { \"notShort\":true } } }",
+ "/binding-primitive/nested/short | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/short | { \"nested\": { } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": \"notByte\" } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": [\"notByte\"] } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": { \"notByte\": true } } }",
+ "/binding-primitive/nested/byte | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/byte | { \"nested\": { } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": \"notFloat\" } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": [\"notFloat\"] } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": { \"notFloat\": true } } }",
+ "/binding-primitive/nested/float | { \"nested\": { \"value\": null } }",
+ "/binding-primitive/nested/float | { \"nested\": { } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": \"notDouble\" } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": [\"notDouble\"] } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": { \"notDouble\": true } } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": { \"notDouble\": null } } }",
+ "/binding-primitive/nested/double | { \"nested\": { \"value\": { } } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": \"notBool\" } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": [\"notBool\"] } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": { \"notBool\": true } } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": { \"notBool\": null } } }",
+ "/binding-primitive/nested/boolean | { \"nested\": { \"value\": { } } }",
+ })
+ void givenMalformedNested_whenPost_thenReturnProblem(String path, String json)
+ throws JsonProcessingException {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE);
+
+ Problem problem = objectMapper.readValue(response.getBody(), Problem.class);
+
+ String expectedKind;
+ if (path.endsWith("/boolean")) {
+ expectedKind = "boolean";
+ } else if (path.endsWith("/float") || path.endsWith("/double")) {
+ expectedKind = "number";
+ } else {
+ expectedKind = "integer";
+ }
+
+ assertThat(problem)
+ .isEqualTo(
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "nested.value")
+ .extension(KIND_EXTENSION, expectedKind)
+ .build());
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": \"\" }",
+ "/binding-primitive/long | { \"value\": \"\" }",
+ "/binding-primitive/short | { \"value\": \"\" }",
+ "/binding-primitive/byte | { \"value\": \"\" }",
+ "/binding-primitive/float | { \"value\": \"\" }",
+ "/binding-primitive/double | { \"value\": \"\" }",
+ "/binding-primitive/boolean | { \"value\": \"\" }",
+ })
+ void givenEmptyStringPrimitive_whenPost_thenReturnProblem(String path, String json)
+ throws JsonProcessingException {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE);
+
+ Problem problem = objectMapper.readValue(response.getBody(), Problem.class);
+
+ String expectedKind;
+ if (path.endsWith("/boolean")) {
+ expectedKind = "boolean";
+ } else if (path.endsWith("/float") || path.endsWith("/double")) {
+ expectedKind = "number";
+ } else {
+ expectedKind = "integer";
+ }
+
+ assertThat(problem)
+ .isEqualTo(
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, expectedKind)
+ .build());
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "/binding-primitive/int | { \"value\": 2147483648 }",
+ "/binding-primitive/long | { \"value\": 9223372036854775808 }",
+ "/binding-primitive/short | { \"value\": 40000 }",
+ "/binding-primitive/byte | { \"value\": 256 }",
+ })
+ void givenOverflowPrimitive_whenPost_thenReturnProblem(String path, String json)
+ throws JsonProcessingException {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(path, new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE);
+
+ Problem problem = objectMapper.readValue(response.getBody(), Problem.class);
+
+ Problem expected =
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, "integer")
+ .build();
+
+ if (!problem.equals(expected)) {
+ assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value()));
+ }
+ }
+
+ @Test
+ void givenNullPrimitive_whenPost_thenReturnProblem() throws JsonProcessingException {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(
+ "/binding-primitive/int",
+ new HttpEntity<>("{ \"value\": null }", headers),
+ String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE);
+
+ Problem problem = objectMapper.readValue(response.getBody(), Problem.class);
+
+ Problem expected =
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, "value")
+ .extension(KIND_EXTENSION, "integer")
+ .build();
+
+ if (!problem.equals(expected)) {
+ assertThat(problem).isEqualTo(Problem.of(HttpStatus.BAD_REQUEST.value()));
+ }
+ }
+
+ @Test
+ void givenValidComplexRoot_whenPost_thenReturnOk() {
+ String json =
+ """
+ {
+ "flag": true,
+ "timestamp": 1672531200000,
+ "amount": 12.34,
+ "shortNested": { "value": 3 }
+ }
+ """;
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(
+ "/binding-primitive/complex", new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("OK");
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "{ \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | flag | boolean",
+ "{ \"timestamp\": \"notLong\", \"flag\": \"notBool\", \"amount\": \"notFloat\", \"shortNested\": { \"value\": \"notShort\" } } | timestamp | integer",
+ "{ \"amount\": \"notFloat\", \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"shortNested\": { \"value\": \"notShort\" } } | amount | number",
+ "{ \"shortNested\": { \"value\": \"notShort\" }, \"flag\": \"notBool\", \"timestamp\": \"notLong\", \"amount\": \"notFloat\" } | shortNested.value | integer"
+ })
+ void givenMalformedComplexObject_whenPost_thenReturnProblemWithFirstInvalidFieldAsProperty(
+ String json, String expectedProperty, String expectedKind) throws JsonProcessingException {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ ResponseEntity response =
+ restTemplate.postForEntity(
+ "/binding-primitive/complex", new HttpEntity<>(json, headers), String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getHeaders().getContentType()).hasToString(Problem.CONTENT_TYPE);
+
+ Problem problem = objectMapper.readValue(response.getBody(), Problem.class);
+
+ assertThat(problem)
+ .isEqualTo(
+ Problem.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .detail(TYPE_MISMATCH_DETAIL)
+ .extension(PROPERTY_EXTENSION, expectedProperty)
+ .extension(KIND_EXTENSION, expectedKind)
+ .build());
+ }
+}
diff --git a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/ValidateRequestBodyWebMvcTest.java b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/ValidateRequestBodyWebMvcTest.java
index cfa50dab..cb7fa013 100644
--- a/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/ValidateRequestBodyWebMvcTest.java
+++ b/problem4j-spring-webmvc/src/test/java/io/github/problem4j/spring/webmvc/integration/ValidateRequestBodyWebMvcTest.java
@@ -103,7 +103,7 @@ void givenGlobalValidationViolation_shouldReturnProblemWithoutFieldName() throws
@ParameterizedTest
@ValueSource(
- strings = {"{ \"name\": \"Alice\"", "{ \"name\": \"Alice\", \"age\": \"too young\"}", ""})
+ strings = {"{ \"name\": \"Alice\"", "not a json", "123", "[1, 2, 3]", "\"just a string\""})
@NullSource
void givenMalformedRequestBody_shouldReturnProblem(String json) throws JsonProcessingException {
HttpHeaders headers = new HttpHeaders();