RainbowGumServiceProvider.java

package io.jstach.rainbowgum.spi;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.WeakHashMap;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Stream;

import org.jspecify.annotations.Nullable;

import io.avaje.spi.Service;
import io.jstach.rainbowgum.LogAlerts;
import io.jstach.rainbowgum.LogConfig;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.RainbowGum;
import io.jstach.rainbowgum.ServiceRegistry;

/**
 * RainbowGum SPI. The Rainbow Gum SPI uses the {@link ServiceLoader} with the
 * registration of <strong>this class</strong> and <em>NOT the <code>non-sealed</code>
 * subclasses!</em> Read the {@link ServiceLoader} doc to understand how to register a
 * service loader class. A common option is to use an annotation processor to generate the
 * <code>META-INF/services</code> registration. There are several libraries that can do
 * this:
 *
 * <ul>
 * <li><a href="https://avaje.io/spi/">Avaje SPI's</a> <code>@ServiceProvider</code>
 * <li><a href="https://github.com/kohsuke/metainf-services">metainf-services</a>
 * <code>@ServiceProvider(RainbowGumServiceProvider.class)</code>
 * <li><a href="https://github.com/jstachio/pistachio#serviceloader-helper">Pistachio
 * SVC's</a> <code>@ServiceProvider(RainbowGumServiceProvider.class)</code>
 * </ul>
 *
 * However, if your application is fully modularized (has a <code>module-info.java</code>)
 * you will also need to use the module-info syntax:
 *
 * {@snippet :
 *
 * provides io.jstach.rainbowgum.spi.RainbowGumServiceProvider with com.mycompany.SomeService;
 *
 * }
 *
 * <strong>Initialization Order:</strong>
 *
 * <ol>
 * <li>{@link PropertiesProvider}
 * <li>{@link Configurator}
 * <li>{@link RainbowGumProvider}
 * </ol>
 */
@Service
@SuppressWarnings("InvalidInlineTag")
public sealed interface RainbowGumServiceProvider {

	/**
	 * Provides properties and or register services.
	 */
	public non-sealed interface PropertiesProvider extends RainbowGumServiceProvider {

		/**
		 * Provides properties and or register services. <em>If just registering services
		 * and not providing properies an empty list can be returned. </em>
		 * @param registry registry is usually empty here.
		 * @param alerts records alerts into the configured {@link LogConfig#alerts()}.
		 * Alerts recorded before it exists are replayed into it once it does, and this
		 * instance keeps forwarding afterwards, so it may be retained and used after the
		 * provider returns. Listener registration is not supported.
		 * @return list of properties.
		 */
		List<LogProperties> provideProperties(ServiceRegistry registry, LogAlerts alerts);

	}

	/**
	 * Providers sometimes need multiple passes to support dependencies without using true
	 * dependency injection.
	 */
	public enum Pass {

		/**
		 * First pass.
		 */
		FIRST,
		/**
		 * Second or greater pass but not the last pass.
		 */
		MIDDLE,
		/**
		 * The last pass to return true otherwise Rainbow Gum will fail to load. This
		 * state is useful if a provider can provide a better error by throwing an
		 * exception.
		 */
		LAST;

		static Pass of(int pass, int total) {
			if (pass >= (total - 1)) {
				return LAST;
			}
			if (pass == 0) {
				return FIRST;
			}
			return MIDDLE;
		}

	}

	/**
	 * The default amount of passes made to resolve registrators or configurators.
	 */
	public static int PASSES = 4;

