ValidationErrorMessages.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.util;
import jakarta.validation.ConstraintViolation;
import org.springframework.validation.FieldError;
import java.lang.reflect.RecordComponent;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
/**
* Picks the one violation to report when several constraints fail together.
*
* <p>Endpoints that answer with a single message need a defined winner. Bean Validation returns
* violations as a {@code Set} in unspecified order, so reporting "the first" would vary between runs
* for the same request.
*
* <p><b>The rule:</b> earliest-declared record component wins; ties break on the property path, so the
* outcome is total and reproducible even for a body that is not a record (where every path ties) or a
* validated method's parameters (which have no declaration order to consult).
*
* <p>Component declaration order is therefore a client-visible contract for these bodies: reordering
* the components of a request-body record changes which message a caller receives. {@code
* SignupRequestBody} declares email, password, firstName, lastName in the order their messages should
* win.
*/
public final class ValidationErrorMessages {
private ValidationErrorMessages() {
}
/**
* For violations straight from a {@code Validator} — a programmatically validated body, or the set
* carried by a {@code ConstraintViolationException} from a validated method's parameters.
*
* <p>Wildcarded rather than generic in the root bean type: {@code
* ConstraintViolationException.getConstraintViolations()} is a {@code Set<ConstraintViolation<?>>},
* which no single type variable can satisfy.
*/
public static Optional<String> firstFromViolations(Class<?> bodyType,
Set<? extends ConstraintViolation<?>> violations) {
return firstByDeclarationOrder(bodyType, violations,
violation -> violation.getPropertyPath().toString(), ConstraintViolation::getMessage);
}
/**
* For Spring's binding result, where violations have already become {@link FieldError}s.
*
* <p>{@code getField()} is also a path — a constraint on a nested member reports {@code
* "address.city"} — so it goes through the same leading-segment reduction as a violation path.
*/
public static Optional<String> firstFromFieldErrors(Class<?> bodyType, List<FieldError> fieldErrors) {
return firstByDeclarationOrder(bodyType, fieldErrors,
FieldError::getField, FieldError::getDefaultMessage);
}
/**
* One implementation for both entry points, so the winner-selection rule cannot be changed on one
* path and left stale on the other.
*/
private static <E> Optional<String> firstByDeclarationOrder(Class<?> bodyType, Collection<E> errors,
Function<E, String> path,
Function<E, String> message) {
// Resolved once per call rather than inside the comparator: Class.getRecordComponents() is an
// uncached native call that allocates a fresh array plus one object per component, and a
// comparator is invoked O(n) times per selection — on a path an unauthenticated caller can drive
// by sending invalid bodies.
Map<String, Integer> declarationOrder = declarationOrder(bodyType);
Comparator<E> byDeclarationThenPath = Comparator
.<E, Integer>comparing(error -> declarationIndex(declarationOrder, rootField(path.apply(error))))
.thenComparing(error -> rootField(path.apply(error)));
return errors.stream().min(byDeclarationThenPath).map(message);
}
/**
* Leading segment of a property path, so a violation on a nested member is ordered by the top-level
* field that contains it.
*/
private static String rootField(String propertyPath) {
int firstSeparator = propertyPath.indexOf('.');
return firstSeparator < 0 ? propertyPath : propertyPath.substring(0, firstSeparator);
}
/** Empty for a non-record type, which leaves every field tied and the path tie-break deciding. */
private static Map<String, Integer> declarationOrder(Class<?> bodyType) {
RecordComponent[] components = bodyType.getRecordComponents();
if (components == null) {
return Map.of();
}
Map<String, Integer> order = new HashMap<>();
for (int index = 0; index < components.length; index++) {
order.put(components[index].getName(), index);
}
return order;
}
/**
* A field that is not a component of this record — a class-level constraint, say — sorts after every
* component, so it is reported only when nothing else failed.
*/
private static int declarationIndex(Map<String, Integer> declarationOrder, String field) {
Integer index = declarationOrder.get(field);
return index != null ? index : declarationOrder.size();
}
}