From 9e045287c1ef638cc32a399c02fbe09e46f0bb73 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 12 Aug 2026 18:37:30 +0000 Subject: [PATCH] feat(ext-data): add unified Data gateway; mcp: derive roles claim from OIDC Data/RepositoryFactory/HibernateData give applications one cached, stateless entry point for repositories per entity type instead of per-request instantiation, with write()/afterCommit() replacing manual transaction+reload choreography. McpConfig.rolesClaimPath is removed - MCP now derives the claim path from OidcMiddleware.rolesClaimPath() so applications never duplicate the roles-claim config between OIDC and MCP. McpPackageScanner is rebuilt on the shared PackageScanner.discover(packageName) primitive, doing only McpTool/McpResource/McpPrompt classification itself. Co-Authored-By: Claude Sonnet 5 --- .../relism/flash/ext/data/DataExtension.java | 10 ++- .../dev/relism/flash/ext/data/core/Data.java | 46 ++++++++++ .../ext/data/core/RepositoryFactory.java | 9 ++ .../ext/data/hibernate/HibernateData.java | 25 ++++++ .../data/hibernate/HibernateTxManager.java | 6 +- .../dev/relism/flash/ext/mcp/McpConfig.java | 13 --- .../relism/flash/ext/mcp/McpExtension.java | 3 +- .../flash/ext/mcp/McpOidcIntegration.java | 5 +- .../flash/ext/mcp/McpPackageScanner.java | 89 +------------------ .../dev/relism/flash/ext/mcp/McpRegistry.java | 3 +- .../relism/flash/ext/oidc/OidcMiddleware.java | 3 + .../flash/extension/PackageScanner.java | 56 +++++++++++- 12 files changed, 161 insertions(+), 107 deletions(-) create mode 100644 flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Data.java create mode 100644 flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/RepositoryFactory.java create mode 100644 flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateData.java diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java index 63f6e72..017308b 100644 --- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java @@ -1,6 +1,7 @@ package dev.relism.flash.ext.data; import dev.relism.flash.ext.data.core.Tx; +import dev.relism.flash.ext.data.core.Data; import dev.relism.flash.ext.data.core.TxDefinition; import dev.relism.flash.ext.data.core.TxManager; import dev.relism.flash.ext.data.core.TransactionPropagation; @@ -19,16 +20,23 @@ public final class DataExtension implements FlashExtension { private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction"); private final TxManager txManager; private final Tx tx; + private final Data data; public DataExtension(TxManager txManager) { + this(txManager, null); + } + + public DataExtension(TxManager txManager, Data data) { this.txManager = Objects.requireNonNull(txManager); - this.tx = new Tx(txManager); + this.data = data; + this.tx = data != null ? data.tx() : new Tx(txManager); } @Override public void configure(FlashRegistrar app, FlashContext ctx) { ctx.provide(Tx.class, tx); ctx.provide(TxManager.class, txManager); + if (data != null) ctx.provide(Data.class, data); ctx.addAnnotationProcessor(handlerClass -> { Transactional ann = handlerClass.getAnnotation(Transactional.class); if (ann == null) { diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Data.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Data.java new file mode 100644 index 0000000..1f92447 --- /dev/null +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Data.java @@ -0,0 +1,46 @@ +package dev.relism.flash.ext.data.core; + +import java.util.Objects; +import java.io.Serializable; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Application-facing data gateway. Repositories are created once per entity type and are safe to + * share: transaction/session state stays in {@link Tx}, never in the repository instance. + */ +public final class Data { + private final Tx tx; + private final RepositoryFactory repositories; + private final ConcurrentHashMap, Repository> cache = new ConcurrentHashMap<>(); + + public Data(Tx tx, RepositoryFactory repositories) { + this.tx = Objects.requireNonNull(tx, "tx"); + this.repositories = Objects.requireNonNull(repositories, "repositories"); + } + + public Tx tx() { return tx; } + + @SuppressWarnings("unchecked") + public Repository repository(Class type) { + Objects.requireNonNull(type, "type"); + return (Repository) cache.computeIfAbsent(type, key -> repositories.create(tx, type)); + } + + public void read(Tx.TxRunnable work) { tx.run(tx.readOnly(), work); } + public T read(Tx.TxCallable work) { return tx.call(tx.readOnly(), work); } + public void write(Tx.TxRunnable work) { tx.run(work); } + public T write(Tx.TxCallable work) { return tx.call(work); } + /** Transitional low-level access for infrastructure that needs an explicit definition. */ + public void run(Tx.TxRunnable work) { tx.run(work); } + public T call(Tx.TxCallable work) { return tx.call(work); } + public T call(TxDefinition definition, Tx.TxCallable work) { return tx.call(definition, work); } + public TxDefinition readOnly() { return tx.readOnly(); } + + /** Registers work that runs only after the enclosing write transaction commits. */ + public void afterCommit(Runnable work) { + if (!tx.isActive()) throw new IllegalStateException("afterCommit requires an active transaction"); + ResourceRegistry.addSynchronization(new TxSynchronization() { + @Override public void afterCommit() { work.run(); } + }); + } +} diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/RepositoryFactory.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/RepositoryFactory.java new file mode 100644 index 0000000..b33f613 --- /dev/null +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/RepositoryFactory.java @@ -0,0 +1,9 @@ +package dev.relism.flash.ext.data.core; + +import java.io.Serializable; + +/** Creates the backend-specific, stateless repository for one entity type. */ +@FunctionalInterface +public interface RepositoryFactory { + Repository create(Tx tx, Class type); +} diff --git a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateData.java b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateData.java new file mode 100644 index 0000000..4851104 --- /dev/null +++ b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateData.java @@ -0,0 +1,25 @@ +package dev.relism.flash.ext.data.hibernate; + +import dev.relism.flash.ext.data.core.Data; +import dev.relism.flash.ext.data.core.Repository; +import dev.relism.flash.ext.data.core.RepositoryFactory; +import dev.relism.flash.ext.data.core.Tx; +import dev.relism.flash.ext.data.core.TxManager; +import java.io.Serializable; + +/** Hibernate-backed {@link Data} factory. */ +public final class HibernateData { + private HibernateData() {} + + private static final RepositoryFactory REPOSITORIES = new RepositoryFactory() { + @Override + public Repository create(Tx tx, Class type) { + return new HibernateRepository(tx, type) {}; + } + }; + + public static Data create(TxManager manager) { + Tx tx = new Tx(manager); + return new Data(tx, REPOSITORIES); + } +} diff --git a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java index c43ca2b..5ce0901 100644 --- a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java +++ b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java @@ -114,18 +114,20 @@ public class HibernateTxManager implements TxManager { cleanupIfIdle(); return; } + TxOutcome outcome; try { if (s.isRollbackOnly() && s.session().getTransaction().isActive()) { s.session().getTransaction().rollback(); - ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK); + outcome = TxOutcome.ROLLED_BACK; } else { s.session().getTransaction().commit(); - ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED); + outcome = TxOutcome.COMMITTED; } } finally { cleanupAndResume(s); cleanupIfIdle(); } + ResourceRegistry.fireSynchronizations(outcome); } @Override diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java index da6315f..a0538d7 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -28,7 +28,6 @@ public final class McpConfig { private final String authorizationServerIssuer; private final List allowedOrigins; private final List scopesSupported; - private final String rolesClaimPath; private McpConfig(Builder b) { this.name = b.name; @@ -41,7 +40,6 @@ public final class McpConfig { this.authorizationServerIssuer = b.authorizationServerIssuer; this.allowedOrigins = List.copyOf(b.allowedOrigins); this.scopesSupported = List.copyOf(b.scopesSupported); - this.rolesClaimPath = b.rolesClaimPath; } String name() { return name; } @@ -54,7 +52,6 @@ public final class McpConfig { String authorizationServerIssuer() { return authorizationServerIssuer; } List allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } - String rolesClaimPath() { return rolesClaimPath; } public static Builder builder(String name) { return new Builder(name); } @@ -69,7 +66,6 @@ public final class McpConfig { private String authorizationServerIssuer; private final List allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); - private String rolesClaimPath = "realm_access.roles"; private Builder(String name) { if (name == null || name.isBlank()) @@ -132,15 +128,6 @@ public final class McpConfig { */ public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } - /** - * Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} — - * same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code - * OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no - * way to auto-derive it from the installed {@code OidcExtension} (see {@code - * docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why). - */ - public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; return this; } - public McpConfig build() { if (toolsPackage == null || toolsPackage.isBlank()) throw new IllegalStateException( diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index 9a3dd3e..baa0dff 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -61,7 +61,8 @@ public class McpExtension implements FlashExtension { // @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration // (see McpOidcIntegration#compileToolPolicy) — must run first, not after. McpOidcIntegration.Resolved secured = resolveSecurity(ctx); - McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, config.rolesClaimPath()); + McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, + secured == null ? null : secured.rolesClaimPath()); McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); List chain = new ArrayList<>(3); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java index a12a3b4..80eae76 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java @@ -46,7 +46,8 @@ final class McpOidcIntegration { private McpOidcIntegration() {} /** Everything {@link McpExtension} needs once oidc security is resolved. */ - record Resolved(Middleware security, String issuer, Function resourceIdentifier) {} + record Resolved(Middleware security, String issuer, String rolesClaimPath, + Function resourceIdentifier) {} /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */ static Resolved resolve(FlashContext ctx, McpConfig config) { @@ -63,7 +64,7 @@ final class McpOidcIntegration { Middleware protect = oidcMw.protect(resourceMetadataPath); Middleware secured = Middleware.of(protect, audienceGuard(resourceId)); - return new Resolved(secured, issuer, resourceId); + return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId); } /** diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java index 5961321..28bc071 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java @@ -2,14 +2,10 @@ package dev.relism.flash.ext.mcp; import dev.relism.flash.exceptions.InitializationException; -import java.io.File; import java.lang.reflect.Modifier; -import java.net.URL; import java.util.ArrayList; -import java.util.Enumeration; import java.util.List; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; +import dev.relism.flash.extension.PackageScanner; /** * Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds @@ -37,40 +33,11 @@ final class McpPackageScanner { if (packageName == null || packageName.isBlank()) throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name"); - String resourcePath = packageName.replace('.', '/'); - ClassLoader cl = Thread.currentThread().getContextClassLoader(); List> tools = new ArrayList<>(); List> resources = new ArrayList<>(); List> prompts = new ArrayList<>(); List errors = new ArrayList<>(); - boolean packageFound = false; - - try { - Enumeration urls = cl.getResources(resourcePath); - while (urls.hasMoreElements()) { - packageFound = true; - URL url = urls.nextElement(); - String protocol = url.getProtocol(); - if ("file".equals(protocol)) { - scanDirectory(new File(url.toURI()), packageName, cl, tools, resources, prompts, errors); - } else if ("jar".equals(protocol)) { - String jarPath = url.getPath(); - String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!')); - try (JarFile jar = new JarFile(filePart)) { - scanJar(jar, resourcePath, cl, tools, resources, prompts, errors); - } - } - } - } catch (InitializationException e) { - throw e; - } catch (Exception e) { - throw new InitializationException("Failed to scan MCP package: " + packageName, e); - } - - if (!packageFound) - throw new InitializationException( - "McpConfig.toolsPackage(\"" + packageName + "\") — package not found on classpath. " + - "Verify the package name and ensure the module is on the classpath."); + for (Class cls : PackageScanner.discover(packageName)) tryLoad(cls, tools, resources, prompts, errors); if (!errors.isEmpty()) throw new InitializationException( @@ -86,55 +53,13 @@ final class McpPackageScanner { return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts)); } - private static void scanDirectory(File dir, String packageName, ClassLoader cl, - List> tools, - List> resources, - List> prompts, - List errors) { - File[] files = dir.listFiles(); - if (files == null) return; - for (File file : files) { - if (file.isDirectory()) { - scanDirectory(file, packageName + '.' + file.getName(), cl, tools, resources, prompts, errors); - } else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) { - String className = packageName + '.' + file.getName().replace(".class", ""); - tryLoad(className, cl, tools, resources, prompts, errors); - } - } - } - - private static void scanJar(JarFile jar, String resourcePath, ClassLoader cl, - List> tools, - List> resources, - List> prompts, - List errors) { - String prefix = resourcePath + "/"; - Enumeration entries = jar.entries(); - while (entries.hasMoreElements()) { - String name = entries.nextElement().getName(); - if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) { - String className = name.replace('/', '.').replace(".class", ""); - tryLoad(className, cl, tools, resources, prompts, errors); - } - } - } - - private static boolean isAnonymous(String fileName) { - int dollar = fileName.lastIndexOf('$'); - if (dollar < 0) return false; - int next = dollar + 1; - while (next < fileName.length() && fileName.charAt(next) == '$') next++; - return next < fileName.length() && Character.isDigit(fileName.charAt(next)); - } - @SuppressWarnings("unchecked") - private static void tryLoad(String className, ClassLoader cl, + private static void tryLoad(Class cls, List> tools, List> resources, List> prompts, List errors) { try { - Class cls = cl.loadClass(className); if (Modifier.isAbstract(cls.getModifiers())) return; if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) { @@ -151,13 +76,7 @@ final class McpPackageScanner { assertNoArgConstructor(cls, errors); prompts.add((Class) cls); } - } catch (ClassNotFoundException e) { - errors.add(className + " — class not found: " + e.getMessage()); - } catch (NoClassDefFoundError e) { - errors.add(className + " — missing dependency: " + e.getMessage()); - } catch (LinkageError e) { - errors.add(className + " — linkage error: " + e.getMessage()); - } + } catch (LinkageError e) { errors.add(cls.getName() + " — linkage error: " + e.getMessage()); } } private static void assertNoArgConstructor(Class cls, List errors) { diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java index dc653fe..4615862 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java @@ -45,8 +45,7 @@ final class McpRegistry { * {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or * rejected at boot as a misconfiguration; see * {@link McpOidcIntegration#compileToolPolicy}. - * @param rolesClaimPath claim path forwarded to {@code @RolesAllowed} checks; see - * {@link McpConfig#rolesClaimPath(String)}. + * @param rolesClaimPath claim path resolved from the installed OIDC extension. */ static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) { McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java index 6afe313..7da1b7f 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java @@ -60,6 +60,9 @@ public class OidcMiddleware { // -- Public API ----------------------------------------------------------- + /** The single configured claim path used by every transport for role checks. */ + public String rolesClaimPath() { return config.rolesClaimPath(); } + /** * Validates the bearer token or session cookie. Browser clients are redirected * to the login page on failure; API clients receive 401. diff --git a/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java b/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java index 98c4ef5..ce378d4 100644 --- a/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java +++ b/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java @@ -25,7 +25,7 @@ import java.util.jar.JarFile; * class cannot be loaded, an {@link InitializationException} is thrown immediately. * A clear crash at boot is always preferable to a server that starts "empty". */ -final class PackageScanner { +public final class PackageScanner { private PackageScanner() {} @@ -41,6 +41,60 @@ final class PackageScanner { return scan(packageName).httpHandlers(); } + /** + * Shared deterministic classpath discovery primitive for Flash extensions. It owns the + * directory/JAR traversal, anonymous-class exclusion and load diagnostics; extensions only + * classify the returned classes according to their own component contract. + */ + public static List> discover(String packageName) { + if (packageName == null || packageName.isBlank()) + throw new InitializationException("scan() called with null or blank package name"); + String resourcePath = packageName.replace('.', '/'); + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + List> result = new ArrayList<>(); + List errors = new ArrayList<>(); + boolean found = false; + try { + Enumeration resources = cl.getResources(resourcePath); + while (resources.hasMoreElements()) { + found = true; + URL url = resources.nextElement(); + if ("file".equals(url.getProtocol())) discoverDirectory(new File(url.toURI()), packageName, cl, result, errors); + else if ("jar".equals(url.getProtocol())) { + String path = url.getPath(); + try (JarFile jar = new JarFile(path.substring(path.indexOf("file:") + 5, path.indexOf('!')))) { + discoverJar(jar, resourcePath, cl, result, errors); + } + } + } + } catch (Exception e) { throw new InitializationException("Failed to scan package: " + packageName, e); } + if (!found) throw new InitializationException("scan(\"" + packageName + "\") — package not found on classpath"); + if (!errors.isEmpty()) throw new InitializationException("scan(\"" + packageName + "\") — failed to load classes:\n • " + String.join("\n • ", errors)); + return List.copyOf(result); + } + + private static void discoverDirectory(File dir, String packageName, ClassLoader cl, List> result, List errors) { + File[] files = dir.listFiles(); if (files == null) return; + for (File file : files) { + if (file.isDirectory()) discoverDirectory(file, packageName + '.' + file.getName(), cl, result, errors); + else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) discoverClass(packageName + '.' + file.getName().replace(".class", ""), cl, result, errors); + } + } + + private static void discoverJar(JarFile jar, String resourcePath, ClassLoader cl, List> result, List errors) { + String prefix = resourcePath + "/"; + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) discoverClass(name.replace('/', '.').replace(".class", ""), cl, result, errors); + } + } + + private static void discoverClass(String name, ClassLoader cl, List> result, List errors) { + try { result.add(cl.loadClass(name)); } + catch (ClassNotFoundException | LinkageError e) { errors.add(name + " — " + e.getMessage()); } + } + static ScanResult scan(String packageName) { if (packageName == null || packageName.isBlank()) throw new InitializationException("scan() called with null or blank package name"); -- 2.54.0