	/**
	 * Called after {@link LogConfig} has been loaded to do various custom initialization
	 * like registering {@link io.jstach.rainbowgum.LogOutput.OutputProvider}s.
	 * <p>
	 * The configurator has the option to return <code>false</code> to indicate a retry
	 * needs to be made. This is a simple way to handle dependency needs of one
	 * configurator needing another to run prior.
	 *
	 * @see LogConfig#outputRegistry()
	 * @see ServiceRegistry
	 */
	@FunctionalInterface // TODO remove and make the builder take a function
	public non-sealed interface Configurator extends RainbowGumServiceProvider {

		/**
		 * Do ad-hoc initialization before RainbowGum is fully loaded.
		 * @param config config.
		 * @param pass info on what pass this is.
		 * @return <code>true</code> if all dependencies were found.
		 */
		boolean configure(LogConfig config, Pass pass);

		/**
		 * The default amount of passes made to resolve configurators.
		 */
		public static int CONFIGURATOR_PASSES = PASSES;

		/**
		 * Runs configurators. If a configurator returns false then it will be retried.
		 * The default of {@value #CONFIGURATOR_PASSES} passes will be tried.
		 * @param configurators stream of configurators.
		 * @param config config to use for registration.
		 * @throws IllegalStateException if there are configurators still returning
		 * <code>false</code>.
		 */
		public static void runConfigurators(Stream<? extends RainbowGumServiceProvider.Configurator> configurators,
				LogConfig config) {
			runConfigurators(configurators, config, CONFIGURATOR_PASSES);
		}

		/**
		 * Runs configurators. If a configurator returns false then it will be retried.
		 * @param configurators stream of configurators.
		 * @param config config to use for registration.
		 * @param passes number of times to retry configurator if it returns
		 * @throws IllegalStateException if there are configurators still returning
		 * <code>false</code>.
		 */
		private static void runConfigurators(Stream<? extends RainbowGumServiceProvider.Configurator> configurators,
				LogConfig config, int passes) {
			List<Configurator> unresolved = new ArrayList<>(
					configurators.sorted(Comparator.comparingInt(Configurator::priority).reversed()).toList());
			for (int i = 0; i < passes; i++) {
				var it = unresolved.iterator();
				while (it.hasNext()) {
					var c = it.next();
					if (c.configure(config, Pass.of(i, passes))) {
						it.remove();
						if (c instanceof AutoCloseable ac) {
							config.serviceRegistry().onClose(ac);
						}
					}
				}
				if (unresolved.isEmpty()) {
					break;
				}
			}
			if (!unresolved.isEmpty()) {
				throw new IllegalStateException("Configurators could not find dependencies (returned false) after "
						+ passes + " passes. configurators = " + unresolved);
			}

		}

		/**
		 * Priority of configurators.
		 * @return priority order where higher number means it will be tried earlier.
		 */
		default int priority() {
			return 0;
		}

	}

	/**
	 * Implement to create a custom RainbowGum. This is the preferred way to customize
	 * RainbowGum publishers, appenders, and outputs.
	 */
	public non-sealed interface RainbowGumProvider extends RainbowGumServiceProvider {

		/**
		 * Optionally provides a rainbow gum based on config.
		 * @param config config loaded.
		 * @return a Rainbow Gum or not.
		 */
		Optional<RainbowGum> provide(LogConfig config);

		/**
		 * If there are multiple rainbow gum providers found the higher priority ones are
		 * tried first ({@link #provide(LogConfig)}). The default is {@code 0}.
		 * <p>
		 * This feature allows custom rainbow gums for different environments like a
		 * testing version that lives in its own jar that is in Maven scope test. If its
		 * priority is higher and {@link #provide(LogConfig)} returns a non-empty optional
		 * then it will be used instead of than the production Rainbow Gum.
		 * @return priority order where higher number means it will be tried earlier.
		 */
		default int priority() {
			return 0;
		}

	}

	/**
	 * This is a marker interface that indicates that a facade implementation will do the
	 * eager loading of RainbowGum. This is used to determine if another facade that has
	 * the option to queue should queue and not load rainbowgum if any implementations are
	 * found of this interface.
	 *
	 * @apiNote Only facade implementers need concern themselves with this interface and
	 * for others it is an internal detail.
	 * @see #exists()
	 */
	public non-sealed interface RainbowGumEagerLoad extends RainbowGumServiceProvider {

		/**
		 * Without loading service providers will carefully check for the existence of any
		 * {@link RainbowGumEagerLoad} implementations.
		 * @return true if something is marked as eager.
		 */
		public static boolean exists() {
			var sl = ServiceLoaderCache.SERVICE_LOADER_CACHE.findServiceLoader();
			return sl.stream()
				.filter(p -> RainbowGumEagerLoad.class.isAssignableFrom(p.type()))
				.findFirst()
				.isPresent();
		}

	}

