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: + * + * + * + * @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();