You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bval.apache.org by ju...@apache.org on 2026/08/05 11:30:20 UTC
(bval) branch bval-4.x updated: BVAL-228 - Support discovering ConstraintValidator via service loading
This is an automated email from the ASF dual-hosted git repository.
jungm pushed a commit to branch bval-4.x
in repository https://gitbox.apache.org/repos/asf/bval.git
The following commit(s) were added to refs/heads/bval-4.x by this push:
new 141924e BVAL-228 - Support discovering ConstraintValidator via service loading
141924e is described below
commit 141924e48e8e1b82f1efda4d36076c06864dea2a
Author: Markus Jung <ju...@apache.org>
AuthorDate: Wed Aug 5 13:30:04 2026 +0200
BVAL-228 - Support discovering ConstraintValidator via service loading
---
.../apache/bval/jsr/ApacheValidatorFactory.java | 5 +
.../java/org/apache/bval/jsr/ConstraintCached.java | 27 ++-
.../org/apache/bval/jsr/ParticipantFactory.java | 37 +++-
.../ServiceLoaderValidatorMappingProvider.java | 105 ++++++++++
.../jsr/ServiceLoadedConstraintValidatorTest.java | 213 +++++++++++++++++++++
.../jsr/serviceloader/CollidingConstraint.java | 58 ++++++
.../serviceloader/CollidingServiceValidator.java | 30 +++
.../jsr/serviceloader/ServiceLoadedConstraint.java | 48 +++++
.../ServiceLoadedConstraintValidator.java | 30 +++
.../serviceloader/ServiceLoaderBootstrapTest.java | 87 +++++++++
.../jakarta.validation.ConstraintValidator | 18 ++
11 files changed, 654 insertions(+), 4 deletions(-)
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java b/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java
index 8fa5785..2983191 100644
--- a/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java
@@ -28,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
import jakarta.validation.ClockProvider;
+import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorFactory;
import jakarta.validation.MessageInterpolator;
import jakarta.validation.ParameterNameProvider;
@@ -45,6 +46,7 @@ import org.apache.bval.jsr.metadata.MetadataBuilder;
import org.apache.bval.jsr.metadata.MetadataBuilder.ForBean;
import org.apache.bval.jsr.metadata.MetadataBuilders;
import org.apache.bval.jsr.metadata.MetadataSource;
+import org.apache.bval.jsr.metadata.ServiceLoaderValidatorMappingProvider;
import org.apache.bval.jsr.util.AnnotationsManager;
import org.apache.bval.jsr.valueextraction.ValueExtractors;
import org.apache.bval.jsr.valueextraction.ValueExtractors.OnDuplicateContainerElementKey;
@@ -138,6 +140,9 @@ public class ApacheValidatorFactory implements ValidatorFactory, Cloneable {
toClose.add(participantFactory);
+ constraintsCache.setServiceLoaderValidatorMappingProvider(new ServiceLoaderValidatorMappingProvider(
+ participantFactory.loadServiceClasses(ConstraintValidator.class)));
+
valueExtractors = createBaseValueExtractors(participantFactory).createChild();
configuration.getValueExtractors().forEach(valueExtractors::add);
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java b/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java
index 2dc1279..555e97f 100644
--- a/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java
@@ -106,13 +106,32 @@ public class ConstraintCached {
private final Lazy<ValidatorMappingProvider> validatorMappingProvider =
new Lazy<>(this::createValidatorMappingProvider);
+ private ValidatorMappingProvider serviceLoaderValidatorMappingProvider;
+
public ConcurrentMap<ConstraintD<?>, ConstraintValidator<?, ?>> getValidators() {
return validators;
}
public void add(ValidatorMappingProvider validatorMappingProvider) {
customValidatorMappingProviders.add(validatorMappingProvider);
- this.validatorMappingProvider.reset(this::createValidatorMappingProvider);
+ resetValidatorMappingProvider();
+ }
+
+ /**
+ * Set the provider of the {@link ConstraintValidator} implementations registered via the service loader
+ * mechanism. These rank alongside the built-in validators: they are merged with, rather than overridden by,
+ * {@code @Constraint.validatedBy()} and XML {@code <constraint-definition>} registrations that include existing
+ * validators.
+ */
+ public void setServiceLoaderValidatorMappingProvider(ValidatorMappingProvider serviceLoaderValidatorMappingProvider) {
+ this.serviceLoaderValidatorMappingProvider = serviceLoaderValidatorMappingProvider;
+ resetValidatorMappingProvider();
+ }
+
+ private void resetValidatorMappingProvider() {
+ validatorMappingProvider.reset(this::createValidatorMappingProvider);
+ // memoized per-constraint results were computed from the previous composition:
+ constraintValidatorInfo.clear();
}
public <A extends Annotation> List<Class<? extends ConstraintValidator<A, ?>>> getConstraintValidatorClasses(
@@ -146,6 +165,10 @@ public class ConstraintCached {
}
configured = new DualValidationMappingProvider(AnnotationDeclaredValidatorMappingProvider.INSTANCE, custom);
}
- return new DualValidationMappingProvider(ConstraintDefaults.INSTANCE, configured);
+ final ValidatorMappingProvider preconfigured = serviceLoaderValidatorMappingProvider == null
+ ? ConstraintDefaults.INSTANCE
+ : new DualValidationMappingProvider(ConstraintDefaults.INSTANCE, serviceLoaderValidatorMappingProvider);
+
+ return new DualValidationMappingProvider(preconfigured, configured);
}
}
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java b/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java
index 1cbdb3d..ae0b8a3 100644
--- a/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java
@@ -78,6 +78,29 @@ class ParticipantFactory implements Closeable {
}
<T> Set<T> loadServices(Class<T> type) { // todo: enable somehow to cache shared classloader (think tomcat/tomee)
+ return serviceEntries(type).stream().<T> map(this::create).collect(ToUnmodifiable.set());
+ }
+
+ /**
+ * Read the service entries declared for {@code type} without instantiating them. Used where the declared classes
+ * themselves are the participants, e.g. {@code ConstraintValidator} implementations, whose instantiation is the
+ * business of the configured {@code ConstraintValidatorFactory}.
+ */
+ <T> Set<Class<? extends T>> loadServiceClasses(Class<T> type) {
+ return serviceEntries(type).stream().map(classname -> asSubclass(loadClass(classname), type))
+ .collect(ToUnmodifiable.set());
+ }
+
+ private static <T> Class<? extends T> asSubclass(Class<?> loaded, Class<T> type) {
+ try {
+ return loaded.asSubclass(type);
+ } catch (ClassCastException e) {
+ throw new ValidationException(String.format("%s declared in %s%s does not implement it", loaded.getName(),
+ META_INF_SERVICES, type.getName()), e);
+ }
+ }
+
+ private Set<String> serviceEntries(Class<?> type) {
Validate.notNull(type);
final Set<URL> resources = new LinkedHashSet<>();
final String resourceName = META_INF_SERVICES + type.getName();
@@ -90,19 +113,29 @@ class ParticipantFactory implements Closeable {
log.log(Level.SEVERE, "Error searching for resource(s) " + resourceName, e);
}
}
- return resources.stream().distinct().map(this::read).flatMap(Collection::stream).<T> map(this::create)
+ return resources.stream().distinct().map(this::read).flatMap(Collection::stream)
.collect(ToUnmodifiable.set());
}
private Set<String> read(URL url) {
try (BufferedReader r = new BufferedReader(new InputStreamReader(url.openStream()))) {
- return r.lines().map(String::trim).filter(line -> line.charAt(0) != '#').collect(Collectors.toSet());
+ return r.lines().map(ParticipantFactory::stripComment).filter(line -> !line.isEmpty())
+ .collect(Collectors.toSet());
} catch (IOException e) {
log.log(Level.SEVERE, "Unable to read resource " + url, e);
return Collections.emptySet();
}
}
+ /**
+ * Per the service provider configuration file format, a {@code '#'} starts a comment running to the end of the
+ * line, whether or not anything precedes it.
+ */
+ private static String stripComment(String line) {
+ final int comment = line.indexOf('#');
+ return (comment < 0 ? line : line.substring(0, comment)).trim();
+ }
+
@SuppressWarnings("unchecked")
private <T> Class<T> loadClass(final String className) {
for (ClassLoader loader : loaders) {
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/metadata/ServiceLoaderValidatorMappingProvider.java b/bval-jsr/src/main/java/org/apache/bval/jsr/metadata/ServiceLoaderValidatorMappingProvider.java
new file mode 100644
index 0000000..aeb0c8e
--- /dev/null
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/metadata/ServiceLoaderValidatorMappingProvider.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.bval.jsr.metadata;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import jakarta.validation.ConstraintValidator;
+
+import org.apache.bval.util.Validate;
+import org.apache.bval.util.reflection.TypeUtils;
+
+/**
+ * Provides the {@link ConstraintValidator} implementations registered via the service loader mechanism, i.e. named in
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator} resources. Discovered types are indexed by the
+ * constraint annotation they validate, which is the first type argument of their {@link ConstraintValidator}
+ * implementation.
+ *
+ * @since 4.0
+ */
+public class ServiceLoaderValidatorMappingProvider extends ValidatorMappingProvider {
+ public static final String SOURCE = "META-INF/services/" + ConstraintValidator.class.getName();
+
+ private static final Logger log = Logger.getLogger(ServiceLoaderValidatorMappingProvider.class.getName());
+
+ private final Map<Class<? extends Annotation>, List<Class<? extends ConstraintValidator<?, ?>>>> validatorTypes;
+
+ public ServiceLoaderValidatorMappingProvider(Collection<? extends Class<?>> serviceLoadedTypes) {
+ super();
+ Validate.notNull(serviceLoadedTypes, "serviceLoadedTypes");
+
+ final Map<Class<? extends Annotation>, List<Class<? extends ConstraintValidator<?, ?>>>> index = new HashMap<>();
+ for (Class<?> type : serviceLoadedTypes) {
+ if (Modifier.isAbstract(type.getModifiers())) {
+ ignore(type, "it is not instantiable");
+ continue;
+ }
+ final Class<? extends Annotation> constraintType = constraintTypeOf(type);
+ if (constraintType == null) {
+ continue;
+ }
+ @SuppressWarnings("unchecked")
+ final Class<? extends ConstraintValidator<?, ?>> validatorType =
+ (Class<? extends ConstraintValidator<?, ?>>) type.asSubclass(ConstraintValidator.class);
+
+ index.computeIfAbsent(constraintType, k -> new ArrayList<>()).add(validatorType);
+ }
+ index.replaceAll((k, v) -> Collections.unmodifiableList(v));
+ this.validatorTypes = Collections.unmodifiableMap(index);
+ }
+
+ @Override
+ protected <A extends Annotation> ValidatorMapping<A> doGetValidatorMapping(Class<A> constraintType) {
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ final List<Class<? extends ConstraintValidator<A, ?>>> types = (List) validatorTypes.get(constraintType);
+
+ return types == null ? null : new ValidatorMapping<>(SOURCE, types);
+ }
+
+ private static Class<? extends Annotation> constraintTypeOf(Class<?> validatorType) {
+ final Map<TypeVariable<?>, Type> typeArguments =
+ TypeUtils.getTypeArguments(validatorType, ConstraintValidator.class);
+
+ final Type constraintParameter =
+ typeArguments == null ? null : typeArguments.get(ConstraintValidator.class.getTypeParameters()[0]);
+
+ if (constraintParameter instanceof Class<?>
+ && Annotation.class.isAssignableFrom((Class<?>) constraintParameter)) {
+ return ((Class<?>) constraintParameter).asSubclass(Annotation.class);
+ }
+ ignore(validatorType, "the constraint annotation type it validates cannot be determined");
+ return null;
+ }
+
+ private static void ignore(Class<?> validatorType, String reason) {
+ if (log.isLoggable(Level.WARNING)) {
+ log.log(Level.WARNING, String.format("Ignoring %s declared in %s: %s", validatorType.getName(), SOURCE,
+ reason));
+ }
+ }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/ServiceLoadedConstraintValidatorTest.java b/bval-jsr/src/test/java/org/apache/bval/jsr/ServiceLoadedConstraintValidatorTest.java
new file mode 100644
index 0000000..39a72a3
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/ServiceLoadedConstraintValidatorTest.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.bval.jsr;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import jakarta.validation.Payload;
+import jakarta.validation.constraints.Size;
+
+import org.apache.bval.constraints.SizeValidator;
+import org.apache.bval.jsr.metadata.AnnotationBehavior;
+import org.apache.bval.jsr.metadata.ServiceLoaderValidatorMappingProvider;
+import org.apache.bval.jsr.metadata.ValidatorMapping;
+import org.apache.bval.jsr.metadata.ValidatorMappingProvider;
+import org.junit.Test;
+
+/**
+ * Covers the 4.0 service loader registration source: {@link ServiceLoaderValidatorMappingProvider} and the way
+ * {@link ConstraintCached} composes it with the built-in, annotation-declared and XML-declared sources.
+ */
+public class ServiceLoadedConstraintValidatorTest {
+
+ @Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+ @Retention(RUNTIME)
+ @Constraint(validatedBy = {})
+ public @interface ServiceLoaded {
+ String message() default "";
+
+ Class<?>[] groups() default {};
+
+ Class<? extends Payload>[] payload() default {};
+ }
+
+ @Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+ @Retention(RUNTIME)
+ @Constraint(validatedBy = { AnnotationDeclaredValidator.class })
+ public @interface AnnotationDeclared {
+ String message() default "";
+
+ Class<?>[] groups() default {};
+
+ Class<? extends Payload>[] payload() default {};
+ }
+
+ public static class ServiceLoadedValidator implements ConstraintValidator<ServiceLoaded, Object> {
+ @Override
+ public boolean isValid(Object value, ConstraintValidatorContext context) {
+ return true;
+ }
+ }
+
+ public static class AnnotationDeclaredValidator implements ConstraintValidator<AnnotationDeclared, Object> {
+ @Override
+ public boolean isValid(Object value, ConstraintValidatorContext context) {
+ return true;
+ }
+ }
+
+ public static class ServiceLoadedAnnotationDeclaredValidator
+ implements ConstraintValidator<AnnotationDeclared, String> {
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return true;
+ }
+ }
+
+ public static class SizeValidatorForBook implements ConstraintValidator<Size, Book> {
+ @Override
+ public boolean isValid(Book value, ConstraintValidatorContext context) {
+ return true;
+ }
+ }
+
+ public static class Book {
+ }
+
+ /** Not a {@link ConstraintValidator} of any determinable constraint type. */
+ @SuppressWarnings("rawtypes")
+ public static class RawValidator implements ConstraintValidator {
+ @Override
+ public boolean isValid(Object value, ConstraintValidatorContext context) {
+ return true;
+ }
+ }
+
+ public static abstract class AbstractServiceLoadedValidator implements ConstraintValidator<ServiceLoaded, Object> {
+ }
+
+ private static ServiceLoaderValidatorMappingProvider provider(Class<?>... types) {
+ return new ServiceLoaderValidatorMappingProvider(Arrays.asList(types));
+ }
+
+ private static ConstraintCached cache(ServiceLoaderValidatorMappingProvider serviceLoader) {
+ final ConstraintCached result = new ConstraintCached();
+ result.setServiceLoaderValidatorMappingProvider(serviceLoader);
+ return result;
+ }
+
+ @Test
+ public void testIndexedByConstraintType() {
+ final ServiceLoaderValidatorMappingProvider provider =
+ provider(ServiceLoadedValidator.class, SizeValidatorForBook.class);
+
+ assertEquals(Collections.singletonList(ServiceLoadedValidator.class),
+ provider.getValidatorMapping(ServiceLoaded.class).getValidatorTypes());
+
+ assertEquals(Collections.singletonList(SizeValidatorForBook.class),
+ provider.getValidatorMapping(Size.class).getValidatorTypes());
+ }
+
+ @Test
+ public void testUnknownConstraintType() {
+ assertNull(provider(ServiceLoadedValidator.class).getValidatorMapping(AnnotationDeclared.class));
+ }
+
+ @Test
+ public void testUndeterminableConstraintTypeIgnored() {
+ assertNull(provider(RawValidator.class).getValidatorMapping(ServiceLoaded.class));
+ }
+
+ @Test
+ public void testAbstractValidatorIgnored() {
+ assertNull(provider(AbstractServiceLoadedValidator.class).getValidatorMapping(ServiceLoaded.class));
+ }
+
+ @Test
+ public void testServiceLoadedValidatorForConstraintWithoutValidatedBy() {
+ assertEquals(Collections.singletonList(ServiceLoadedValidator.class),
+ cache(provider(ServiceLoadedValidator.class)).getConstraintValidatorClasses(ServiceLoaded.class));
+ }
+
+ @Test
+ public void testServiceLoadedValidatorMergedWithValidatedBy() {
+ final List<Class<? extends ConstraintValidator<AnnotationDeclared, ?>>> validators =
+ cache(provider(ServiceLoadedAnnotationDeclaredValidator.class))
+ .getConstraintValidatorClasses(AnnotationDeclared.class);
+
+ assertEquals(2, validators.size());
+ assertTrue(validators.containsAll(
+ Arrays.asList(AnnotationDeclaredValidator.class, ServiceLoadedAnnotationDeclaredValidator.class)));
+ }
+
+ @Test
+ public void testServiceLoadedValidatorMergedWithBuiltIn() {
+ final List<Class<? extends ConstraintValidator<Size, ?>>> validators =
+ cache(provider(SizeValidatorForBook.class)).getConstraintValidatorClasses(Size.class);
+
+ assertTrue(validators.contains(SizeValidatorForBook.class));
+ assertTrue(validators.contains(SizeValidator.ForCharSequence.class));
+ }
+
+ @Test
+ public void testXmlOverrideExcludesServiceLoadedValidator() {
+ final ConstraintCached cache = cache(provider(SizeValidatorForBook.class));
+ cache.add(excluding(Size.class, SizeValidator.ForCharSequence.class));
+
+ assertEquals(Collections.singletonList(SizeValidator.ForCharSequence.class),
+ cache.getConstraintValidatorClasses(Size.class));
+ }
+
+ /**
+ * Stands in for an XML {@code <validated-by include-existing-validators="false">} registration.
+ */
+ private static ValidatorMappingProvider excluding(Class<? extends Annotation> constraintType,
+ Class<? extends ConstraintValidator<?, ?>> validatorType) {
+ return new ValidatorMappingProvider() {
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ @Override
+ protected <A extends Annotation> ValidatorMapping<A> doGetValidatorMapping(Class<A> t) {
+ return t.equals(constraintType)
+ ? new ValidatorMapping<>("test", (List) Collections.singletonList(validatorType),
+ AnnotationBehavior.EXCLUDE)
+ : null;
+ }
+ };
+ }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingConstraint.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingConstraint.java
new file mode 100644
index 0000000..8a1a312
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingConstraint.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.bval.jsr.serviceloader;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import jakarta.validation.Payload;
+
+/**
+ * Declares a validator for {@link String} while {@link CollidingServiceValidator}, registered via
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator}, declares another for the same target type.
+ */
+@Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+@Retention(RUNTIME)
+@Constraint(validatedBy = { CollidingConstraint.Validator.class })
+public @interface CollidingConstraint {
+ String message() default "colliding";
+
+ Class<?>[] groups() default {};
+
+ Class<? extends Payload>[] payload() default {};
+
+ class Validator implements ConstraintValidator<CollidingConstraint, String> {
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return true;
+ }
+ }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingServiceValidator.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingServiceValidator.java
new file mode 100644
index 0000000..22bb2dd
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingServiceValidator.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.bval.jsr.serviceloader;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class CollidingServiceValidator implements ConstraintValidator<CollidingConstraint, String> {
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return true;
+ }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraint.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraint.java
new file mode 100644
index 0000000..efc5edd
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraint.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.bval.jsr.serviceloader;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+/**
+ * Declares no validator of its own; the only one comes from
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator}.
+ */
+@Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+@Retention(RUNTIME)
+@Constraint
+public @interface ServiceLoadedConstraint {
+ String message() default "service-loaded";
+
+ Class<?>[] groups() default {};
+
+ Class<? extends Payload>[] payload() default {};
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraintValidator.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraintValidator.java
new file mode 100644
index 0000000..174589d
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraintValidator.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.bval.jsr.serviceloader;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class ServiceLoadedConstraintValidator implements ConstraintValidator<ServiceLoadedConstraint, String> {
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return "valid".equals(value);
+ }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoaderBootstrapTest.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoaderBootstrapTest.java
new file mode 100644
index 0000000..85ffbc0
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoaderBootstrapTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.bval.jsr.serviceloader;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Set;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.ValidationException;
+import jakarta.validation.Validator;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Exercises constraint validator discovery through a real
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator} on the test classpath, i.e. the
+ * {@code ParticipantFactory} lookup and the wiring done by {@code ApacheValidatorFactory}, which the provider's own
+ * unit tests bypass.
+ */
+public class ServiceLoaderBootstrapTest {
+
+ public static class ServiceLoadedBean {
+ @ServiceLoadedConstraint
+ public String value;
+
+ public ServiceLoadedBean(String value) {
+ this.value = value;
+ }
+ }
+
+ public static class CollidingBean {
+ @CollidingConstraint
+ public String value;
+ }
+
+ private static Validator validator;
+
+ @BeforeClass
+ public static void setup() {
+ validator = Validation.buildDefaultValidatorFactory().getValidator();
+ }
+
+ @Test
+ public void testServiceLoadedValidatorApplied() {
+ final Set<ConstraintViolation<ServiceLoadedBean>> violations =
+ validator.validate(new ServiceLoadedBean("nope"));
+
+ assertEquals(1, violations.size());
+ assertEquals(ServiceLoadedConstraint.class,
+ violations.iterator().next().getConstraintDescriptor().getAnnotation().annotationType());
+ }
+
+ @Test
+ public void testServiceLoadedValidatorSatisfied() {
+ assertTrue(validator.validate(new ServiceLoadedBean("valid")).isEmpty());
+ }
+
+ /**
+ * Two validators for the same constraint and target type, one from {@code validatedBy()} and one from the service
+ * loader, neither source overriding the other.
+ */
+ @Test
+ public void testCollisionWithAnnotationDeclaredValidator() {
+ assertThrows(ValidationException.class, () -> validator.validate(new CollidingBean()));
+ }
+}
diff --git a/bval-jsr/src/test/resources/META-INF/services/jakarta.validation.ConstraintValidator b/bval-jsr/src/test/resources/META-INF/services/jakarta.validation.ConstraintValidator
new file mode 100644
index 0000000..c48ec04
--- /dev/null
+++ b/bval-jsr/src/test/resources/META-INF/services/jakarta.validation.ConstraintValidator
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+org.apache.bval.jsr.serviceloader.ServiceLoadedConstraintValidator
+org.apache.bval.jsr.serviceloader.CollidingServiceValidator # trailing comments are legal here