From 01e3c5c3493760301833915243a5debf9d95ce9c Mon Sep 17 00:00:00 2001 From: marko-bekhta Date: Sun, 24 May 2026 21:58:32 +0200 Subject: [PATCH] Introduce ValidationPackageOpener to make apps more portable --- .../java/jakarta/validation/Validation.java | 17 +++- .../internal/ValidationPackageOpenerImpl.java | 77 +++++++++++++++++++ .../validation/internal/package-info.java | 10 +++ .../validation/spi/BootstrapState.java | 13 ++++ .../spi/PackageAccessException.java | 42 ++++++++++ .../spi/ValidationPackageOpener.java | 73 ++++++++++++++++++ .../validation/FooValidationProvider.java | 15 +--- .../jakarta/validation/ValidationTest.java | 40 +++++++++- 8 files changed, 271 insertions(+), 16 deletions(-) create mode 100644 src/main/java/jakarta/validation/internal/ValidationPackageOpenerImpl.java create mode 100644 src/main/java/jakarta/validation/internal/package-info.java create mode 100644 src/main/java/jakarta/validation/spi/PackageAccessException.java create mode 100644 src/main/java/jakarta/validation/spi/ValidationPackageOpener.java diff --git a/src/main/java/jakarta/validation/Validation.java b/src/main/java/jakarta/validation/Validation.java index 9823aa91..ac284559 100644 --- a/src/main/java/jakarta/validation/Validation.java +++ b/src/main/java/jakarta/validation/Validation.java @@ -17,7 +17,9 @@ import jakarta.validation.bootstrap.GenericBootstrap; import jakarta.validation.bootstrap.ProviderSpecificBootstrap; +import jakarta.validation.internal.ValidationPackageOpenerImpl; import jakarta.validation.spi.BootstrapState; +import jakarta.validation.spi.ValidationPackageOpener; import jakarta.validation.spi.ValidationProvider; /** @@ -215,6 +217,7 @@ public T configure() { try { U provider = validationProviderClass.getDeclaredConstructor().newInstance(); + state.providerModule = provider.getClass().getModule(); return provider.createSpecializedConfiguration( state ); } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException | RuntimeException e) { @@ -237,6 +240,7 @@ public T configure() { for ( ValidationProvider provider : resolvers ) { if ( validationProviderClass.isAssignableFrom( provider.getClass() ) ) { ValidationProvider specificProvider = validationProviderClass.cast( provider ); + state.providerModule = specificProvider.getClass().getModule(); return specificProvider.createSpecializedConfiguration( state ); } @@ -250,6 +254,7 @@ private static class GenericBootstrapImpl implements GenericBootstrap, Bootstrap private ValidationProviderResolver resolver; private ValidationProviderResolver defaultResolver; + private Module providerModule; @Override public GenericBootstrap providerResolver(ValidationProviderResolver resolver) { @@ -270,6 +275,14 @@ public ValidationProviderResolver getDefaultValidationProviderResolver() { return defaultResolver; } + @Override + public ValidationPackageOpener getPackageOpener() { + if ( providerModule == null || !providerModule.isNamed() ) { + return (providerLookup, targetModule, packageName) -> { }; + } + return new ValidationPackageOpenerImpl( providerModule ); + } + @Override public Configuration configure() { ValidationProviderResolver resolver = this.resolver == null ? @@ -297,7 +310,9 @@ public Configuration configure() { Configuration config; try { - config = resolver.getValidationProviders().get( 0 ).createGenericConfiguration( this ); + final ValidationProvider provider = resolver.getValidationProviders().get( 0 ); + this.providerModule = provider.getClass().getModule(); + config = provider.createGenericConfiguration( this ); } catch ( RuntimeException re ) { throw new ValidationException( "Unable to instantiate Configuration.", re ); diff --git a/src/main/java/jakarta/validation/internal/ValidationPackageOpenerImpl.java b/src/main/java/jakarta/validation/internal/ValidationPackageOpenerImpl.java new file mode 100644 index 00000000..3c7c35ab --- /dev/null +++ b/src/main/java/jakarta/validation/internal/ValidationPackageOpenerImpl.java @@ -0,0 +1,77 @@ +/* + * Jakarta Validation API + * + * License: Apache License, Version 2.0 + * See the license.txt file in the root directory or . + */ +package jakarta.validation.internal; + +import java.lang.invoke.MethodHandles; +import java.util.Objects; + +import jakarta.validation.spi.PackageAccessException; +import jakarta.validation.spi.ValidationPackageOpener; + +/** + * Implementation of {@link ValidationPackageOpener} bound to a specific provider module. + *

+ * This class resides in a non-exported package so that it cannot be accessed, + * subclassed, or instantiated from outside the {@code jakarta.validation} module. + *

+ * The {@link Module#addOpens(String, Module)} call is {@code @CallerSensitive}; + * because this class belongs to the {@code jakarta.validation} module, the JVM + * recognizes it as a valid caller when the user has opened a package to + * {@code jakarta.validation}. + * + * @since 4.0 + */ +public final class ValidationPackageOpenerImpl implements ValidationPackageOpener { + + private final Module providerModule; + + /** + * @param providerModule the module of the validation provider discovered + * during bootstrap; this opener will only open packages to this module + */ + public ValidationPackageOpenerImpl(Module providerModule) { + this.providerModule = Objects.requireNonNull( providerModule ); + } + + @Override + public void openPackage(MethodHandles.Lookup providerLookup, Module targetModule, String packageName) { + Objects.requireNonNull( providerLookup ); + Objects.requireNonNull( targetModule ); + Objects.requireNonNull( packageName ); + + if ( !providerLookup.hasFullPrivilegeAccess() ) { + throw new PackageAccessException( + "ValidationPackageOpener requires a full-privilege Lookup (obtained via MethodHandles.lookup())" + ); + } + + if ( !providerLookup.lookupClass().getModule().equals( providerModule ) ) { + throw new PackageAccessException( + "Lookup class module " + providerLookup.lookupClass().getModule().getName() + + " does not match the bound provider module " + providerModule.getName() + ); + } + + if ( !targetModule.isNamed() ) { + return; + } + + try { + if ( !targetModule.isOpen( packageName, providerModule ) ) { + targetModule.addOpens( packageName, providerModule ); + } + } + catch (IllegalCallerException e) { + throw new PackageAccessException( + "Cannot open package " + packageName + " in module " + targetModule.getName() + + " to provider module " + providerModule.getName() + + ". Ensure the module has 'opens " + packageName + " to jakarta.validation'", + e + ); + } + } +} diff --git a/src/main/java/jakarta/validation/internal/package-info.java b/src/main/java/jakarta/validation/internal/package-info.java new file mode 100644 index 00000000..ce32b34c --- /dev/null +++ b/src/main/java/jakarta/validation/internal/package-info.java @@ -0,0 +1,10 @@ +/* + * Jakarta Validation API + * + * License: Apache License, Version 2.0 + * See the license.txt file in the root directory or . + */ +/** + * Internal implementation package -- not exported and not part of the public API. + */ +package jakarta.validation.internal; diff --git a/src/main/java/jakarta/validation/spi/BootstrapState.java b/src/main/java/jakarta/validation/spi/BootstrapState.java index 1a2c2f8d..d18c80f0 100644 --- a/src/main/java/jakarta/validation/spi/BootstrapState.java +++ b/src/main/java/jakarta/validation/spi/BootstrapState.java @@ -32,4 +32,17 @@ public interface BootstrapState { * @return default implementation of ValidationProviderResolver */ ValidationProviderResolver getDefaultValidationProviderResolver(); + + /** + * Returns a {@link ValidationPackageOpener} that can relay JPMS package-opens + * through the {@code jakarta.validation} module to the validation + * provider module discovered during bootstrap. + *

+ * In non-modular (classpath) environments, the returned opener is a + * no-op and can be safely invoked without effect. + * + * @return a {@code ValidationPackageOpener} instance; never {@code null} + * @since 4.0 + */ + ValidationPackageOpener getPackageOpener(); } diff --git a/src/main/java/jakarta/validation/spi/PackageAccessException.java b/src/main/java/jakarta/validation/spi/PackageAccessException.java new file mode 100644 index 00000000..b806f5d7 --- /dev/null +++ b/src/main/java/jakarta/validation/spi/PackageAccessException.java @@ -0,0 +1,42 @@ +/* + * Jakarta Validation API + * + * License: Apache License, Version 2.0 + * See the license.txt file in the root directory or . + */ +package jakarta.validation.spi; + +import jakarta.validation.ValidationException; + +import java.io.Serial; + +/** + * Exception raised when a {@link jakarta.validation.spi.ValidationPackageOpener} + * cannot relay package access to the validation provider module. + *

+ * Typical causes include a missing or invalid {@link java.lang.invoke.MethodHandles.Lookup} + * and a package that has not been opened to the {@code jakarta.validation} module. + * + * @since 4.0 + */ +public class PackageAccessException extends ValidationException { + + @Serial + private static final long serialVersionUID = 1L; + + public PackageAccessException() { + super(); + } + + public PackageAccessException(String message) { + super( message ); + } + + public PackageAccessException(Throwable cause) { + super( cause ); + } + + public PackageAccessException(String message, Throwable cause) { + super( message, cause ); + } +} diff --git a/src/main/java/jakarta/validation/spi/ValidationPackageOpener.java b/src/main/java/jakarta/validation/spi/ValidationPackageOpener.java new file mode 100644 index 00000000..e56b1c86 --- /dev/null +++ b/src/main/java/jakarta/validation/spi/ValidationPackageOpener.java @@ -0,0 +1,73 @@ +/* + * Jakarta Validation API + * + * License: Apache License, Version 2.0 + * See the license.txt file in the root directory or . + */ +package jakarta.validation.spi; + +import java.lang.invoke.MethodHandles; + +/** + * Callback provided by the Jakarta Validation bootstrap to relay + * JPMS package-opens through the {@code jakarta.validation} module to a + * validation provider module. + *

+ * When a user module opens a package to {@code jakarta.validation} + * via a qualified {@code opens ... to jakarta.validation} directive, + * this callback relays that access to the validation provider's module, + * so the user never needs to name the provider module directly. + *

+ * The opener is bound to the provider module that was discovered during + * bootstrap. The {@link MethodHandles.Lookup} parameter serves as an + * unforgeable proof of caller identity: the implementation verifies + * that the lookup has {@linkplain MethodHandles.Lookup#hasFullPrivilegeAccess() + * full privilege access} and that its + * {@linkplain MethodHandles.Lookup#lookupClass() lookup class} belongs + * to the bound provider module. + *

+ * Usage by providers: + *

+ * // During bootstrap, obtain the opener from BootstrapState
+ * ValidationPackageOpener opener = bootstrapState.getPackageOpener();
+ *
+ * // Only relay when the user module has opened the package to jakarta.validation
+ * Module targetModule = beanClass.getModule();
+ * String packageName = beanClass.getPackageName();
+ * if (targetModule.isOpen(packageName, ValidationPackageOpener.class.getModule())) {
+ *     // Create lookup at the call site — do not cache as a static field
+ *     opener.openPackage(MethodHandles.lookup(), targetModule, packageName);
+ * }
+ * 
+ *

+ * In non-modular environments (classpath), invoking this callback + * is a safe no-op. + *

+ * If the provider needs to further relay access to a third-party + * library module, it can call {@link Module#addOpens(String, Module)} + * from its own code after obtaining access through this callback. + * + * @since 4.0 + */ +@FunctionalInterface +public interface ValidationPackageOpener { + + /** + * Opens {@code packageName} in {@code targetModule} to the bound + * provider module, provided that the package is open to the + * {@code jakarta.validation} module and not already open to the + * provider. + * + * @param providerLookup a full-privilege {@link MethodHandles.Lookup} + * obtained via {@code MethodHandles.lookup()} in the provider's + * code; used to verify the caller's identity + * @param targetModule the module containing the package to open + * (typically the user's module) + * @param packageName the fully qualified package name to open + * (e.g. {@code "com.example.beans"}) + * @throws PackageAccessException if the lookup does not + * have full privilege access, does not belong to the bound + * provider module, or the package could not be opened + */ + void openPackage(MethodHandles.Lookup providerLookup, Module targetModule, String packageName); +} diff --git a/src/test/java/jakarta/validation/FooValidationProvider.java b/src/test/java/jakarta/validation/FooValidationProvider.java index 3561f52b..7d51586e 100644 --- a/src/test/java/jakarta/validation/FooValidationProvider.java +++ b/src/test/java/jakarta/validation/FooValidationProvider.java @@ -11,16 +11,6 @@ import java.util.ArrayList; import java.util.List; -import jakarta.validation.BootstrapConfiguration; -import jakarta.validation.ClockProvider; -import jakarta.validation.Configuration; -import jakarta.validation.ConstraintValidatorFactory; -import jakarta.validation.MessageInterpolator; -import jakarta.validation.ParameterNameProvider; -import jakarta.validation.TraversableResolver; -import jakarta.validation.Validator; -import jakarta.validation.ValidatorContext; -import jakarta.validation.ValidatorFactory; import jakarta.validation.FooValidationProvider.DummyConfiguration; import jakarta.validation.spi.BootstrapState; import jakarta.validation.spi.ConfigurationState; @@ -32,6 +22,7 @@ */ public class FooValidationProvider implements ValidationProvider { public static List> createdValidationProviders = new ArrayList<>(); + public static volatile BootstrapState latestBootstrapState; public FooValidationProvider() { createdValidationProviders.add( new SoftReference<>( this ) ); @@ -39,11 +30,13 @@ public FooValidationProvider() { @Override public DummyConfiguration createSpecializedConfiguration(BootstrapState state) { - return null; + latestBootstrapState = state; + return new DummyConfiguration(); } @Override public DummyConfiguration createGenericConfiguration(BootstrapState state) { + latestBootstrapState = state; return new DummyConfiguration(); } diff --git a/src/test/java/jakarta/validation/ValidationTest.java b/src/test/java/jakarta/validation/ValidationTest.java index 40f8896b..9ab24da4 100644 --- a/src/test/java/jakarta/validation/ValidationTest.java +++ b/src/test/java/jakarta/validation/ValidationTest.java @@ -13,6 +13,7 @@ import static org.testng.Assert.fail; import java.io.IOException; +import java.lang.invoke.MethodHandles; import java.lang.ref.SoftReference; import java.net.URL; import java.net.URLClassLoader; @@ -23,11 +24,8 @@ import org.testng.annotations.Test; -import jakarta.validation.NoProviderFoundException; -import jakarta.validation.Validation; -import jakarta.validation.ValidationProviderResolver; -import jakarta.validation.ValidatorFactory; import jakarta.validation.NonRegisteredValidationProvider.NonRegisteredConfiguration; +import jakarta.validation.spi.ValidationPackageOpener; import jakarta.validation.spi.ValidationProvider; /** @@ -154,6 +152,40 @@ public void testNoProviderFoundThrowsNoProviderFoundException() { .buildValidatorFactory(); } + @Test + public void testPackageOpenerAvailableFromBootstrapState() { + FooValidationProvider.latestBootstrapState = null; + Validation.buildDefaultValidatorFactory(); + assertNotNull( FooValidationProvider.latestBootstrapState, "BootstrapState should have been captured" ); + + ValidationPackageOpener opener = FooValidationProvider.latestBootstrapState.getPackageOpener(); + assertNotNull( opener, "ValidationPackageOpener should not be null" ); + } + + @Test + public void testPackageOpenerNoOpOnUnnamedModule() { + FooValidationProvider.latestBootstrapState = null; + Validation.buildDefaultValidatorFactory(); + ValidationPackageOpener opener = FooValidationProvider.latestBootstrapState.getPackageOpener(); + + // In classpath mode all classes are in the unnamed module; openPackage should be a no-op + opener.openPackage( + MethodHandles.lookup(), + getClass().getModule(), + getClass().getPackageName() + ); + } + + @Test + public void testPackageOpenerAvailableFromProviderSpecificBootstrap() { + FooValidationProvider.latestBootstrapState = null; + Validation.byProvider( FooValidationProvider.class ).configure(); + assertNotNull( FooValidationProvider.latestBootstrapState, "BootstrapState should have been captured" ); + + ValidationPackageOpener opener = FooValidationProvider.latestBootstrapState.getPackageOpener(); + assertNotNull( opener, "ValidationPackageOpener should not be null for provider-specific bootstrap" ); + } + private int countInMemoryProviders() { int count = 0; // we cannot access Validation.DefaultValidationProviderResolver#providersPerClassloader, so we have to