diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java index 135416946b..5f7256df5f 100644 --- a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientProperties.java @@ -16,8 +16,10 @@ import io.dapr.spring.data.DaprKeyValueAdapterResolver; import org.springframework.boot.context.properties.ConfigurationProperties; -@ConfigurationProperties(prefix = "dapr.client") +@ConfigurationProperties(prefix = DaprClientProperties.PROPERTY_PREFIX) public class DaprClientProperties { + public static final String PROPERTY_PREFIX = "dapr.client"; + private String httpEndpoint; private String grpcEndpoint; private Integer httpPort; diff --git a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml index 623040b378..3a2f73ed6f 100644 --- a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml +++ b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml @@ -45,6 +45,11 @@ <artifactId>dapr-spring-workflows</artifactId> <version>${project.parent.version}</version> </dependency> + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-cloudconfig</artifactId> + <version>${project.parent.version}</version> + </dependency> </dependencies> <build> diff --git a/dapr-spring/dapr-spring-cloudconfig/pom.xml b/dapr-spring/dapr-spring-cloudconfig/pom.xml new file mode 100644 index 0000000000..c77a803032 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/pom.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-parent</artifactId> + <version>0.15.0-SNAPSHOT</version> + </parent> + + <artifactId>dapr-spring-cloudconfig</artifactId> + <name>dapr-spring-cloudconfig</name> + <description>Dapr Spring Cloud Config</description> + <packaging>jar</packaging> + + <dependencies> + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-boot-autoconfigure</artifactId> + <version>${project.parent.version}</version> + <scope>compile</scope> + </dependency> + + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-data</artifactId> + <version>${project.parent.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-messaging</artifactId> + <version>${project.parent.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-workflows</artifactId> + <version>${project.parent.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>com.github.spotbugs</groupId> + <artifactId>spotbugs-maven-plugin</artifactId> + <version>4.8.2.0</version> + <configuration> + <excludeFilterFile>./spotbugs-exclude.xml</excludeFilterFile> + <failOnError>${spotbugs.fail}</failOnError> + <xmlOutput>true</xmlOutput> + </configuration> + <executions> + <execution> + <id>validate</id> + <phase>validate</phase> + <goals> + <goal>check</goal> + </goals> + </execution> + </executions> + </plugin> + </plugins> + </build> + +</project> \ No newline at end of file diff --git a/dapr-spring/dapr-spring-cloudconfig/spotbugs-exclude.xml b/dapr-spring/dapr-spring-cloudconfig/spotbugs-exclude.xml new file mode 100644 index 0000000000..a3108aba0c --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/spotbugs-exclude.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<FindBugsFilter> + <Match> + <Package name="~io\.dapr\.spring.*"/> + <Bug pattern="EI_EXPOSE_REP"/> + </Match> + + <Match> + <Package name="~io\.dapr\.spring.*"/> + <Bug pattern="EI_EXPOSE_REP2"/> + </Match> + <Match> + <Class name="~io.dapr.spring.boot.cloudconfig.configdata.*"/> + <Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/> + </Match> + <Match> + <Class name="~io.dapr.spring.boot.cloudconfig.*"/> + <Bug pattern="MS_EXPOSE_REP"/> + </Match> +</FindBugsFilter> diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/autoconfigure/DaprCloudConfigAutoConfiguration.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/autoconfigure/DaprCloudConfigAutoConfiguration.java new file mode 100644 index 0000000000..f3283728bc --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/autoconfigure/DaprCloudConfigAutoConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.autoconfigure; + +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(DaprCloudConfigProperties.class) +@ConditionalOnProperty(name = DaprCloudConfigProperties.PROPERTY_PREFIX + ".enabled", matchIfMissing = true) +@ConditionalOnClass(DaprClient.class) +public class DaprCloudConfigAutoConfiguration { + +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/CloudConfigPropertiesDaprConnectionDetails.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/CloudConfigPropertiesDaprConnectionDetails.java new file mode 100644 index 0000000000..8f56c383ba --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/CloudConfigPropertiesDaprConnectionDetails.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.config; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.autoconfigure.client.DaprConnectionDetails; + +class CloudConfigPropertiesDaprConnectionDetails implements DaprConnectionDetails { + + private final DaprClientProperties daprClientProperties; + + public CloudConfigPropertiesDaprConnectionDetails(DaprClientProperties daprClientProperties) { + this.daprClientProperties = daprClientProperties; + } + + @Override + public String httpEndpoint() { + return this.daprClientProperties.getHttpEndpoint(); + } + + @Override + public String grpcEndpoint() { + return this.daprClientProperties.getGrpcEndpoint(); + } + + @Override + public Integer httpPort() { + return this.daprClientProperties.getHttpPort(); + } + + @Override + public Integer grpcPort() { + return this.daprClientProperties.getGrpcPort(); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigClientManager.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigClientManager.java new file mode 100644 index 0000000000..3038a152ce --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigClientManager.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.config; + +import io.dapr.client.DaprClient; +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.DaprPreviewClient; +import io.dapr.config.Properties; +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.autoconfigure.client.DaprConnectionDetails; + +public class DaprCloudConfigClientManager { + + private static DaprClient daprClient; + private static DaprPreviewClient daprPreviewClient; + private final DaprCloudConfigProperties daprCloudConfigProperties; + private final DaprClientProperties daprClientConfig; + + /** + * Create a DaprCloudConfigClientManager to create Config-Specific Dapr Client. + * + * @param daprCloudConfigProperties Properties of Dapr Cloud Config + * @param daprClientConfig Properties of Dapr Client + */ + public DaprCloudConfigClientManager(DaprCloudConfigProperties daprCloudConfigProperties, + DaprClientProperties daprClientConfig) { + this.daprCloudConfigProperties = daprCloudConfigProperties; + this.daprClientConfig = daprClientConfig; + + DaprClientBuilder daprClientBuilder = createDaprClientBuilder( + createDaprConnectionDetails(daprClientConfig) + ); + + if (DaprCloudConfigClientManager.daprClient == null) { + DaprCloudConfigClientManager.daprClient = daprClientBuilder.build(); + } + + if (DaprCloudConfigClientManager.daprPreviewClient == null) { + DaprCloudConfigClientManager.daprPreviewClient = daprClientBuilder.buildPreviewClient(); + } + } + + public static DaprPreviewClient getDaprPreviewClient() { + return DaprCloudConfigClientManager.daprPreviewClient; + } + + public static DaprClient getDaprClient() { + return DaprCloudConfigClientManager.daprClient; + } + + private DaprConnectionDetails createDaprConnectionDetails(DaprClientProperties properties) { + return new CloudConfigPropertiesDaprConnectionDetails(properties); + } + + DaprClientBuilder createDaprClientBuilder(DaprConnectionDetails daprConnectionDetails) { + DaprClientBuilder builder = new DaprClientBuilder(); + if (daprConnectionDetails.httpEndpoint() != null) { + builder.withPropertyOverride(Properties.HTTP_ENDPOINT, daprConnectionDetails.httpEndpoint()); + } + if (daprConnectionDetails.grpcEndpoint() != null) { + builder.withPropertyOverride(Properties.GRPC_ENDPOINT, daprConnectionDetails.grpcEndpoint()); + } + if (daprConnectionDetails.httpPort() != null) { + builder.withPropertyOverride(Properties.HTTP_PORT, String.valueOf(daprConnectionDetails.httpPort())); + } + if (daprConnectionDetails.grpcPort() != null) { + builder.withPropertyOverride(Properties.GRPC_PORT, String.valueOf(daprConnectionDetails.grpcPort())); + } + return builder; + } + + public DaprCloudConfigProperties getDaprCloudConfigProperties() { + return daprCloudConfigProperties; + } + + public DaprClientProperties getDaprClientConfig() { + return daprClientConfig; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigProperties.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigProperties.java new file mode 100644 index 0000000000..7087f91254 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/config/DaprCloudConfigProperties.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The properties for creating dapr client. + */ +@ConfigurationProperties(DaprCloudConfigProperties.PROPERTY_PREFIX) +public class DaprCloudConfigProperties { + + public static final String PROPERTY_PREFIX = "dapr.cloudconfig"; + + /** + * whether enable cloud config. + */ + private Boolean enabled = true; + + /** + * whether enable dapr client wait for sidecar, if no response, will throw IOException. + */ + private Boolean waitSidecarEnabled = false; + + /** + * retries of dapr client wait for sidecar. + */ + private Integer waitSidecarRetries = 3; + + /** + * get config timeout (include wait for dapr sidecar). + */ + private Integer timeout = 2000; + + public Integer getTimeout() { + return timeout; + } + + public void setTimeout(Integer timeout) { + this.timeout = timeout; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public Boolean getWaitSidecarEnabled() { + return waitSidecarEnabled; + } + + public void setWaitSidecarEnabled(Boolean waitSidecarEnabled) { + this.waitSidecarEnabled = waitSidecarEnabled; + } + + public Integer getWaitSidecarRetries() { + return waitSidecarRetries; + } + + public void setWaitSidecarRetries(Integer waitSidecarRetries) { + this.waitSidecarRetries = waitSidecarRetries; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/DaprCloudConfigParserHandler.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/DaprCloudConfigParserHandler.java new file mode 100644 index 0000000000..fdb58fdeb1 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/DaprCloudConfigParserHandler.java @@ -0,0 +1,141 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata; + +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import io.dapr.spring.boot.cloudconfig.configdata.types.DocType; +import org.springframework.boot.env.PropertySourceLoader; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class DaprCloudConfigParserHandler { + + private static List<PropertySourceLoader> propertySourceLoaders; + + private DaprCloudConfigParserHandler() { + List<PropertySourceLoader> loaders = SpringFactoriesLoader + .loadFactories(PropertySourceLoader.class, getClass().getClassLoader()); + + //Range loaders (Yaml as the first) + int yamlIndex = -1; + for (int i = 0; i < loaders.size(); i++) { + if (loaders.get(i) instanceof YamlPropertySourceLoader) { + yamlIndex = i; + break; + } + } + + // found yaml loader then move to the front + if (yamlIndex != -1) { + PropertySourceLoader yamlSourceLoader = loaders.remove(yamlIndex); + loaders.add(0, yamlSourceLoader); + } + + propertySourceLoaders = loaders; + } + + public static DaprCloudConfigParserHandler getInstance() { + return ParserHandler.HANDLER; + } + + /** + * Parse Secret using PropertySourceLoaders. + * + * <p> + * if type = doc, will treat all values as a property source (both "properties" or "yaml" format supported) + * </p> + * + * <p> + * if type = value, will transform key and value to "key=value" format ("properties" format) + * </p> + * + * @param configName name of the config + * @param configValue value of the config + * @param type value type + * @return property source list + */ + public List<PropertySource<?>> parseDaprCloudConfigData( + String configName, + Map<String, String> configValue, + DaprCloudConfigType type + ) { + List<PropertySource<?>> result = new ArrayList<>(); + + Map<String, Resource> configResults = getConfigResult(configValue, type); + String extension = type instanceof DocType ? ((DocType) type).getDocExtension() : ".properties"; + + configResults.forEach((key, configResult) -> { + for (PropertySourceLoader propertySourceLoader : propertySourceLoaders) { + if (!canLoadFileExtension(propertySourceLoader, extension)) { + continue; + } + String fullConfigName = StringUtils.hasText(key) ? configName + "." + key : configName; + try { + result.addAll(propertySourceLoader.load(fullConfigName, configResult)); + } catch (IOException ignored) { + continue; + } + return; + } + }); + + return result; + } + + private Map<String, Resource> getConfigResult( + Map<String, String> configValue, + DaprCloudConfigType type + ) { + Map<String, Resource> result = new HashMap<>(); + if (type instanceof DocType) { + configValue.forEach((key, value) -> result.put(key, + new ByteArrayResource(value.getBytes(StandardCharsets.UTF_8)))); + } else { + List<String> configList = new ArrayList<>(); + configValue.forEach((key, value) -> configList.add(String.format("%s=%s", key, value))); + result.put("", new ByteArrayResource(String.join("\n", configList).getBytes(StandardCharsets.UTF_8))); + } + return result; + } + + /** + * check the current extension can be processed. + * @param loader the propertySourceLoader + * @param extension file extension + * @return if can match extension + */ + private boolean canLoadFileExtension(PropertySourceLoader loader, String extension) { + return Arrays.stream(loader.getFileExtensions()) + .anyMatch((fileExtension) -> StringUtils.endsWithIgnoreCase(extension, + fileExtension)); + } + + private static class ParserHandler { + + private static final DaprCloudConfigParserHandler HANDLER = new DaprCloudConfigParserHandler(); + + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLoader.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLoader.java new file mode 100644 index 0000000000..c97b6c8cd0 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLoader.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata.config; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.ConfigurationItem; +import io.dapr.client.domain.GetConfigurationRequest; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.DaprCloudConfigParserHandler; +import org.apache.commons.logging.Log; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoader; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.PropertySource; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_IMPORTS; +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_PROFILES; +import static org.springframework.boot.context.config.ConfigData.Option.PROFILE_SPECIFIC; + +public class DaprConfigurationConfigDataLoader implements ConfigDataLoader<DaprConfigurationConfigDataResource> { + + private final Log log; + + private DaprClient daprClient; + + private DaprCloudConfigProperties daprCloudConfigProperties; + + /** + * Create a Config Data Loader to load config from Dapr Configuration api. + * + * @param logFactory logFactory + * @param daprClient Dapr Client created + * @param daprCloudConfigProperties Dapr Cloud Config Properties + */ + public DaprConfigurationConfigDataLoader(DeferredLogFactory logFactory, DaprClient daprClient, + DaprCloudConfigProperties daprCloudConfigProperties) { + this.log = logFactory.getLog(getClass()); + this.daprClient = daprClient; + this.daprCloudConfigProperties = daprCloudConfigProperties; + } + + + /** + * Load {@link ConfigData} for the given resource. + * + * @param context the loader context + * @param resource the resource to load + * @return the loaded config data or {@code null} if the location should be skipped + * @throws IOException on IO error + * @throws ConfigDataResourceNotFoundException if the resource cannot be found + */ + @Override + public ConfigData load(ConfigDataLoaderContext context, DaprConfigurationConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + DaprCloudConfigClientManager daprClientSecretStoreConfigManager = + getBean(context, DaprCloudConfigClientManager.class); + + daprClient = DaprCloudConfigClientManager.getDaprClient(); + daprCloudConfigProperties = daprClientSecretStoreConfigManager.getDaprCloudConfigProperties(); + + if (!daprCloudConfigProperties.getEnabled()) { + return ConfigData.EMPTY; + } + + if (daprCloudConfigProperties.getWaitSidecarEnabled()) { + waitForSidecar(); + } + + return fetchConfig(resource); + } + + private void waitForSidecar() throws IOException { + try { + daprClient.waitForSidecar(daprCloudConfigProperties.getTimeout()) + .retry(daprCloudConfigProperties.getWaitSidecarRetries()) + .block(); + } catch (RuntimeException e) { + log.info(e.getMessage(), e); + throw new IOException("Failed to wait for sidecar", e); + } + } + + private ConfigData fetchConfig(DaprConfigurationConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + Mono<Map<String, ConfigurationItem>> secretMapMono = daprClient.getConfiguration(new GetConfigurationRequest( + resource.getStoreName(), + StringUtils.hasText(resource.getConfigName()) + ? List.of(resource.getConfigName()) + : null + )); + + try { + Map<String, ConfigurationItem> secretMap = + secretMapMono.block(Duration.ofMillis(daprCloudConfigProperties.getTimeout())); + + if (secretMap == null) { + log.info("Config not found"); + throw new ConfigDataResourceNotFoundException(resource); + } + + Map<String, String> configMap = new HashMap<>(); + secretMap.forEach((key, value) -> { + configMap.put(value.getKey(), value.getValue()); + }); + + List<PropertySource<?>> sourceList = + new ArrayList<>(DaprCloudConfigParserHandler.getInstance().parseDaprCloudConfigData( + resource.getStoreName(), + configMap, + resource.getType() + )); + + return new ConfigData(sourceList, IGNORE_IMPORTS, IGNORE_PROFILES, PROFILE_SPECIFIC); + } catch (RuntimeException e) { + log.info("Failed to get config from sidecar: " + e.getMessage(), e); + throw new IOException("Failed to get config from sidecar", e); + } + + } + + protected <T> T getBean(ConfigDataLoaderContext context, Class<T> type) { + if (context.getBootstrapContext().isRegistered(type)) { + return context.getBootstrapContext().get(type); + } + return null; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLocationResolver.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLocationResolver.java new file mode 100644 index 0000000000..a650064696 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataLocationResolver.java @@ -0,0 +1,187 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata.config; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.apache.commons.logging.Log; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.ConfigurableBootstrapContext; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolver; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.Ordered; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +public class DaprConfigurationConfigDataLocationResolver + implements ConfigDataLocationResolver<DaprConfigurationConfigDataResource>, Ordered { + + public static final String PREFIX = "dapr:config:"; + + private final Log log; + + public DaprConfigurationConfigDataLocationResolver(DeferredLogFactory logFactory) { + this.log = logFactory.getLog(getClass()); + } + + /** + * Returns if the specified location address contains dapr prefix. + * + * @param context the location resolver context + * @param location the location to check. + * @return if the location is supported by this resolver + */ + @Override + public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) { + log.debug(String.format("checking if %s suits for dapr config", location.toString())); + return location.hasPrefix(PREFIX); + } + + /** + * Resolve a {@link ConfigDataLocation} into one or more {@link ConfigDataResource} instances. + * + * @param context the location resolver context + * @param location the location that should be resolved + * @return a list of {@link ConfigDataResource resources} in ascending priority order. + * @throws ConfigDataLocationNotFoundException on a non-optional location that cannot be found + * @throws ConfigDataResourceNotFoundException if a resolved resource cannot be found + */ + @Override + public List<DaprConfigurationConfigDataResource> resolve(ConfigDataLocationResolverContext context, + ConfigDataLocation location) + throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException { + + DaprCloudConfigProperties daprSecretStoreConfig = loadProperties(context); + DaprClientProperties daprClientConfig = loadClientProperties(context); + + ConfigurableBootstrapContext bootstrapContext = context + .getBootstrapContext(); + + registerConfigManager(daprSecretStoreConfig, daprClientConfig, bootstrapContext); + + List<DaprConfigurationConfigDataResource> result = new ArrayList<>(); + + // To avoid UriComponentsBuilder to decode a wrong host. + String fullConfig = "config://" + location.getNonPrefixedValue(PREFIX); + + UriComponents configUri = UriComponentsBuilder.fromUriString(fullConfig).build(); + + String storeName = configUri.getHost(); + + String configPath = configUri.getPath(); + String configName = StringUtils.hasText(configPath) + ? StringUtils.trimLeadingCharacter(configPath, '/') + : null; + + MultiValueMap<String, String> configQuery = configUri.getQueryParams(); + DaprCloudConfigType configType = DaprCloudConfigType.fromString(configQuery.getFirst("type"), + configQuery.getFirst("doc-type")); + Boolean subscribe = StringUtils.hasText(configQuery.getFirst("subscribe")) + && Boolean.parseBoolean(configQuery.getFirst("subscribe")); + + + if (configName == null) { + log.debug("Dapr Cloud Config now gains store name: '" + storeName + "' configuration for config"); + result.add(new DaprConfigurationConfigDataResource(location.isOptional(), storeName, + null, configType, subscribe)); + + } else if (configName.contains("/")) { + throw new ConfigDataLocationNotFoundException(location); + + } else { + log.debug("Dapr Cloud Config now gains store name: '" + storeName + "' and config name: '" + + configName + "' configuration for config"); + result.add( + new DaprConfigurationConfigDataResource(location.isOptional(), storeName, configName, + configType, subscribe)); + + } + + return result; + } + + @Override + public int getOrder() { + return -1; + } + + private void registerConfigManager(DaprCloudConfigProperties properties, + DaprClientProperties clientConfig, + ConfigurableBootstrapContext bootstrapContext) { + synchronized (DaprCloudConfigClientManager.class) { + if (!bootstrapContext.isRegistered(DaprCloudConfigClientManager.class)) { + bootstrapContext.register(DaprCloudConfigClientManager.class, + BootstrapRegistry.InstanceSupplier + .of(new DaprCloudConfigClientManager(properties, clientConfig))); + } + } + } + + protected DaprCloudConfigProperties loadProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprCloudConfigProperties daprCloudConfigProperties; + if (context.getBootstrapContext().isRegistered(DaprCloudConfigProperties.class)) { + daprCloudConfigProperties = context.getBootstrapContext() + .get(DaprCloudConfigProperties.class); + } else { + daprCloudConfigProperties = binder + .bind(DaprCloudConfigProperties.PROPERTY_PREFIX, Bindable.of(DaprCloudConfigProperties.class), + bindHandler) + .orElseGet(DaprCloudConfigProperties::new); + } + + return daprCloudConfigProperties; + } + + protected DaprClientProperties loadClientProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprClientProperties daprClientConfig; + if (context.getBootstrapContext().isRegistered(DaprClientProperties.class)) { + daprClientConfig = context.getBootstrapContext() + .get(DaprClientProperties.class); + } else { + daprClientConfig = binder + .bind(DaprClientProperties.PROPERTY_PREFIX, Bindable.of(DaprClientProperties.class), + bindHandler) + .orElseGet(DaprClientProperties::new); + } + + return daprClientConfig; + } + + private BindHandler getBindHandler(ConfigDataLocationResolverContext context) { + return context.getBootstrapContext().getOrElse(BindHandler.class, null); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataResource.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataResource.java new file mode 100644 index 0000000000..5d94cb10b9 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/config/DaprConfigurationConfigDataResource.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata.config; + +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.lang.Nullable; + +public class DaprConfigurationConfigDataResource extends ConfigDataResource { + private final String storeName; + private final String configName; + private final DaprCloudConfigType type; + private final Boolean subscribe; + + /** + * Create a new non-optional {@link ConfigDataResource} instance. + * @param storeName store name + * @param configName config name + * @param type value type + * @param subscribe subscribe for update + */ + public DaprConfigurationConfigDataResource(String storeName, @Nullable String configName, + DaprCloudConfigType type, Boolean subscribe) { + this.storeName = storeName; + this.configName = configName; + this.type = type; + this.subscribe = subscribe; + } + + /** + * Create a new {@link ConfigDataResource} instance. + * @param optional if the resource is optional + * @param storeName store name + * @param configName config name + * @param type value type + * @param subscribe subscribe for update + */ + public DaprConfigurationConfigDataResource(boolean optional, String storeName, @Nullable String configName, + DaprCloudConfigType type, Boolean subscribe) { + super(optional); + this.storeName = storeName; + this.configName = configName; + this.type = type; + this.subscribe = subscribe; + } + + public String getStoreName() { + return storeName; + } + + public String getConfigName() { + return configName; + } + + public DaprCloudConfigType getType() { + return type; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLoader.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLoader.java new file mode 100644 index 0000000000..aaa094a74f --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLoader.java @@ -0,0 +1,190 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata.secret; + +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.DaprCloudConfigParserHandler; +import org.apache.commons.logging.Log; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoader; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.PropertySource; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_IMPORTS; +import static org.springframework.boot.context.config.ConfigData.Option.IGNORE_PROFILES; +import static org.springframework.boot.context.config.ConfigData.Option.PROFILE_SPECIFIC; + +public class DaprSecretStoreConfigDataLoader implements ConfigDataLoader<DaprSecretStoreConfigDataResource> { + + private final Log log; + + private DaprClient daprClient; + + private DaprCloudConfigProperties daprCloudConfigProperties; + + /** + * Create a Config Data Loader to load config from Dapr Secret Store api. + * + * @param logFactory logFactory + * @param daprClient Dapr Client created + * @param daprCloudConfigProperties Dapr Cloud Config Properties + */ + public DaprSecretStoreConfigDataLoader(DeferredLogFactory logFactory, DaprClient daprClient, + DaprCloudConfigProperties daprCloudConfigProperties) { + this.log = logFactory.getLog(getClass()); + this.daprClient = daprClient; + this.daprCloudConfigProperties = daprCloudConfigProperties; + } + + + /** + * Load {@link ConfigData} for the given resource. + * + * @param context the loader context + * @param resource the resource to load + * @return the loaded config data or {@code null} if the location should be skipped + * @throws IOException on IO error + * @throws ConfigDataResourceNotFoundException if the resource cannot be found + */ + @Override + public ConfigData load(ConfigDataLoaderContext context, DaprSecretStoreConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + DaprCloudConfigClientManager daprCloudConfigClientManager = + getBean(context, DaprCloudConfigClientManager.class); + + daprClient = DaprCloudConfigClientManager.getDaprClient(); + daprCloudConfigProperties = daprCloudConfigClientManager.getDaprCloudConfigProperties(); + + if (!daprCloudConfigProperties.getEnabled()) { + return ConfigData.EMPTY; + } + + if (daprCloudConfigProperties.getWaitSidecarEnabled()) { + waitForSidecar(); + } + + if (resource.getSecretName() == null) { + return fetchBulkSecret(resource); + } else { + return fetchSecret(resource); + } + } + + private void waitForSidecar() throws IOException { + try { + daprClient.waitForSidecar(daprCloudConfigProperties.getTimeout()) + .retry(daprCloudConfigProperties.getWaitSidecarRetries()) + .block(); + } catch (RuntimeException e) { + log.info("Failed to wait for sidecar: " + e.getMessage(), e); + throw new IOException("Failed to wait for sidecar", e); + } + } + + /** + * Get Bulk Secret from Store. + * @param resource Secret Data Resource to fetch + * @return config data + * @throws IOException for block returns exception + * @throws ConfigDataResourceNotFoundException for secret not found + */ + private ConfigData fetchBulkSecret(DaprSecretStoreConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + + Mono<Map<String, Map<String, String>>> secretMapMono = daprClient.getBulkSecret(resource.getStoreName()); + + try { + Map<String, Map<String, String>> secretMap = + secretMapMono.block(Duration.ofMillis(daprCloudConfigProperties.getTimeout())); + + if (secretMap == null) { + log.info("Secret not found"); + throw new ConfigDataResourceNotFoundException(resource); + } + + List<PropertySource<?>> sourceList = new ArrayList<>(); + + for (Map.Entry<String, Map<String, String>> entry : secretMap.entrySet()) { + sourceList.addAll(DaprCloudConfigParserHandler.getInstance().parseDaprCloudConfigData( + resource.getStoreName() + ":" + entry.getKey(), + entry.getValue(), + resource.getType() + )); + } + + log.debug(String.format("now gain %d data source in secret, storename = %s", + sourceList.size(), resource.getStoreName())); + return new ConfigData(sourceList, IGNORE_IMPORTS, IGNORE_PROFILES, PROFILE_SPECIFIC); + } catch (RuntimeException e) { + log.info("Failed to get secret from sidecar: " + e.getMessage(), e); + throw new IOException("Failed to get secret from sidecar", e); + } + } + + /** + * Get Secret from Store. + * @param resource Secret Data Resource to fetch + * @return config data + * @throws IOException for block returns exception + * @throws ConfigDataResourceNotFoundException for secret not found + */ + private ConfigData fetchSecret(DaprSecretStoreConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + Mono<Map<String, String>> secretMapMono = daprClient.getSecret(resource.getStoreName(), resource.getSecretName()); + + try { + Map<String, String> secretMap = secretMapMono.block(Duration.ofMillis(daprCloudConfigProperties.getTimeout())); + + if (secretMap == null) { + log.info("Secret not found"); + throw new ConfigDataResourceNotFoundException(resource); + } + + log.debug(String.format("now gain %d secretMap in secret, storename = %s, secretname = %s", + secretMap.size(), resource.getStoreName(), resource.getSecretName())); + + List<PropertySource<?>> sourceList = new ArrayList<>( + DaprCloudConfigParserHandler.getInstance().parseDaprCloudConfigData( + resource.getStoreName() + ":" + resource.getSecretName(), + secretMap, + resource.getType() + )); + + log.debug(String.format("now gain %d data source in secret, storename = %s, secretname = %s", + sourceList.size(), resource.getStoreName(), resource.getSecretName())); + return new ConfigData(sourceList, IGNORE_IMPORTS, IGNORE_PROFILES, PROFILE_SPECIFIC); + } catch (RuntimeException e) { + log.info("Failed to get secret from sidecar: " + e.getMessage(), e); + throw new IOException("Failed to get secret from sidecar", e); + } + } + + protected <T> T getBean(ConfigDataLoaderContext context, Class<T> type) { + if (context.getBootstrapContext().isRegistered(type)) { + return context.getBootstrapContext().get(type); + } + return null; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLocationResolver.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLocationResolver.java new file mode 100644 index 0000000000..41070ea341 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataLocationResolver.java @@ -0,0 +1,179 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata.secret; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientProperties; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties; +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.apache.commons.logging.Log; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.ConfigurableBootstrapContext; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolver; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.Ordered; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +public class DaprSecretStoreConfigDataLocationResolver + implements ConfigDataLocationResolver<DaprSecretStoreConfigDataResource>, Ordered { + + public static final String PREFIX = "dapr:secret:"; + + private final Log log; + + public DaprSecretStoreConfigDataLocationResolver(DeferredLogFactory logFactory) { + this.log = logFactory.getLog(getClass()); + } + + /** + * Returns if the specified location address contains dapr prefix. + * + * @param context the location resolver context + * @param location the location to check. + * @return if the location is supported by this resolver + */ + @Override + public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) { + log.debug(String.format("checking if %s suits for dapr secret", location.toString())); + return location.hasPrefix(PREFIX); + } + + /** + * Resolve a {@link ConfigDataLocation} into one or more {@link ConfigDataResource} instances. + * + * @param context the location resolver context + * @param location the location that should be resolved + * @return a list of {@link ConfigDataResource resources} in ascending priority order. + * @throws ConfigDataLocationNotFoundException on a non-optional location that cannot be found + * @throws ConfigDataResourceNotFoundException if a resolved resource cannot be found + */ + @Override + public List<DaprSecretStoreConfigDataResource> resolve(ConfigDataLocationResolverContext context, + ConfigDataLocation location) + throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException { + + DaprCloudConfigProperties daprSecretStoreConfig = loadProperties(context); + DaprClientProperties daprClientConfig = loadClientProperties(context); + + ConfigurableBootstrapContext bootstrapContext = context + .getBootstrapContext(); + + registerConfigManager(daprSecretStoreConfig, daprClientConfig, bootstrapContext); + + List<DaprSecretStoreConfigDataResource> result = new ArrayList<>(); + + // To avoid UriComponentsBuilder to decode a wrong host. + String fullConfig = "secret://" + location.getNonPrefixedValue(PREFIX); + + UriComponents configUri = UriComponentsBuilder.fromUriString(fullConfig).build(); + + String storeName = configUri.getHost(); + String secretPath = configUri.getPath(); + String secretName = StringUtils.hasText(secretPath) + ? StringUtils.trimLeadingCharacter(secretPath, '/') + : null; + + + MultiValueMap<String, String> typeQuery = configUri.getQueryParams(); + DaprCloudConfigType secretType = DaprCloudConfigType.fromString(typeQuery.getFirst("type"), + typeQuery.getFirst("doc-type")); + + if (secretName == null) { + log.debug("Dapr Secret Store now gains store name: '" + storeName + "' secret store for config"); + result.add(new DaprSecretStoreConfigDataResource(location.isOptional(), storeName, null, secretType)); + } else if (secretName.contains("/")) { + throw new ConfigDataLocationNotFoundException(location); + } else { + log.debug("Dapr Secret Store now gains store name: '" + storeName + "' and secret name: '" + + secretName + "' secret store for config"); + result.add( + new DaprSecretStoreConfigDataResource(location.isOptional(), storeName, secretName, secretType)); + } + + return result; + } + + @Override + public int getOrder() { + return -1; + } + + private void registerConfigManager(DaprCloudConfigProperties properties, + DaprClientProperties clientConfig, + ConfigurableBootstrapContext bootstrapContext) { + synchronized (DaprCloudConfigClientManager.class) { + if (!bootstrapContext.isRegistered(DaprCloudConfigClientManager.class)) { + bootstrapContext.register(DaprCloudConfigClientManager.class, + BootstrapRegistry.InstanceSupplier + .of(new DaprCloudConfigClientManager(properties, clientConfig))); + } + } + } + + protected DaprCloudConfigProperties loadProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprCloudConfigProperties daprCloudConfigProperties; + if (context.getBootstrapContext().isRegistered(DaprCloudConfigProperties.class)) { + daprCloudConfigProperties = context.getBootstrapContext() + .get(DaprCloudConfigProperties.class); + } else { + daprCloudConfigProperties = binder + .bind(DaprCloudConfigProperties.PROPERTY_PREFIX, Bindable.of(DaprCloudConfigProperties.class), + bindHandler) + .orElseGet(DaprCloudConfigProperties::new); + } + + return daprCloudConfigProperties; + } + + protected DaprClientProperties loadClientProperties( + ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + + DaprClientProperties daprClientConfig; + if (context.getBootstrapContext().isRegistered(DaprClientProperties.class)) { + daprClientConfig = context.getBootstrapContext() + .get(DaprClientProperties.class); + } else { + daprClientConfig = binder + .bind(DaprClientProperties.PROPERTY_PREFIX, Bindable.of(DaprClientProperties.class), + bindHandler) + .orElseGet(DaprClientProperties::new); + } + + return daprClientConfig; + } + + private BindHandler getBindHandler(ConfigDataLocationResolverContext context) { + return context.getBootstrapContext().getOrElse(BindHandler.class, null); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataResource.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataResource.java new file mode 100644 index 0000000000..c87fee75cd --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/secret/DaprSecretStoreConfigDataResource.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.spring.boot.cloudconfig.configdata.secret; + +import io.dapr.spring.boot.cloudconfig.configdata.types.DaprCloudConfigType; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.lang.Nullable; + +public class DaprSecretStoreConfigDataResource extends ConfigDataResource { + private final String storeName; + private final String secretName; + private final DaprCloudConfigType type; + + /** + * Create a new non-optional {@link ConfigDataResource} instance. + * + * @param storeName store name + * @param secretName secret name + * @param type secret type + */ + public DaprSecretStoreConfigDataResource(String storeName, @Nullable String secretName, DaprCloudConfigType type) { + this.storeName = storeName; + this.secretName = secretName; + this.type = type; + } + + /** + * Create a new {@link ConfigDataResource} instance. + * + * @param optional if the resource is optional + * @param storeName store name + * @param secretName secret name + * @param type secret type + */ + public DaprSecretStoreConfigDataResource(boolean optional, String storeName, + @Nullable String secretName, DaprCloudConfigType type) { + super(optional); + this.storeName = storeName; + this.secretName = secretName; + this.type = type; + } + + public String getStoreName() { + return storeName; + } + + public String getSecretName() { + return secretName; + } + + public DaprCloudConfigType getType() { + return type; + } + + @Override + public String toString() { + return "DaprSecretStoreConfigDataResource{" + + "storeName='" + storeName + '\'' + + ", secretName='" + secretName + '\'' + + ", type=" + type + + '}'; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DaprCloudConfigType.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DaprCloudConfigType.java new file mode 100644 index 0000000000..527f5cfe20 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DaprCloudConfigType.java @@ -0,0 +1,17 @@ +package io.dapr.spring.boot.cloudconfig.configdata.types; + +import org.springframework.util.StringUtils; + +public class DaprCloudConfigType { + /** + * Get Type from String. + * @param value type specified in schema + * @param docType type of doc (if specified) + * @return type enum + */ + public static DaprCloudConfigType fromString(String value, String docType) { + return "doc".equals(value) + ? new DocType(StringUtils.hasText(docType) ? docType : "properties") + : new ValueType(); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DocType.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DocType.java new file mode 100644 index 0000000000..31928c627d --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/DocType.java @@ -0,0 +1,20 @@ +package io.dapr.spring.boot.cloudconfig.configdata.types; + +import org.springframework.util.StringUtils; + +public class DocType extends DaprCloudConfigType { + private final String docType; + + public DocType(String docType) { + this.docType = StringUtils.hasText(docType) ? docType : "properties"; + } + + public String getDocType() { + return docType; + } + + public String getDocExtension() { + String type = getDocType(); + return "." + StringUtils.trimLeadingCharacter(type, '.'); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/ValueType.java b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/ValueType.java new file mode 100644 index 0000000000..bd1f68d519 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/java/io/dapr/spring/boot/cloudconfig/configdata/types/ValueType.java @@ -0,0 +1,4 @@ +package io.dapr.spring.boot.cloudconfig.configdata.types; + +public class ValueType extends DaprCloudConfigType { +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000000..928939cb5e --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,32 @@ +{ + "properties": [ + { + "name": "dapr.cloudconfig.enabled", + "type": "java.lang.Boolean", + "defaultValue": true, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "enable dapr cloud config or not." + }, + { + "name": "dapr.cloudconfig.timeout", + "type": "java.lang.Integer", + "defaultValue": 2000, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "timeout for getting dapr config (include wait for dapr sidecar)." + }, + { + "name": "dapr.cloudconfig.wait-sidecar-enabled", + "type": "java.lang.Boolean", + "defaultValue": false, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "whether enable dapr client wait for sidecar, if no response, will throw IOException." + }, + { + "name": "dapr.cloudconfig.wait-sidecar-retries", + "type": "java.lang.Integer", + "defaultValue": 3, + "sourceType": "io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigProperties", + "description": "retries of dapr client wait for sidecar." + } + ] +} \ No newline at end of file diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring.factories b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..3050977cc1 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring.factories @@ -0,0 +1,9 @@ +# ConfigData Location Resolvers +org.springframework.boot.context.config.ConfigDataLocationResolver=\ + io.dapr.spring.boot.cloudconfig.configdata.secret.DaprSecretStoreConfigDataLocationResolver,\ + io.dapr.spring.boot.cloudconfig.configdata.config.DaprConfigurationConfigDataLocationResolver + +# ConfigData Loaders +org.springframework.boot.context.config.ConfigDataLoader=\ + io.dapr.spring.boot.cloudconfig.configdata.secret.DaprSecretStoreConfigDataLoader,\ + io.dapr.spring.boot.cloudconfig.configdata.config.DaprConfigurationConfigDataLoader diff --git a/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..068c69bd08 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +io.dapr.spring.boot.cloudconfig.autoconfigure.DaprCloudConfigAutoConfiguration diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTestApplication.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTestApplication.java new file mode 100644 index 0000000000..a69b67a408 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTestApplication.java @@ -0,0 +1,14 @@ +package io.dapr.spring.boot.cloudconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +@SpringBootApplication +@ComponentScan(basePackages = "io.dapr.spring.boot.cloudconfig.config") +public class CloudConfigTestApplication { + public static void main(String[] args) { + SpringApplication.run(CloudConfigTestApplication.class, args); + } + +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTests.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTests.java new file mode 100644 index 0000000000..cbb1891a76 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/CloudConfigTests.java @@ -0,0 +1,76 @@ +package io.dapr.spring.boot.cloudconfig; + +import io.dapr.client.DaprClient; +import io.dapr.client.domain.ConfigurationItem; +import io.dapr.client.domain.GetConfigurationRequest; +import io.dapr.spring.boot.cloudconfig.config.DaprCloudConfigClientManager; +import io.dapr.spring.boot.cloudconfig.config.MultipleConfig; +import io.dapr.spring.boot.cloudconfig.config.SingleConfig; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = {CloudConfigTestApplication.class, MultipleConfig.class, SingleConfig.class}) +public class CloudConfigTests { + + static { + try { + DaprClient daprClient = Mockito.mock(DaprClient.class); + Mockito.when(daprClient.waitForSidecar(Mockito.anyInt())).thenReturn(Mono.empty()); + + Map<String, String> multiValueProperties = new HashMap<>(); + multiValueProperties.put("multivalue-properties", "dapr.spring.democonfigsecret.multivalue.v1=spring\ndapr.spring.democonfigsecret.multivalue.v2=dapr"); + Mockito.when(daprClient.getSecret(Mockito.eq("democonfig"), Mockito.eq("multivalue-properties"))).thenReturn(Mono.just(multiValueProperties)); + + Map<String, String> singleValueProperties = new HashMap<>(); + singleValueProperties.put("dapr.spring.democonfigsecret.singlevalue", "testvalue"); + Mockito.when(daprClient.getSecret(Mockito.eq("democonfig"), Mockito.eq("dapr.spring.democonfigsecret.singlevalue"))).thenReturn(Mono.just(singleValueProperties)); + + Map<String, ConfigurationItem> singleValueConfigurationItems = new HashMap<>(); + singleValueConfigurationItems.put("dapr.spring.democonfigconfig.singlevalue", new ConfigurationItem("dapr.spring.democonfigconfig.singlevalue", "testvalue", "")); + Mockito.when(daprClient.getConfiguration(Mockito.refEq(new GetConfigurationRequest("democonfigconf", List.of("dapr.spring.democonfigconfig.singlevalue")), "metadata"))).thenReturn(Mono.just(singleValueConfigurationItems)); + + Map<String, ConfigurationItem> multiValueConfigurationItems = new HashMap<>(); + multiValueConfigurationItems.put("multivalue-yaml", new ConfigurationItem("multivalue-yaml", "dapr:\n spring:\n democonfigconfig:\n multivalue:\n v3: cloud", "")); + Mockito.when(daprClient.getConfiguration(Mockito.refEq(new GetConfigurationRequest("democonfigconf", List.of("multivalue-yaml")), "metadata"))).thenReturn(Mono.just(multiValueConfigurationItems)); + + ReflectionTestUtils.setField(DaprCloudConfigClientManager.class, "daprClient", + daprClient); + + } + catch (Exception ignore) { + ignore.printStackTrace(); + + } + } + + @Autowired + MultipleConfig multipleConfig; + + @Autowired + SingleConfig singleConfig; + + @Test + public void testSecretStoreConfig() { + assertEquals("testvalue", singleConfig.getSingleValueSecret()); + + assertEquals("spring", multipleConfig.getMultipleSecretConfigV1()); + assertEquals("dapr", multipleConfig.getMultipleSecretConfigV2()); + } + + @Test + public void testConfigurationConfig() { + assertEquals("testvalue", singleConfig.getSingleValueConfig()); + + assertEquals("cloud", multipleConfig.getMultipleConfigurationConfigV3()); + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/MultipleConfig.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/MultipleConfig.java new file mode 100644 index 0000000000..a3bf7328df --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/MultipleConfig.java @@ -0,0 +1,43 @@ +package io.dapr.spring.boot.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class MultipleConfig { + + //should be spring + @Value("${dapr.spring.democonfigsecret.multivalue.v1}") + private String multipleSecretConfigV1; + //should be dapr + @Value("${dapr.spring.democonfigsecret.multivalue.v2}") + private String multipleSecretConfigV2; + + //should be cloud + @Value("${dapr.spring.democonfigconfig.multivalue.v3}") + private String multipleConfigurationConfigV3; + + public String getMultipleSecretConfigV1() { + return multipleSecretConfigV1; + } + + public void setMultipleSecretConfigV1(String multipleSecretConfigV1) { + this.multipleSecretConfigV1 = multipleSecretConfigV1; + } + + public String getMultipleSecretConfigV2() { + return multipleSecretConfigV2; + } + + public void setMultipleSecretConfigV2(String multipleSecretConfigV2) { + this.multipleSecretConfigV2 = multipleSecretConfigV2; + } + + public String getMultipleConfigurationConfigV3() { + return multipleConfigurationConfigV3; + } + + public void setMultipleConfigurationConfigV3(String multipleConfigurationConfigV3) { + this.multipleConfigurationConfigV3 = multipleConfigurationConfigV3; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/SingleConfig.java b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/SingleConfig.java new file mode 100644 index 0000000000..d18cc0d3a5 --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/java/io/dapr/spring/boot/cloudconfig/config/SingleConfig.java @@ -0,0 +1,32 @@ +package io.dapr.spring.boot.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SingleConfig { + + // should be testvalue + @Value("${dapr.spring.democonfigsecret.singlevalue}") + private String singleValueSecret; + + // should be testvalue + @Value("${dapr.spring.democonfigconfig.singlevalue}") + private String singleValueConfig; + + public String getSingleValueSecret() { + return singleValueSecret; + } + + public void setSingleValueSecret(String singleValueSecret) { + this.singleValueSecret = singleValueSecret; + } + + public String getSingleValueConfig() { + return singleValueConfig; + } + + public void setSingleValueConfig(String singleValueConfig) { + this.singleValueConfig = singleValueConfig; + } +} diff --git a/dapr-spring/dapr-spring-cloudconfig/src/test/resources/application.yaml b/dapr-spring/dapr-spring-cloudconfig/src/test/resources/application.yaml new file mode 100644 index 0000000000..473a5c308b --- /dev/null +++ b/dapr-spring/dapr-spring-cloudconfig/src/test/resources/application.yaml @@ -0,0 +1,20 @@ +spring: + application: + name: producer-app + config: + import: + - dapr:secret:democonfig/multivalue-properties?type=doc + - dapr:secret:democonfig/dapr.spring.democonfigsecret.singlevalue?type=value + - dapr:config:democonfigconf/dapr.spring.democonfigconfig.singlevalue?type=value + - dapr:config:democonfigconf/multivalue-yaml?type=doc&doc-type=yaml + + +dapr: + cloudconfig: + enabled: true + # in case some connection issue + wait-sidecar-enabled: true + +logging: + level: + root: debug diff --git a/dapr-spring/pom.xml b/dapr-spring/pom.xml index fe4ebaa172..4dac964402 100644 --- a/dapr-spring/pom.xml +++ b/dapr-spring/pom.xml @@ -25,6 +25,7 @@ <module>dapr-spring-boot-tests</module> <module>dapr-spring-boot-starters/dapr-spring-boot-starter</module> <module>dapr-spring-boot-starters/dapr-spring-boot-starter-test</module> + <module>dapr-spring-cloudconfig</module> </modules> <properties> diff --git a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md index 9819f8ef81..70dddccbdd 100644 --- a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md +++ b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md @@ -323,6 +323,146 @@ daprWorkflowClient.raiseEvent(instanceId, "MyEvenet", event); Check the [Dapr Workflow documentation](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) for more information about how to work with Dapr Workflows. +## Using Dapr Cloud Config with Spring Boot + +To enable dapr cloud config, you should add following properties in your application's config (properties for example): + +```properties +# default enable is true, don't need to specify +dapr.cloudconfig.enabled = true +spring.config.import[0] = <schema> +spring.config.import[1] = <schema> +spring.config.import[2] = <schema> +#... keep going if you want to import more configs +``` + +There are other config of the dapr cloud config, listed below: + +```properties +#enable dapr cloud config or not (default = true). +dapr.cloudconfig.enabled=true +#timeout for getting dapr config (include wait for dapr sidecar) (default = 2000). +dapr.cloudconfig.timeout=2000 +#whether enable dapr client wait for sidecar, if no response, will throw IOException (default = false). +dapr.cloudconfig.wait-sidecar-enabled=false +#retries of dapr client wait for sidecar (default = 3). +dapr.cloudconfig.wait-sidecar-retries=3 +``` + +In Dapr Cloud Config component, we support two ways to import config: Secret Store API and Configuration API. + +Both of them have their schemas, listed below. + +### Cloud Config Import Schemas + +#### Secret Store Component + +##### url structure + +`dapr:secret:<store-name>[/<secret-name>][?<paramters>]` + +###### paramters + +| parameter | description | default | available | +|--------------------|--------------------|--------------------|--------------------| +| type | value type | `value` | `value`/`doc`| +| doc-type | type of doc | `properties` | `yaml`/`properties`/`or any file extensions you want`| + +- when type = `value`, if `secret-name` is specified, will treat secret as the value of property, and `secret-name` as the key of property; if none `secret-name` is specified, will get bulk secret and treat every value of secret as the value of property, and every key of secret as the key of property. +- when type = `doc`, if `secret-name` is specified, will treat secret as a bunch of property, and load it with property or yaml loader; if none `secret-name` is specified, will get bulk secret and and treat every value of secret as bunches of property, and load them with property or yaml loader. +- secret store with multiValud = true must specify nestedSeparator = ".", and using type = `doc` is not recommanded + +##### demo + +###### multiValued = false: + +####### store content(file secret store as example) + +```json +{ + "dapr.spring.demo-config-secret.singlevalue": "testvalue", + "multivalue-properties": "dapr.spring.demo-config-secret.multivalue.v1=spring\ndapr.spring.demo-config-secret.multivalue.v2=dapr", + "multivalue-yaml": "dapr:\n spring:\n demo-config-secret:\n multivalue:\n v3: cloud" +} +``` + +####### valid demo url + +- `dapr:secret:democonfig/multivalue-properties?type=doc&doc-type=properties` +- `dapr:secret:democonfig/multivalue-yaml?type=doc&doc-type=yaml` +- `dapr:secret:democonfig/dapr.spring.demo-config.singlevalue?type=value` +- `dapr:secret:democonfig?type=value` +- `dapr:secret:democonfig?type=doc` + +###### multiValued = true, nestedSeparator = ".": + +####### store content(file secret store as example) + +```json +{ + "value1": { + "dapr": { + "spring": { + "demo-config-secret": { + "multivalue": { + "v4": "config" + } + } + } + } + } +} +``` + +will be read as + +```json +{ + "value1": { + "dapr.spring.demo-config-secret.multivalue.v4": "config" + } +} +``` + +####### valid demo url +- `dapr:secret:demo-config-multi/value1?type=value` +- `dapr:secret:demo-config-multi?type=value` + +#### Configuration Component + +##### url structure + +`dapr:config:<store-name>[/<key>][?<paramters>]` + +###### paramters + +| parameter | description | default | available | +|--------------------|--------------------|--------------------|--------------------| +| type | value type | `value` | `doc`/`value` | +| doc-type | type of doc | `properties` | `yaml`/`properties`/`or any file extensions you want`| +| subscribe | subscribe this configuration | `false` | `true`/`false` | + +- when subscribe = `true`, will subscribe update for the configuration. +- when type = `value`, if `key` is specified, will treat config value as the value of property, and `key` as the key of property; if none `key` is specified, will get all key and value in the `config-name` and treat every config value as the value of property, and every `key` as the key of property. +- when type = `doc`, if `key` is specified, will treat config value as a bunch of property, and load it with property or yaml loader; if none `key` is specified, will get all key and value in the `config-name` and treat every config value as bunches of property, and load them with property or yaml loader. + +##### Demo + +###### store content(table as example) + +| key | value | +|--------------------|--------------------| +| dapr.spring.demo-config-config.singlevalue | testvalue | +| multivalue-properties | `dapr.spring.demo-config-config.multivalue.v1=spring\ndapr.spring.demo-config-config.multivalue.v2=dapr` | +| multivalue-yaml | `dapr:\n spring:\n demo-config-config:\n multivalue:\n v3: cloud` | + +###### valid demo url + +- `dapr:config:democonfigconf/dapr.spring.demo-config-config.singlevalue?type=value` +- `dapr:config:democonfigconf/multivalue-properties?type=doc&doc-type=properties` +- `dapr:config:democonfigconf/multivalue-yaml?type=doc&doc-type=yaml` +- `dapr:config:democonfigconf?type=doc` +- `dapr:config:democonfigconf?type=value` ## Next steps diff --git a/sdk-tests/components/secret-spring/multivalued.json b/sdk-tests/components/secret-spring/multivalued.json new file mode 100644 index 0000000000..8cfa56e9ac --- /dev/null +++ b/sdk-tests/components/secret-spring/multivalued.json @@ -0,0 +1,13 @@ +{ + "value1": { + "dapr": { + "spring": { + "demo-config-secret": { + "multivalue": { + "v4": "config" + } + } + } + } + } +} \ No newline at end of file diff --git a/sdk-tests/components/secret-spring/singlevalued.json b/sdk-tests/components/secret-spring/singlevalued.json new file mode 100644 index 0000000000..6e9fd591e5 --- /dev/null +++ b/sdk-tests/components/secret-spring/singlevalued.json @@ -0,0 +1,5 @@ +{ + "dapr.spring.demo-config-secret.singlevalue": "testvalue", + "multivalue-properties": "dapr.spring.demo-config-secret.multivalue.v1=spring\ndapr.spring.demo-config-secret.multivalue.v2=dapr", + "multivalue-yaml": "dapr:\n spring:\n demo-config-secret:\n multivalue:\n v3: cloud" +} \ No newline at end of file diff --git a/sdk-tests/components/secret.json b/sdk-tests/components/secret.json index 9e26dfeeb6..bd604a6a0f 100644 --- a/sdk-tests/components/secret.json +++ b/sdk-tests/components/secret.json @@ -1 +1 @@ -{} \ No newline at end of file +{"589f54ec-d0b7-40b5-92d9-6ab7ddef3c4f":{"name":"Jon Doe"},"969595f8-859c-4f7d-ae6a-864704f94a10":{"year":"2020","title":"The Metrics IV"}} \ No newline at end of file diff --git a/sdk-tests/components/secretstore-springboot-multivalued.yaml b/sdk-tests/components/secretstore-springboot-multivalued.yaml new file mode 100644 index 0000000000..24ac13d589 --- /dev/null +++ b/sdk-tests/components/secretstore-springboot-multivalued.yaml @@ -0,0 +1,14 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfigMultivalued +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/multivalued.json" + - name: nestedSeparator + value: "." + - name: multiValued + value: "true" diff --git a/sdk-tests/components/secretstore-springboot-singlevalued.yaml b/sdk-tests/components/secretstore-springboot-singlevalued.yaml new file mode 100644 index 0000000000..17a8580f54 --- /dev/null +++ b/sdk-tests/components/secretstore-springboot-singlevalued.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfig +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/singlevalued.json" + - name: multiValued + value: "false" diff --git a/sdk-tests/pom.xml b/sdk-tests/pom.xml index c1ffacad0e..eebeeaee2d 100644 --- a/sdk-tests/pom.xml +++ b/sdk-tests/pom.xml @@ -145,6 +145,12 @@ <artifactId>zipkin-sender-okhttp3</artifactId> <version>3.4.0</version> </dependency> + <dependency> + <groupId>redis.clients</groupId> + <artifactId>jedis</artifactId> + <version>5.2.0</version> + <scope>test</scope> + </dependency> <dependency> <groupId>io.dapr</groupId> <artifactId>dapr-sdk</artifactId> @@ -228,6 +234,12 @@ <version>${testcontainers-test.version}</version> <scope>test</scope> </dependency> + <dependency> + <groupId>com.redis</groupId> + <artifactId>testcontainers-redis</artifactId> + <version>2.2.4</version> + <scope>test</scope> + </dependency> <dependency> <groupId>jakarta.annotation</groupId> <artifactId>jakarta.annotation-api</artifactId> diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprCloudConfigIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprCloudConfigIT.java new file mode 100644 index 0000000000..a8cb4662a2 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprCloudConfigIT.java @@ -0,0 +1,106 @@ +package io.dapr.it.spring.cloudconfig; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.redis.testcontainers.RedisContainer; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.containers.Network; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import redis.clients.jedis.Jedis; + +import java.util.List; +import java.util.Map; + +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = { + "spring.config.import[0]=dapr:config:" + DaprCloudConfigIT.CONFIG_STORE_NAME + + "/" + DaprCloudConfigIT.CONFIG_MULTI_NAME + "?type=doc&doc-type=yaml", + "spring.config.import[1]=dapr:config:" + DaprCloudConfigIT.CONFIG_STORE_NAME + + "/" + DaprCloudConfigIT.CONFIG_SINGLE_NAME + "?type=value", + "dapr.cloudconfig.wait-sidecar-enabled=true", + "dapr.cloudconfig.wait-sidecar-retries=5", +}) +@ContextConfiguration(classes = TestDaprCloudConfigConfiguration.class) +@ExtendWith(SpringExtension.class) +@Testcontainers +@Tag("testcontainers") +public class DaprCloudConfigIT { + public static final String CONFIG_STORE_NAME = "democonfigconf"; + public static final String CONFIG_MULTI_NAME = "multivalue-yaml"; + public static final String CONFIG_SINGLE_NAME = "dapr.spring.demo-config-config.singlevalue"; + + private static final Map<String, String> STORE_PROPERTY = generateStoreProperty(); + + private static final Network DAPR_NETWORK = Network.newNetwork(); + + private static final RedisContainer REDIS_CONTAINER = new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)) { + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + super.containerIsStarted(containerInfo); + + String address = getHost(); + Integer port = getMappedPort(6379); + + Logger logger = LoggerFactory.getLogger(DaprCloudConfigIT.class); + // Put values using Jedis + try (Jedis jedis = new Jedis(address, port)) { + logger.info("Putting Dapr Cloud config to {}:{}", address, port); + jedis.set(DaprCloudConfigIT.CONFIG_MULTI_NAME, DaprConfigurationStores.YAML_CONFIG); + jedis.set(DaprCloudConfigIT.CONFIG_SINGLE_NAME, "testvalue"); + } + } + } + .withNetworkAliases("redis") + .withCommand() + .withNetwork(DAPR_NETWORK); + + @Container + @ServiceConnection + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) + .withAppName("configuration-dapr-app") + .withNetwork(DAPR_NETWORK) + .withComponent(new Component(CONFIG_STORE_NAME, "configuration.redis", "v1", STORE_PROPERTY)) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .dependsOn(REDIS_CONTAINER); + + static { + DAPR_CONTAINER.setPortBindings(List.of("3500:3500", "50001:50001")); + } + + private static Map<String, String> generateStoreProperty() { + return Map.of("redisHost", "redis:6379", + "redisPassword", ""); + } + + @Value("${dapr.spring.demo-config-config.singlevalue}") + String valueConfig; + + @Value("${dapr.spring.demo-config-config.multivalue.v3}") + String yamlConfig; + + @Test + public void configTest() { + assertEquals("testvalue", valueConfig); + assertEquals("cloud", yamlConfig); + } + +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprConfigurationStores.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprConfigurationStores.java new file mode 100644 index 0000000000..9c25dc4761 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprConfigurationStores.java @@ -0,0 +1,9 @@ +package io.dapr.it.spring.cloudconfig; + +public class DaprConfigurationStores { + public static final String YAML_CONFIG = "dapr:\n" + + " spring:\n" + + " demo-config-config:\n" + + " multivalue:\n" + + " v3: cloud"; +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStoreIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStoreIT.java new file mode 100644 index 0000000000..d3930022a7 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStoreIT.java @@ -0,0 +1,103 @@ +package io.dapr.it.spring.cloudconfig; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; + +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = { + "spring.config.import[0]=dapr:secret:" + DaprSecretStoreIT.SECRET_STORE_NAME + + "/" + DaprSecretStoreIT.SECRET_MULTI_NAME + "?type=doc", + "spring.config.import[1]=dapr:secret:" + DaprSecretStoreIT.SECRET_STORE_NAME + + "/" + DaprSecretStoreIT.SECRET_SINGLE_NAME + "?type=value", + "spring.config.import[2]=dapr:secret:" + DaprSecretStoreIT.SECRET_STORE_NAME_MULTI + + "?type=value", + "dapr.cloudconfig.wait-sidecar-enabled=true", + "dapr.cloudconfig.wait-sidecar-retries=5", +}) +@ContextConfiguration(classes = TestDaprCloudConfigConfiguration.class) +@ExtendWith(SpringExtension.class) +@Testcontainers +@Tag("testcontainers") +public class DaprSecretStoreIT { + public static final String SECRET_STORE_NAME = "democonfig"; + public static final String SECRET_MULTI_NAME = "multivalue-properties"; + public static final String SECRET_SINGLE_NAME = "dapr.spring.demo-config-secret.singlevalue"; + + public static final String SECRET_STORE_NAME_MULTI = "democonfigMultivalued"; + + private static final Map<String, String> SINGLE_VALUE_PROPERTY = generateSingleValueProperty(); + private static final Map<String, String> MULTI_VALUE_PROPERTY = generateMultiValueProperty(); + + private static final Network DAPR_NETWORK = Network.newNetwork(); + + @Container + @ServiceConnection + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) + .withAppName("secret-store-dapr-app") + .withComponent(new Component(SECRET_STORE_NAME, "secretstores.local.file", "v1", SINGLE_VALUE_PROPERTY)) + .withComponent(new Component(SECRET_STORE_NAME_MULTI, "secretstores.local.file", "v1", MULTI_VALUE_PROPERTY)) + .withNetwork(DAPR_NETWORK) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withCopyToContainer(Transferable.of(DaprSecretStores.SINGLE_VALUED_SECRET), "/dapr-secrets/singlevalued.json") + .withCopyToContainer(Transferable.of(DaprSecretStores.MULTI_VALUED_SECRET), "/dapr-secrets/multivalued.json"); + + static { + DAPR_CONTAINER.setPortBindings(List.of("3500:3500", "50001:50001")); + } + + private static Map<String, String> generateSingleValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/singlevalued.json", + "multiValued", "false"); + } + + private static Map<String, String> generateMultiValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/multivalued.json", + "nestedSeparator", ".", + "multiValued", "true"); + } + + @Value("${dapr.spring.demo-config-secret.singlevalue}") + String singleValue; + + @Value("${dapr.spring.demo-config-secret.multivalue.v1}") + String multiV1; + + @Value("${dapr.spring.demo-config-secret.multivalue.v2}") + String multiV2; + + @Value("${dapr.spring.demo-config-secret.multivalue.v4}") + String multiV4; + + @Test + public void testSecretStore() { + assertEquals("testvalue", singleValue); + assertEquals("spring", multiV1); + assertEquals("dapr", multiV2); + assertEquals("config", multiV4); + } + +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStores.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStores.java new file mode 100644 index 0000000000..67601b1baf --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/DaprSecretStores.java @@ -0,0 +1,23 @@ +package io.dapr.it.spring.cloudconfig; + +public class DaprSecretStores { + public static final String SINGLE_VALUED_SECRET = "{\n" + + " \"dapr.spring.demo-config-secret.singlevalue\": \"testvalue\",\n" + + " \"multivalue-properties\": \"dapr.spring.demo-config-secret.multivalue.v1=spring\\ndapr.spring.demo-config-secret.multivalue.v2=dapr\",\n" + + " \"multivalue-yaml\": \"dapr:\\n spring:\\n demo-config-secret:\\n multivalue:\\n v3: cloud\"\n" + + "}"; + + public static final String MULTI_VALUED_SECRET = "{\n" + + " \"value1\": {\n" + + " \"dapr\": {\n" + + " \"spring\": {\n" + + " \"demo-config-secret\": {\n" + + " \"multivalue\": {\n" + + " \"v4\": \"config\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; +} diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/TestDaprCloudConfigConfiguration.java b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/TestDaprCloudConfigConfiguration.java new file mode 100644 index 0000000000..62fb063752 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/spring/cloudconfig/TestDaprCloudConfigConfiguration.java @@ -0,0 +1,11 @@ +package io.dapr.it.spring.cloudconfig; + +import io.dapr.spring.boot.autoconfigure.client.DaprClientAutoConfiguration; +import io.dapr.spring.boot.cloudconfig.autoconfigure.DaprCloudConfigAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import({DaprClientAutoConfiguration.class, DaprCloudConfigAutoConfiguration.class}) +public class TestDaprCloudConfigConfiguration { +} diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index f0111ca638..35f66c7d95 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -51,4 +51,10 @@ <Package name="~io\.dapr.*"/> <Bug pattern="NP_UNWRITTEN_FIELD"/> </Match> + + <!-- have to disable this in order to make test work --> + <Match> + <Class name="~io.dapr.spring.boot.cloudconfig.*"/> + <Bug pattern="MS_EXPOSE_REP"/> + </Match> </FindBugsFilter> \ No newline at end of file diff --git a/spring-boot-examples/README.md b/spring-boot-examples/README.md index 3cc88610de..d24f723e64 100644 --- a/spring-boot-examples/README.md +++ b/spring-boot-examples/README.md @@ -1,12 +1,15 @@ # Dapr Spring Boot and Testcontainers integration Example -This example consists of two applications: +This example consists of three applications: - Producer App: - Publish messages using a Spring Messaging approach - Store and retrieve information using Spring Data CrudRepository - Implements a Workflow with Dapr Workflows - Consumer App: - Subscribe to messages +- Cloud Config Demo: + - Import and use configs + - Can not run in Kubernetes currently, as lack of redis pre-fill data supported ## Running these examples from source code diff --git a/spring-boot-examples/cloud-config-demo/components/redisconfigstore.yaml b/spring-boot-examples/cloud-config-demo/components/redisconfigstore.yaml new file mode 100644 index 0000000000..6b1f3f799f --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/redisconfigstore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfigconf +spec: + type: configuration.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/spring-boot-examples/cloud-config-demo/components/secret-spring/multivalued.json b/spring-boot-examples/cloud-config-demo/components/secret-spring/multivalued.json new file mode 100644 index 0000000000..8cfa56e9ac --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secret-spring/multivalued.json @@ -0,0 +1,13 @@ +{ + "value1": { + "dapr": { + "spring": { + "demo-config-secret": { + "multivalue": { + "v4": "config" + } + } + } + } + } +} \ No newline at end of file diff --git a/spring-boot-examples/cloud-config-demo/components/secret-spring/singlevalued.json b/spring-boot-examples/cloud-config-demo/components/secret-spring/singlevalued.json new file mode 100644 index 0000000000..6e9fd591e5 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secret-spring/singlevalued.json @@ -0,0 +1,5 @@ +{ + "dapr.spring.demo-config-secret.singlevalue": "testvalue", + "multivalue-properties": "dapr.spring.demo-config-secret.multivalue.v1=spring\ndapr.spring.demo-config-secret.multivalue.v2=dapr", + "multivalue-yaml": "dapr:\n spring:\n demo-config-secret:\n multivalue:\n v3: cloud" +} \ No newline at end of file diff --git a/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-multivalued.yaml b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-multivalued.yaml new file mode 100644 index 0000000000..24ac13d589 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-multivalued.yaml @@ -0,0 +1,14 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfigMultivalued +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/multivalued.json" + - name: nestedSeparator + value: "." + - name: multiValued + value: "true" diff --git a/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-singlevalued.yaml b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-singlevalued.yaml new file mode 100644 index 0000000000..17a8580f54 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/components/secretstore-springboot-singlevalued.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: democonfig +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: "./components/secret-spring/singlevalued.json" + - name: multiValued + value: "false" diff --git a/spring-boot-examples/cloud-config-demo/pom.xml b/spring-boot-examples/cloud-config-demo/pom.xml new file mode 100644 index 0000000000..65f58fa31a --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/pom.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>io.dapr</groupId> + <artifactId>spring-boot-examples</artifactId> + <version>0.15.0-SNAPSHOT</version> + </parent> + + <artifactId>cloud-config-demo</artifactId> + <name>cloud-config-demo</name> + <description>Spring Boot, Testcontainers and Dapr Integration Examples :: Cloud Config Demo</description> + + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-dependencies</artifactId> + <version>${springboot.version}</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> + </dependencyManagement> + + <dependencies> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-actuator</artifactId> + </dependency> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-boot-starter</artifactId> + <version>${dapr.sdk.alpha.version}</version> + </dependency> + <dependency> + <groupId>io.dapr.spring</groupId> + <artifactId>dapr-spring-boot-starter-test</artifactId> + <version>${dapr.sdk.alpha.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>junit-jupiter</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>redis.clients</groupId> + <artifactId>jedis</artifactId> + <version>5.2.0</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.redis</groupId> + <artifactId>testcontainers-redis</artifactId> + <version>2.2.4</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>io.rest-assured</groupId> + <artifactId>rest-assured</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-site-plugin</artifactId> + <version>3.12.1</version> + <configuration> + <skip>true</skip> + </configuration> + </plugin> + </plugins> + </build> +</project> diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/CloudConfigApplication.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/CloudConfigApplication.java new file mode 100644 index 0000000000..d3fa3f2de2 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/CloudConfigApplication.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.springboot.examples.cloudconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class CloudConfigApplication { + + public static void main(String[] args) { + SpringApplication.run(CloudConfigApplication.class, args); + } + +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/MultipleConfig.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/MultipleConfig.java new file mode 100644 index 0000000000..f6b2cb040d --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/MultipleConfig.java @@ -0,0 +1,43 @@ +package io.dapr.springboot.examples.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class MultipleConfig { + + //should be spring + @Value("${dapr.spring.demo-config-secret.multivalue.v1}") + private String multipleSecretConfigV1; + //should be dapr + @Value("${dapr.spring.demo-config-secret.multivalue.v2}") + private String multipleSecretConfigV2; + + //should be cloud + @Value("${dapr.spring.demo-config-config.multivalue.v3}") + private String multipleConfigurationConfigV3; + + public String getMultipleSecretConfigV1() { + return multipleSecretConfigV1; + } + + public void setMultipleSecretConfigV1(String multipleSecretConfigV1) { + this.multipleSecretConfigV1 = multipleSecretConfigV1; + } + + public String getMultipleSecretConfigV2() { + return multipleSecretConfigV2; + } + + public void setMultipleSecretConfigV2(String multipleSecretConfigV2) { + this.multipleSecretConfigV2 = multipleSecretConfigV2; + } + + public String getMultipleConfigurationConfigV3() { + return multipleConfigurationConfigV3; + } + + public void setMultipleConfigurationConfigV3(String multipleConfigurationConfigV3) { + this.multipleConfigurationConfigV3 = multipleConfigurationConfigV3; + } +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/SingleConfig.java b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/SingleConfig.java new file mode 100644 index 0000000000..53a9af960e --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/java/io/dapr/springboot/examples/cloudconfig/config/SingleConfig.java @@ -0,0 +1,32 @@ +package io.dapr.springboot.examples.cloudconfig.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SingleConfig { + + // should be testvalue + @Value("${dapr.spring.demo-config-secret.singlevalue}") + private String singleValueSecret; + + // should be testvalue + @Value("${dapr.spring.demo-config-config.singlevalue}") + private String singleValueConfig; + + public String getSingleValueSecret() { + return singleValueSecret; + } + + public void setSingleValueSecret(String singleValueSecret) { + this.singleValueSecret = singleValueSecret; + } + + public String getSingleValueConfig() { + return singleValueConfig; + } + + public void setSingleValueConfig(String singleValueConfig) { + this.singleValueConfig = singleValueConfig; + } +} diff --git a/spring-boot-examples/cloud-config-demo/src/main/resources/application.yaml b/spring-boot-examples/cloud-config-demo/src/main/resources/application.yaml new file mode 100644 index 0000000000..2785be400d --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + application: + name: cloud-config-demo + config: + import: + - dapr:secret:democonfig/multivalue-properties?type=doc + - dapr:secret:democonfig/dapr.spring.demo-config-secret.singlevalue?type=value + #- dapr:secret:democonfigmulti/value1?type=value + - dapr:config:democonfigconf/dapr.spring.demo-config-config.singlevalue?type=value + - dapr:config:democonfigconf/multivalue-yaml?type=doc&doc-type=yaml + + diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/CloudConfigTests.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/CloudConfigTests.java new file mode 100644 index 0000000000..cdcd8c12cd --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/CloudConfigTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.springboot.examples.cloudconfig; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.redis.testcontainers.RedisContainer; +import io.dapr.spring.boot.cloudconfig.autoconfigure.DaprCloudConfigAutoConfiguration; +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.springboot.examples.cloudconfig.config.MultipleConfig; +import io.dapr.springboot.examples.cloudconfig.config.SingleConfig; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import redis.clients.jedis.Jedis; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = {TestCloudConfigApplication.class, DaprTestContainersConfig.class, + DaprAutoConfiguration.class, DaprCloudConfigAutoConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@Testcontainers +@Tag("testcontainers") +class CloudConfigTests { + + public static final String CONFIG_STORE_NAME = "democonfigconf"; + public static final String CONFIG_MULTI_NAME = "multivalue-yaml"; + public static final String CONFIG_SINGLE_NAME = "dapr.spring.demo-config-config.singlevalue"; + + public static final String SECRET_STORE_NAME = "democonfig"; + public static final String SECRET_MULTI_NAME = "multivalue-properties"; + public static final String SECRET_SINGLE_NAME = "dapr.spring.demo-config-secret.singlevalue"; + + public static final String SECRET_STORE_NAME_MULTI = "democonfigMultivalued"; + + private static final Map<String, String> SINGLE_VALUE_PROPERTY = generateSingleValueProperty(); + private static final Map<String, String> MULTI_VALUE_PROPERTY = generateMultiValueProperty(); + + + private static final Map<String, String> STORE_PROPERTY = generateStoreProperty(); + + private static final Network DAPR_NETWORK = Network.newNetwork(); + + @Container + private static final RedisContainer REDIS_CONTAINER = new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)) { + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + super.containerIsStarted(containerInfo); + + String address = getHost(); + Integer port = getMappedPort(6379); + + Logger logger = LoggerFactory.getLogger(CloudConfigTests.class); + // Put values using Jedis + try (Jedis jedis = new Jedis(address, port)) { + logger.info("Putting Dapr Cloud config to {}:{}", address, port); + jedis.set(CloudConfigTests.CONFIG_MULTI_NAME, DaprConfigurationStores.YAML_CONFIG); + jedis.set(CloudConfigTests.CONFIG_SINGLE_NAME, "testvalue"); + } + } + } + .withNetworkAliases("redis") + .withCommand() + .withNetwork(DAPR_NETWORK); + + @Container + @ServiceConnection + private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.14.4") + .withAppName("configuration-dapr-app") + .withNetwork(DAPR_NETWORK) + .withComponent(new Component(CONFIG_STORE_NAME, "configuration.redis", "v1", STORE_PROPERTY)) + .withComponent(new Component(SECRET_STORE_NAME, "secretstores.local.file", "v1", SINGLE_VALUE_PROPERTY)) + .withComponent(new Component(SECRET_STORE_NAME_MULTI, "secretstores.local.file", "v1", MULTI_VALUE_PROPERTY)) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .dependsOn(REDIS_CONTAINER) + .withCopyToContainer(Transferable.of(DaprSecretStores.SINGLE_VALUED_SECRET), "/dapr-secrets/singlevalued.json") + .withCopyToContainer(Transferable.of(DaprSecretStores.MULTI_VALUED_SECRET), "/dapr-secrets/multivalued.json"); + + static { + DAPR_CONTAINER.setPortBindings(List.of("3500:3500", "50001:50001")); + } + + private static Map<String, String> generateStoreProperty() { + return Map.of("redisHost", "redis:6379", + "redisPassword", ""); + } + + private static Map<String, String> generateSingleValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/singlevalued.json", + "multiValued", "false"); + } + + private static Map<String, String> generateMultiValueProperty() { + return Map.of("secretsFile", "/dapr-secrets/multivalued.json", + "nestedSeparator", ".", + "multiValued", "true"); + } + + @Autowired + MultipleConfig multipleConfig; + + @Autowired + SingleConfig singleConfig; + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add("dapr.client.http-port", DAPR_CONTAINER::getHttpPort); + registry.add("dapr.client.grpc-port", DAPR_CONTAINER::getGrpcPort); + } + + @Test + void testCloudConfig() { + assertEquals("testvalue", singleConfig.getSingleValueSecret()); + assertEquals("spring", multipleConfig.getMultipleSecretConfigV1()); + assertEquals("dapr", multipleConfig.getMultipleSecretConfigV2()); + + assertEquals("testvalue", singleConfig.getSingleValueConfig()); + + assertEquals("cloud", multipleConfig.getMultipleConfigurationConfigV3()); + } + +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprConfigurationStores.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprConfigurationStores.java new file mode 100644 index 0000000000..87911de3e1 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprConfigurationStores.java @@ -0,0 +1,9 @@ +package io.dapr.springboot.examples.cloudconfig; + +public class DaprConfigurationStores { + public static final String YAML_CONFIG = "dapr:\n" + + " spring:\n" + + " demo-config-config:\n" + + " multivalue:\n" + + " v3: cloud"; +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprSecretStores.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprSecretStores.java new file mode 100644 index 0000000000..ef05862cb1 --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprSecretStores.java @@ -0,0 +1,23 @@ +package io.dapr.springboot.examples.cloudconfig; + +public class DaprSecretStores { + public static final String SINGLE_VALUED_SECRET = "{\n" + + " \"dapr.spring.demo-config-secret.singlevalue\": \"testvalue\",\n" + + " \"multivalue-properties\": \"dapr.spring.demo-config-secret.multivalue.v1=spring\\ndapr.spring.demo-config-secret.multivalue.v2=dapr\",\n" + + " \"multivalue-yaml\": \"dapr:\\n spring:\\n demo-config-secret:\\n multivalue:\\n v3: cloud\"\n" + + "}"; + + public static final String MULTI_VALUED_SECRET = "{\n" + + " \"value1\": {\n" + + " \"dapr\": {\n" + + " \"spring\": {\n" + + " \"demo-config-secret\": {\n" + + " \"multivalue\": {\n" + + " \"v4\": \"config\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprTestContainersConfig.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprTestContainersConfig.java new file mode 100644 index 0000000000..4a770895ea --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/DaprTestContainersConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.springboot.examples.cloudconfig; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.redis.testcontainers.RedisContainer; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import redis.clients.jedis.Jedis; + +import java.util.List; +import java.util.Map; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + +} diff --git a/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/TestCloudConfigApplication.java b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/TestCloudConfigApplication.java new file mode 100644 index 0000000000..d8514a356a --- /dev/null +++ b/spring-boot-examples/cloud-config-demo/src/test/java/io/dapr/springboot/examples/cloudconfig/TestCloudConfigApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed 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 io.dapr.springboot.examples.cloudconfig; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestCloudConfigApplication { + + public static void main(String[] args) { + + SpringApplication.from(CloudConfigApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } + +} diff --git a/spring-boot-examples/pom.xml b/spring-boot-examples/pom.xml index 75a32364f7..6667ec0cab 100644 --- a/spring-boot-examples/pom.xml +++ b/spring-boot-examples/pom.xml @@ -21,6 +21,7 @@ <modules> <module>producer-app</module> <module>consumer-app</module> + <module>cloud-config-demo</module> </modules> <dependencyManagement>