	/**
	 * Finds service providers based on type.
	 * @param <T> provider interface type.
	 * @param loader service loader to use.
	 * @param providerType provider class.
	 * @return stream containing only provider of the given type.
	 */
	public static <T extends RainbowGumServiceProvider> Stream<T> findProviders(
			ServiceLoader<RainbowGumServiceProvider> loader, Class<T> providerType) {
		return loader.stream().flatMap(p -> {
			if (providerType.isAssignableFrom(p.type())) {
				var s = providerType.cast(p.get());
				return Stream.of(s);
			}
			return Stream.empty();
		});
	}

	/**
	 * Creates config from service loader.
	 * @param loader service loader.
	 * @return config.
	 */
	public static LogConfig provideConfig(ServiceLoader<RainbowGumServiceProvider> loader) {
		return LogConfig.builder().serviceLoader(loader).build();
	}

	/**
	 * Will load all RainbowGum SPI to create a RainbowGum powered from the ServiceLoader.
	 * @param loader service loader to use.
	 * @return Rainbow Gum.
	 */
	public static RainbowGum provide(ServiceLoader<RainbowGumServiceProvider> loader) {
		var config = provideConfig(loader);
		@Nullable RainbowGum gum = findProviders(loader, RainbowGumProvider.class)
			.sorted(Comparator.comparingInt(RainbowGumProvider::priority).reversed())
			.flatMap(s -> s.provide(config).stream())
			.findFirst()
			.orElse(null);

		if (gum == null) {
			gum = RainbowGum.builder(config).build();
		}
		return gum;
	}

	/**
	 * Will load all RainbowGum SPI to create a RainbowGum powered from the ServiceLoader
	 * using the current thread context class loader if avaiable otherwise the system
	 * classloader.
	 * @return Rainbow Gum.
	 */
	public static RainbowGum provide() {
		// TODO perhaps we should use RainbowGum.class classloader
		return provide(cachedServiceLoader(Thread.currentThread().getContextClassLoader()));
	}

	private static ServiceLoader<RainbowGumServiceProvider> cachedServiceLoader(@Nullable ClassLoader classLoader) {
		if (classLoader == null) {
			return ServiceLoaderCache.SERVICE_LOADER_CACHE.findServiceLoader();
		}
		return ServiceLoaderCache.SERVICE_LOADER_CACHE.serviceLoader(classLoader);
	}

}

@SuppressWarnings("ImmutableEnumChecker")
enum ServiceLoaderCache {

	SERVICE_LOADER_CACHE;

	private final Map<ClassLoader, ServiceLoader<RainbowGumServiceProvider>> serviceLoaderCache = new WeakHashMap<>();

	private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

	static ClassLoader defaultClassLoader() {
		// TODO perhaps we should use RainbowGum.class classloader
		return ClassLoader.getSystemClassLoader();
	}

	ServiceLoader<RainbowGumServiceProvider> serviceLoader(ClassLoader classLoader) {
		lock.writeLock().lock();
		try {
			var sl = serviceLoaderCache.computeIfAbsent(classLoader, cl -> {
				return ServiceLoader.load(RainbowGumServiceProvider.class, classLoader);
			});
			return Objects.requireNonNull(sl);
		}
		finally {
			lock.writeLock().unlock();
		}
	}

	private @Nullable ServiceLoader<RainbowGumServiceProvider> _findServiceLoader(
			@Nullable ClassLoader[] classLoaders) {
		for (var cl : classLoaders) {
			if (cl != null) {
				var sl = serviceLoaderCache.get(cl);
				if (sl != null) {
					return sl;
				}
			}
		}
		return null;
	}

	ServiceLoader<RainbowGumServiceProvider> findServiceLoader() {
		@Nullable ServiceLoader<RainbowGumServiceProvider> loader = null;
		@Nullable ClassLoader[] classLoaders = new @Nullable ClassLoader[] { Thread.currentThread().getContextClassLoader(),
				ServiceLoaderCache.class.getClassLoader(), defaultClassLoader() };
		lock.readLock().lock();
		try {
			loader = _findServiceLoader(classLoaders);
		}
		finally {
			lock.readLock().unlock();
		}
		if (loader != null) {
			return loader;
		}
		return serviceLoader(defaultClassLoader());
	}

}