WorkflowModulePropertiesEnvironmentPostProcessor.java
package io.vanillabp.integration.workflowmodule;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.boot.env.PropertiesPropertySourceLoader;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternUtils;
import org.springframework.lang.Nullable;
/**
* An {@link EnvironmentPostProcessor} that loads workflow module-specific
* YAML and properties files into the Spring {@link ConfigurableEnvironment}.
*
* <p>For each workflow module discovered via {@code META-INF/workflow-module}
* classpath resources, the following files are loaded (if present):
* <ul>
* <li>{@code {moduleId}.yaml} / {@code {moduleId}.yml}</li>
* <li>{@code {moduleId}.properties}</li>
* <li>{@code {moduleId}-{profile}.yaml} / {@code {moduleId}-{profile}.yml} (for each active profile)</li>
* <li>{@code {moduleId}-{profile}.properties} (for each active profile)</li>
* </ul>
*
* <p>Files are searched in the following classpath locations (analogous to
* Spring Boot's own {@code application.yaml} resolution which covers both
* root and {@code config/}):
* <ol>
* <li>{@code {filename}} — classpath root</li>
* <li>{@code config/{filename}} — config directory</li>
* <li>{@code {moduleId}/{filename}} — workflow module subdirectory</li>
* <li>{@code {moduleId}/config/{filename}} — config inside workflow module subdirectory</li>
* </ol>
* This allows workflow modules packaged as separate Maven/Gradle modules to
* place their configuration files in a module-specific subdirectory, avoiding
* classpath conflicts with other modules.
*
* <p>Workflow module property sources are inserted with higher priority than
* {@code application.yaml}/{@code application.properties}, matching the
* behavior of the Quarkus implementation. Profile-specific variants have
* higher priority than base variants. YAML has higher priority than
* {@code .properties} for the same base name.
*/
public class WorkflowModulePropertiesEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
private static final String CLASSPATH_PATTERN = "classpath*:%s";
private final PropertiesPropertySourceLoader propertiesLoader = new PropertiesPropertySourceLoader();
private final YamlPropertySourceLoader yamlLoader = new YamlPropertySourceLoader();
/**
* Run after {@code ConfigDataEnvironmentPostProcessor} so that
* {@code application.yaml} is already loaded and active profiles
* are known.
*/
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 11;
}
@Override
public void postProcessEnvironment(
final ConfigurableEnvironment environment,
final SpringApplication application) {
final var workflowModuleIds = WorkflowModuleAutoConfiguration
.determineWorkflowModules(null)
.getWorkflowModules()
.stream()
.map(WorkflowModule::getId)
.toList();
if (workflowModuleIds.isEmpty()) {
return;
}
final var propertySources = environment.getPropertySources();
final var activeProfiles = environment.getActiveProfiles();
// Find the insertion point: before "Config resource" sources
// (which represent application.yaml/application.properties).
// We insert workflow module sources so they have higher priority
// than application config but lower priority than system properties
// and environment variables.
final var insertBeforeName = findApplicationConfigSourceName(propertySources)
.orElse(null);
final var resolver = ResourcePatternUtils
.getResourcePatternResolver(null);
for (final var moduleId : workflowModuleIds) {
// Load base files (lower priority)
loadAndAdd(resolver, propertySources, insertBeforeName, moduleId, null);
// Load profile-specific files (higher priority — added before base)
for (final var profile : activeProfiles) {
loadAndAdd(resolver, propertySources, insertBeforeName, moduleId, profile);
}
}
}
/**
* Load property sources for a given module ID and optional profile,
* then insert them into the environment.
*/
private void loadAndAdd(
final ResourcePatternResolver resolver,
final MutablePropertySources propertySources,
@Nullable final String insertBeforeName,
final String moduleId,
@Nullable final String profile) {
final var baseName = profile != null
? "%s-%s".formatted(moduleId, profile)
: moduleId;
// Load .yaml / .yml files (higher priority, loaded first so they end up
// further from insertBefore in the property source list)
loadResources(resolver, moduleId, baseName, yamlLoader)
.forEach(ps -> addPropertySource(propertySources, insertBeforeName, ps));
// Load .properties files (lower priority than YAML)
loadResources(resolver, moduleId, baseName, propertiesLoader)
.forEach(ps -> addPropertySource(propertySources, insertBeforeName, ps));
}
/**
* Load all property sources for files matching the given base name
* using the given loader. Files are searched in multiple classpath
* locations: root, config/, {moduleId}/, and {moduleId}/config/.
*/
private List<PropertySource<?>> loadResources(
final ResourcePatternResolver resolver,
final String moduleId,
final String baseName,
final org.springframework.boot.env.PropertySourceLoader loader) {
// Search locations analogous to Spring Boot's application.yaml resolution,
// plus workflow module subdirectory variants
final var searchPrefixes = List.of(
"",
"config/",
"%s/".formatted(moduleId),
"%s/config/".formatted(moduleId));
return Arrays.stream(loader.getFileExtensions())
.flatMap(extension -> {
final var filename = "%s.%s".formatted(baseName, extension);
return searchPrefixes.stream()
.flatMap(prefix -> {
final var location = "%s%s".formatted(prefix, filename);
try {
final var resources = resolver.getResources(
CLASSPATH_PATTERN.formatted(location));
return Arrays.stream(resources)
.filter(Resource::exists)
.flatMap(resource -> {
try {
return loader
.load("workflowmodule:%s".formatted(location), resource)
.stream();
} catch (IOException e) {
return java.util.stream.Stream.empty();
}
});
} catch (IOException e) {
return java.util.stream.Stream.empty();
}
});
})
.toList();
}
/**
* Add a property source before the application config sources,
* or at the end if no application config source was found.
*/
private void addPropertySource(
final MutablePropertySources propertySources,
@Nullable final String insertBeforeName,
final PropertySource<?> propertySource) {
if (insertBeforeName != null) {
propertySources.addBefore(insertBeforeName, propertySource);
} else {
propertySources.addLast(propertySource);
}
}
/**
* Find the name of the first application config property source.
* In Spring Boot 3.x, these are typically named starting with
* "Config resource".
*/
private Optional<String> findApplicationConfigSourceName(
final MutablePropertySources propertySources) {
for (final var ps : propertySources) {
if (ps.getName().startsWith("Config resource")) {
return Optional.of(ps.getName());
}
}
return Optional.empty();
}
}