Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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:
*
* <ul>
* <li>Integer types ({@code int}, {@code long}, {@code short}, {@code byte}, their wrapper
* classes, {@code BigInteger}, {@code AtomicInteger} and {@code AtomicLong}) to {@code
* "integer"}
* <li>Decimal types ({@code double}, {@code float}, their wrapper classes and {@code
* BigDecimal} ) to {@code "number"}
* <li>Boolean types ({@code boolean} and {@code Boolean}) to {@code "boolean"}
* <li>String type ({@code String}) and {@code Enum} types to {@code "string"}
* <li>{@code Array} and {@code Collection} types to {@code "array"}
* </ul>
*
* @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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String>} 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<String> map(Class<?> type);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand All @@ -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;
}

/**
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String> property = resolvePropertyPath(e);
Optional<String> 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<String> 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() {}
}
Loading