From 15cdc7d2bfb754c20d052b495be820a64e3da22d Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 13 Jan 2020 12:41:24 +0200 Subject: [PATCH] Add first version of Spring Config Server Client extension --- .github/boring-cyborg.yml | 2 + bom/deployment/pom.xml | 5 + bom/runtime/pom.xml | 5 + build-parent/pom.xml | 5 + ci-templates/stages.yml | 3 +- .../builditem/FeatureBuildItem.java | 1 + extensions/pom.xml | 1 + .../deployment/pom.xml | 46 +++++++ .../client/SpringCloudConfigProcessor.java | 43 ++++++ extensions/spring-cloud-config-client/pom.xml | 21 +++ .../runtime/pom.xml | 110 +++++++++++++++ ...DefaultSpringCloudConfigClientGateway.java | 130 ++++++++++++++++++ .../cloud/config/client/runtime/Response.java | 52 +++++++ .../SpringCloudConfigClientConfig.java | 64 +++++++++ .../SpringCloudConfigClientGateway.java | 8 ++ .../SpringCloudConfigClientRecorder.java | 48 +++++++ ...onfigServerClientConfigSourceProvider.java | 97 +++++++++++++ .../resources/META-INF/quarkus-extension.yaml | 11 ++ .../SpringCloudConfigClientGatewayTest.java | 81 +++++++++++ .../runtime/src/test/resources/foo-dev.json | 42 ++++++ integration-tests/pom.xml | 1 + .../spring-cloud-config-client/pom.xml | 123 +++++++++++++++++ .../server/client/GreetingResource.java | 18 +++ .../__files/a-bootiful-client-prod.json | 26 ++++ .../src/main/resources/application.properties | 7 + .../mappings/a-bootiful-client-prod.json | 17 +++ .../client/runtime/GreetingResourceIT.java | 12 ++ .../client/runtime/GreetingResourceTest.java | 26 ++++ 28 files changed, 1004 insertions(+), 1 deletion(-) create mode 100644 extensions/spring-cloud-config-client/deployment/pom.xml create mode 100644 extensions/spring-cloud-config-client/deployment/src/main/java/io/quarkus/spring/cloud/config/client/SpringCloudConfigProcessor.java create mode 100644 extensions/spring-cloud-config-client/pom.xml create mode 100644 extensions/spring-cloud-config-client/runtime/pom.xml create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/DefaultSpringCloudConfigClientGateway.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/Response.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGateway.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientRecorder.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigServerClientConfigSourceProvider.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml create mode 100644 extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java create mode 100644 extensions/spring-cloud-config-client/runtime/src/test/resources/foo-dev.json create mode 100644 integration-tests/spring-cloud-config-client/pom.xml create mode 100644 integration-tests/spring-cloud-config-client/src/main/java/io/quarkus/it/spring/config/server/client/GreetingResource.java create mode 100644 integration-tests/spring-cloud-config-client/src/main/resources/__files/a-bootiful-client-prod.json create mode 100644 integration-tests/spring-cloud-config-client/src/main/resources/application.properties create mode 100644 integration-tests/spring-cloud-config-client/src/main/resources/mappings/a-bootiful-client-prod.json create mode 100644 integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceIT.java create mode 100644 integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceTest.java diff --git a/.github/boring-cyborg.yml b/.github/boring-cyborg.yml index a220731d1e3e8..467363191fe6f 100644 --- a/.github/boring-cyborg.yml +++ b/.github/boring-cyborg.yml @@ -244,10 +244,12 @@ labelPRBasedOnFilePath: - extensions/smallrye-reactive-messaging-kafka/**/* - extensions/smallrye-reactive-messaging-mqtt/**/* area/spring: + - extensions/spring-cloud-config-client/**/* - extensions/spring-data-jpa/**/* - extensions/spring-di/**/* - extensions/spring-security/**/* - extensions/spring-web/**/* + - integration-tests/spring-cloud-config-client/**/* - integration-tests/spring-data-jpa/**/* - integration-tests/spring-di/**/* - integration-tests/spring-web/**/* diff --git a/bom/deployment/pom.xml b/bom/deployment/pom.xml index 8eed644a224f1..03a02727730f2 100644 --- a/bom/deployment/pom.xml +++ b/bom/deployment/pom.xml @@ -511,6 +511,11 @@ quarkus-spring-boot-properties-deployment ${project.version} + + io.quarkus + quarkus-spring-cloud-config-client-deployment + ${project.version} + io.quarkus quarkus-jgit-deployment diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index c78d51dceb556..6cb3b62d931c5 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -662,6 +662,11 @@ quarkus-spring-boot-properties ${project.version} + + io.quarkus + quarkus-spring-cloud-config-client + ${project.version} + io.quarkus quarkus-swagger-ui diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 9eb65a5328fe5..49c1f7074da7b 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -351,6 +351,11 @@ antlr4-maven-plugin ${antlr.version} + + uk.co.automatictester + wiremock-maven-plugin + 5.0.1 + diff --git a/ci-templates/stages.yml b/ci-templates/stages.yml index 680ac0f7922ce..4bec56d7a7031 100644 --- a/ci-templates/stages.yml +++ b/ci-templates/stages.yml @@ -419,12 +419,13 @@ stages: parameters: poolSettings: ${{parameters.poolSettings}} expectUseVMs: ${{parameters.expectUseVMs}} - timeoutInMinutes: 25 + timeoutInMinutes: 30 modules: - spring-di - spring-web - spring-data-jpa - spring-boot-properties + - spring-cloud-config-client name: spring - template: native-build-steps.yaml diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java index 79f8cbd925719..71e69eb5737c7 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java @@ -85,6 +85,7 @@ public final class FeatureBuildItem extends MultiBuildItem { public static final String SPRING_DATA_JPA = "spring-data-jpa"; public static final String SPRING_SECURITY = "spring-security"; public static final String SPRING_BOOT_PROPERTIES = "spring-boot-properties"; + public static final String SPRING_CLOUD_CONFIG_CLIENT = "spring-cloud-config-client"; public static final String SWAGGER_UI = "swagger-ui"; public static final String TIKA = "tika"; public static final String UNDERTOW_WEBSOCKETS = "undertow-websockets"; diff --git a/extensions/pom.xml b/extensions/pom.xml index bb1e160ace94f..d3357b618e659 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -95,6 +95,7 @@ spring-data-jpa spring-security spring-boot-properties + spring-cloud-config-client security diff --git a/extensions/spring-cloud-config-client/deployment/pom.xml b/extensions/spring-cloud-config-client/deployment/pom.xml new file mode 100644 index 0000000000000..fa96f5f1991e1 --- /dev/null +++ b/extensions/spring-cloud-config-client/deployment/pom.xml @@ -0,0 +1,46 @@ + + + + quarkus-spring-cloud-config-client-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-spring-cloud-config-client-deployment + Quarkus - Spring Cloud Config Client - Deployment + + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-jackson-deployment + + + io.quarkus + quarkus-spring-cloud-config-client + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + \ No newline at end of file diff --git a/extensions/spring-cloud-config-client/deployment/src/main/java/io/quarkus/spring/cloud/config/client/SpringCloudConfigProcessor.java b/extensions/spring-cloud-config-client/deployment/src/main/java/io/quarkus/spring/cloud/config/client/SpringCloudConfigProcessor.java new file mode 100644 index 0000000000000..07f2cb27afbde --- /dev/null +++ b/extensions/spring-cloud-config-client/deployment/src/main/java/io/quarkus/spring/cloud/config/client/SpringCloudConfigProcessor.java @@ -0,0 +1,43 @@ +package io.quarkus.spring.cloud.config.client; + +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.RunTimeConfigurationSourceValueBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.runtime.ApplicationConfig; +import io.quarkus.spring.cloud.config.client.runtime.Response; +import io.quarkus.spring.cloud.config.client.runtime.SpringCloudConfigClientConfig; +import io.quarkus.spring.cloud.config.client.runtime.SpringCloudConfigClientRecorder; + +public class SpringCloudConfigProcessor { + + @BuildStep + public void feature(BuildProducer feature) { + feature.produce(new FeatureBuildItem(FeatureBuildItem.SPRING_CLOUD_CONFIG_CLIENT)); + } + + @BuildStep + public void enableSsl(BuildProducer extensionSslNativeSupport) { + extensionSslNativeSupport.produce(new ExtensionSslNativeSupportBuildItem(FeatureBuildItem.SPRING_CLOUD_CONFIG_CLIENT)); + } + + @BuildStep + public void registerForReflection(BuildProducer reflectiveClass) { + reflectiveClass.produce(new ReflectiveClassBuildItem(true, false, false, Response.class)); + reflectiveClass.produce(new ReflectiveClassBuildItem(true, false, false, Response.PropertySource.class)); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public RunTimeConfigurationSourceValueBuildItem configure(SpringCloudConfigClientRecorder recorder, + SpringCloudConfigClientConfig springCloudConfigClientConfig, + ApplicationConfig applicationConfig) { + return new RunTimeConfigurationSourceValueBuildItem( + recorder.create(springCloudConfigClientConfig, applicationConfig)); + } + +} diff --git a/extensions/spring-cloud-config-client/pom.xml b/extensions/spring-cloud-config-client/pom.xml new file mode 100644 index 0000000000000..3d2688413f35f --- /dev/null +++ b/extensions/spring-cloud-config-client/pom.xml @@ -0,0 +1,21 @@ + + + + quarkus-build-parent + io.quarkus + 999-SNAPSHOT + ../../build-parent/pom.xml + + 4.0.0 + + quarkus-spring-cloud-config-client-parent + Quarkus - Spring Cloud Config Client + pom + + + deployment + runtime + + \ No newline at end of file diff --git a/extensions/spring-cloud-config-client/runtime/pom.xml b/extensions/spring-cloud-config-client/runtime/pom.xml new file mode 100644 index 0000000000000..2701c1908a069 --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/pom.xml @@ -0,0 +1,110 @@ + + + + quarkus-spring-cloud-config-client-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-spring-cloud-config-client + Quarkus - Spring Cloud Config Client - Runtime + Use properties from Spring Cloud Config as bootstrap properties sources + + + + io.quarkus + quarkus-arc + + + org.apache.httpcomponents + httpclient + + + commons-logging + commons-logging + + + + + org.jboss.logging + commons-logging-jboss-logging + + + io.quarkus + quarkus-jackson + + + + + io.quarkus + quarkus-junit5 + test + + + com.github.tomakehurst + wiremock-jre8 + test + + + javax.servlet + javax.servlet-api + + + javax.xml.bind + jaxb-api + + + + + jakarta.servlet + jakarta.servlet-api + test + + + commons-io + commons-io + test + + + org.assertj + assertj-core + test + + + + + + + io.quarkus + quarkus-bootstrap-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/DefaultSpringCloudConfigClientGateway.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/DefaultSpringCloudConfigClientGateway.java new file mode 100644 index 0000000000000..d1f7b3b6af40b --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/DefaultSpringCloudConfigClientGateway.java @@ -0,0 +1,130 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScheme; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.AuthCache; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +class DefaultSpringCloudConfigClientGateway implements SpringCloudConfigClientGateway { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private final SpringCloudConfigClientConfig springCloudConfigClientConfig; + private final String baseUri; + + public DefaultSpringCloudConfigClientGateway(SpringCloudConfigClientConfig springCloudConfigClientConfig) { + this.springCloudConfigClientConfig = springCloudConfigClientConfig; + this.baseUri = determineBaseUri(springCloudConfigClientConfig); + } + + private String determineBaseUri(SpringCloudConfigClientConfig springCloudConfigClientConfig) { + String baseUri = springCloudConfigClientConfig.uri; + if (null == baseUri || baseUri.isEmpty()) { + throw new IllegalArgumentException("baseUri cannot be empty"); + } + if (baseUri.endsWith("/")) { + return baseUri.substring(0, baseUri.length() - 1); + } + return baseUri; + } + + @Override + public Response exchange(String applicationName, String profile) throws IOException { + final RequestConfig requestConfig = RequestConfig.custom() + .setConnectionRequestTimeout((int) springCloudConfigClientConfig.connectionTimeout.toMillis()) + .setSocketTimeout((int) springCloudConfigClientConfig.readTimeout.toMillis()) + .build(); + final HttpClientBuilder httpClientBuilder = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig); + try (CloseableHttpClient client = httpClientBuilder.build()) { + final String finalUri = baseUri + "/" + applicationName + "/" + profile; + final HttpGet request = new HttpGet(finalUri); + request.addHeader("Accept", "application/json"); + + HttpClientContext context = setupContext(); + try (CloseableHttpResponse response = client.execute(request, context)) { + if (response.getStatusLine().getStatusCode() != 200) { + throw new RuntimeException("Got unexpected HTTP response code " + response.getStatusLine().getStatusCode() + + " from " + finalUri); + } + final HttpEntity entity = response.getEntity(); + if (entity == null) { + throw new RuntimeException("Got empty HTTP response body " + finalUri); + } + + return OBJECT_MAPPER.readValue(EntityUtils.toString(entity), Response.class); + } + } + } + + private HttpClientContext setupContext() { + final HttpClientContext context = HttpClientContext.create(); + if (baseUri.contains("@") || springCloudConfigClientConfig.usernameAndPasswordSet()) { + final AuthCache authCache = InMemoryAuthCache.INSTANCE; + authCache.put(HttpHost.create(baseUri), new BasicScheme()); + context.setAuthCache(authCache); + if (springCloudConfigClientConfig.usernameAndPasswordSet()) { + final CredentialsProvider provider = new BasicCredentialsProvider(); + final UsernamePasswordCredentials credentials = new UsernamePasswordCredentials( + springCloudConfigClientConfig.username.get(), springCloudConfigClientConfig.password.get()); + provider.setCredentials(AuthScope.ANY, credentials); + context.setCredentialsProvider(provider); + } + } + return context; + } + + /** + * We need this class in order to avoid the serialization that Apache HTTP client does by default + * and that does not work in GraalVM. + * We don't care about caching the auth result since one call is only ever going to be made in any case + */ + private static class InMemoryAuthCache implements AuthCache { + + static final InMemoryAuthCache INSTANCE = new InMemoryAuthCache(); + + private final Map map = new ConcurrentHashMap<>(); + + private InMemoryAuthCache() { + } + + @Override + public void put(HttpHost host, AuthScheme authScheme) { + map.put(host, authScheme); + } + + @Override + public AuthScheme get(HttpHost host) { + return map.get(host); + } + + @Override + public void remove(HttpHost host) { + map.remove(host); + } + + @Override + public void clear() { + map.clear(); + } + }; +} diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/Response.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/Response.java new file mode 100644 index 0000000000000..0f788d92ab0f1 --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/Response.java @@ -0,0 +1,52 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Response { + private final String name; + private final List profiles; + private final List propertySources; + + @JsonCreator + public Response(@JsonProperty("name") String name, @JsonProperty("profiles") List profiles, + @JsonProperty("propertySources") List propertySources) { + this.name = name; + this.profiles = profiles; + this.propertySources = propertySources; + } + + public String getName() { + return name; + } + + public List getProfiles() { + return profiles; + } + + public List getPropertySources() { + return propertySources; + } + + public static class PropertySource { + private final String name; + private final Map source; + + @JsonCreator + public PropertySource(@JsonProperty("name") String name, @JsonProperty("source") Map source) { + this.name = name; + this.source = source; + } + + public String getName() { + return name; + } + + public Map getSource() { + return source; + } + } +} diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java new file mode 100644 index 0000000000000..63abf8810fc26 --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientConfig.java @@ -0,0 +1,64 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import java.time.Duration; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +@ConfigRoot(phase = ConfigPhase.BOOTSTRAP, name = SpringCloudConfigClientConfig.NAME) +public class SpringCloudConfigClientConfig { + + protected static final String NAME = "scccc"; + + /** + * If enabled, will try to read the configuration from a Spring Cloud Config Server + */ + @ConfigItem(defaultValue = "false") + public boolean enabled; + + /** + * If set to true, the application will not stand up if it cannot obtain configuration from the Config Server + */ + @ConfigItem(defaultValue = "false") + public boolean failFast; + + /** + * The Base URI where the Spring Cloud Config Server is available + */ + @ConfigItem(defaultValue = "http://localhost:8888") + public String uri; + + /** + * The amount of time to wait when initially establishing a connection before giving up and timing out. + *

+ * Specify `0` to wait indefinitely. + */ + @ConfigItem(defaultValue = "10S") + public Duration connectionTimeout; + + /** + * The amount of time to wait for a read on a socket before an exception is thrown. + *

+ * Specify `0` to wait indefinitely. + */ + @ConfigItem(defaultValue = "60S") + public Duration readTimeout; + + /** + * The username to be used if the Config Server has BASIC Auth enabled + */ + @ConfigItem + public Optional username; + + /** + * The password to be used if the Config Server has BASIC Auth enabled + */ + @ConfigItem + public Optional password; + + public boolean usernameAndPasswordSet() { + return username.isPresent() && password.isPresent(); + } +} diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGateway.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGateway.java new file mode 100644 index 0000000000000..d071f6fb42ced --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGateway.java @@ -0,0 +1,8 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import java.io.IOException; + +interface SpringCloudConfigClientGateway { + + Response exchange(String applicationName, String profile) throws IOException; +} diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientRecorder.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientRecorder.java new file mode 100644 index 0000000000000..555b6a0035663 --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientRecorder.java @@ -0,0 +1,48 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import java.util.Collections; + +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.eclipse.microprofile.config.spi.ConfigSourceProvider; +import org.jboss.logging.Logger; + +import io.quarkus.runtime.ApplicationConfig; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.runtime.configuration.ProfileManager; + +@Recorder +public class SpringCloudConfigClientRecorder { + + private static final Logger log = Logger.getLogger(SpringCloudConfigClientRecorder.class); + + public RuntimeValue create(SpringCloudConfigClientConfig springCloudConfigClientConfig, + ApplicationConfig applicationConfig) { + if (!springCloudConfigClientConfig.enabled) { + log.debug( + "No attempt will be made to obtain configuration from the Spring Cloud Config Server because the functionality has been disabled via configuration"); + return emptyRuntimeValue(); + } + + if (!applicationConfig.name.isPresent()) { + log.warn( + "No attempt will be made to obtain configuration from the Spring Cloud Config Server because the application name has not been set. Consider setting it via 'quarkus.application.name'"); + return emptyRuntimeValue(); + } + + return new RuntimeValue<>(new SpringCloudConfigServerClientConfigSourceProvider( + springCloudConfigClientConfig, applicationConfig.name.get(), ProfileManager.getActiveProfile())); + } + + private RuntimeValue emptyRuntimeValue() { + return new RuntimeValue<>(new EmptyConfigSourceProvider()); + } + + private static class EmptyConfigSourceProvider implements ConfigSourceProvider { + + @Override + public Iterable getConfigSources(ClassLoader forClassLoader) { + return Collections.emptyList(); + } + } +} diff --git a/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigServerClientConfigSourceProvider.java b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigServerClientConfigSourceProvider.java new file mode 100644 index 0000000000000..e1fa7f16a248c --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigServerClientConfigSourceProvider.java @@ -0,0 +1,97 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.eclipse.microprofile.config.spi.ConfigSourceProvider; +import org.jboss.logging.Logger; + +public class SpringCloudConfigServerClientConfigSourceProvider implements ConfigSourceProvider { + + private static final Logger log = Logger.getLogger(SpringCloudConfigServerClientConfigSourceProvider.class); + + private final SpringCloudConfigClientConfig springCloudConfigClientConfig; + private final String applicationName; + private final String activeProfile; + + private final SpringCloudConfigClientGateway springCloudConfigClientGateway; + + public SpringCloudConfigServerClientConfigSourceProvider(SpringCloudConfigClientConfig springCloudConfigClientConfig, + String applicationName, + String activeProfile) { + this.springCloudConfigClientConfig = springCloudConfigClientConfig; + this.applicationName = applicationName; + this.activeProfile = activeProfile; + + springCloudConfigClientGateway = new DefaultSpringCloudConfigClientGateway(springCloudConfigClientConfig); + } + + @Override + public Iterable getConfigSources(ClassLoader forClassLoader) { + try { + final Response response = springCloudConfigClientGateway.exchange(applicationName, activeProfile); + final List propertySources = response.getPropertySources(); + Collections.reverse(propertySources); // reverse the property sources so we can increment the ordinal from lower priority to higher + final List result = new ArrayList<>(propertySources.size()); + for (int i = 0; i < propertySources.size(); i++) { + final Response.PropertySource propertySource = propertySources.get(i); + // Property sources obtained from Spring Cloud Config are expected to have a higher priority than even system properties + // 400 is the ordinal of SysPropConfigSource, so we use 450 here + result.add(new InMemoryConfigSource(450 + i, propertySource.getName(), propertySource.getSource())); + } + return result; + } catch (Exception e) { + final String errorMessage = "Unable to obtain configuration from Spring Cloud Config Server at " + + springCloudConfigClientConfig.uri; + if (springCloudConfigClientConfig.failFast) { + throw new RuntimeException(errorMessage, e); + } else { + log.error(errorMessage, e); + return Collections.emptyList(); + } + } + } + + private static final class InMemoryConfigSource implements ConfigSource { + + private final Map values = new HashMap<>(); + private final int ordinal; + private final String name; + + private InMemoryConfigSource(int ordinal, String name, Map source) { + this.ordinal = ordinal; + this.name = name; + this.values.putAll(source); + } + + @Override + public Map getProperties() { + return values; + } + + @Override + public Set getPropertyNames() { + return values.keySet(); + } + + @Override + public int getOrdinal() { + return ordinal; + } + + @Override + public String getValue(String propertyName) { + return values.get(propertyName); + } + + @Override + public String getName() { + return name; + } + } +} diff --git a/extensions/spring-cloud-config-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/spring-cloud-config-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..cef00f5c55ed0 --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +--- +name: "Quarkus Extension for Spring Cloud Config Client" +metadata: + keywords: + - "spring-cloud-config-client" + - "spring" + - "config" + - "configuration" + categories: + - "compatibility" + status: "preview" diff --git a/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java b/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java new file mode 100644 index 0000000000000..99ffb53b7319d --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/test/java/io/quarkus/spring/cloud/config/client/runtime/SpringCloudConfigClientGatewayTest.java @@ -0,0 +1,81 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Optional; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; + +class SpringCloudConfigClientGatewayTest { + + private static final int MOCK_SERVER_PORT = 8089; + private static final WireMockServer wireMockServer = new WireMockServer(MOCK_SERVER_PORT); + + private final SpringCloudConfigClientGateway sut = new DefaultSpringCloudConfigClientGateway( + configForTesting()); + + @BeforeAll + static void start() { + wireMockServer.start(); + } + + @AfterAll + static void stop() { + wireMockServer.stop(); + } + + @Test + void testBasicExchange() throws IOException { + final String applicationName = "foo"; + final String profile = "dev"; + wireMockServer.stubFor(WireMock.get(String.format("/%s/%s", applicationName, profile)).willReturn(WireMock + .okJson(getJsonStringForApplicationAndProfile(applicationName, profile)))); + + final Response response = sut.exchange(applicationName, profile); + + assertThat(response).isNotNull().satisfies(r -> { + assertThat(r.getName()).isEqualTo("foo"); + assertThat(r.getProfiles()).containsExactly("dev"); + assertThat(r.getPropertySources()).hasSize(4); + assertThat(r.getPropertySources().get(0)).satisfies(ps -> { + assertThat(ps.getSource()).contains(entry("bar", "spam"), entry("foo", "from foo development"), + entry("democonfigclient.message", "hello from dev profile")); + }); + assertThat(r.getPropertySources().get(1)).satisfies(ps -> { + assertThat(ps.getSource()).contains(entry("my.prop", "from application-dev.yml")); + }); + assertThat(r.getPropertySources().get(2)).satisfies(ps -> { + assertThat(ps.getSource()).contains(entry("foo", "from foo props"), + entry("democonfigclient.message", "hello spring io")); + }); + assertThat(r.getPropertySources().get(3)).satisfies(ps -> { + assertThat(ps.getSource()).contains(entry("foo", "baz")); + }); + }); + } + + private String getJsonStringForApplicationAndProfile(String applicationName, String profile) throws IOException { + return IOUtils.toString(this.getClass().getResourceAsStream(String.format("/%s-%s.json", applicationName, profile)), + Charset.defaultCharset()); + } + + private static SpringCloudConfigClientConfig configForTesting() { + SpringCloudConfigClientConfig springCloudConfigClientConfig = new SpringCloudConfigClientConfig(); + springCloudConfigClientConfig.uri = "http://localhost:" + MOCK_SERVER_PORT; + springCloudConfigClientConfig.connectionTimeout = Duration.ZERO; + springCloudConfigClientConfig.readTimeout = Duration.ZERO; + springCloudConfigClientConfig.username = Optional.empty(); + springCloudConfigClientConfig.password = Optional.empty(); + return springCloudConfigClientConfig; + } +} diff --git a/extensions/spring-cloud-config-client/runtime/src/test/resources/foo-dev.json b/extensions/spring-cloud-config-client/runtime/src/test/resources/foo-dev.json new file mode 100644 index 0000000000000..85fcf86bddc50 --- /dev/null +++ b/extensions/spring-cloud-config-client/runtime/src/test/resources/foo-dev.json @@ -0,0 +1,42 @@ +{ + + "name": "foo", + "profiles": [ + "dev" + ], + "label": "master", + "version": "bb51f4173258ae3481c61b95b503c13862ccfba7", + "state": null, + "propertySources": [ + { + "name": "https://github.com/spring-cloud-samples/config-repo/foo-dev.yml", + "source": { + "bar": "spam", + "foo": "from foo development", + "democonfigclient.message": "hello from dev profile" + } + }, + { + "name": "https://github.com/spring-cloud-samples/config-repo/application-dev.yml", + "source": { + "my.prop": "from application-dev.yml" + } + }, + { + "name": "https://github.com/spring-cloud-samples/config-repo/foo.properties", + "source": { + "democonfigclient.message": "hello spring io", + "foo": "from foo props" + } + }, + { + "name": "https://github.com/spring-cloud-samples/config-repo/application.yml", + "source": { + "info.description": "Spring Cloud Samples", + "info.url": "https://github.com/spring-cloud-samples", + "eureka.client.serviceUrl.defaultZone": "http://localhost:8761/eureka/", + "foo": "baz" + } + } + ] +} \ No newline at end of file diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index caee2b865dd63..49f786ab969a5 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -48,6 +48,7 @@ spring-web spring-data-jpa spring-boot-properties + spring-cloud-config-client infinispan-cache-jpa elytron-security elytron-security-oauth2 diff --git a/integration-tests/spring-cloud-config-client/pom.xml b/integration-tests/spring-cloud-config-client/pom.xml new file mode 100644 index 0000000000000..4c2d649130fa8 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/pom.xml @@ -0,0 +1,123 @@ + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-integration-test-spring-cloud-config-client + Quarkus - Integration Tests - Spring Cloud Config Client + + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-spring-cloud-config-client + + + io.quarkus + quarkus-junit5 + test + + + org.assertj + assertj-core + test + + + io.rest-assured + rest-assured + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${project.version} + + + + build + + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + uk.co.automatictester + wiremock-maven-plugin + + + wiremock-start + compile + + stop + run + + +

target/classes + --port=8089 --disable-banner + + + + wiremock-stop + post-integration-test + + stop + + + + + + com.github.tomakehurst + wiremock + 2.25.1 + + + + + + + native + + + + \ No newline at end of file diff --git a/integration-tests/spring-cloud-config-client/src/main/java/io/quarkus/it/spring/config/server/client/GreetingResource.java b/integration-tests/spring-cloud-config-client/src/main/java/io/quarkus/it/spring/config/server/client/GreetingResource.java new file mode 100644 index 0000000000000..5c8603cdf1b60 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/src/main/java/io/quarkus/it/spring/config/server/client/GreetingResource.java @@ -0,0 +1,18 @@ +package io.quarkus.it.spring.config.server.client; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@Path("/greeting") +public class GreetingResource { + + @ConfigProperty(name = "greeting.message") + String message; + + @GET + public String greet() { + return message; + } +} diff --git a/integration-tests/spring-cloud-config-client/src/main/resources/__files/a-bootiful-client-prod.json b/integration-tests/spring-cloud-config-client/src/main/resources/__files/a-bootiful-client-prod.json new file mode 100644 index 0000000000000..ab01d933fac00 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/src/main/resources/__files/a-bootiful-client-prod.json @@ -0,0 +1,26 @@ +{ + "name": "a-bootiful-client", + "profiles": [ + "prod" + ], + "label": "master", + "version": "bb51f4173258ae3481c61b95b503c13862ccfba7", + "state": null, + "propertySources": [ + { + "name": "https://github.com/spring-cloud-samples/config-repo/testapp-prod.yml", + "source": { + "bar": "spam", + "foo": "from foo development", + "greeting.message": "hello from spring cloud config server" + } + }, + { + "name": "https://github.com/spring-cloud-samples/config-repo/application.yml", + "source": { + "info.description": "Sample", + "foo": "baz" + } + } + ] +} \ No newline at end of file diff --git a/integration-tests/spring-cloud-config-client/src/main/resources/application.properties b/integration-tests/spring-cloud-config-client/src/main/resources/application.properties new file mode 100644 index 0000000000000..7e46df5c06ca3 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/src/main/resources/application.properties @@ -0,0 +1,7 @@ +quarkus.application.name=a-bootiful-client +%prod.quarkus.scccc.uri=http://localhost:8089 +%prod.quarkus.scccc.username=user +%prod.quarkus.scccc.password=pass +%prod.quarkus.scccc.enabled=true + +greeting.message=hello from application.properties \ No newline at end of file diff --git a/integration-tests/spring-cloud-config-client/src/main/resources/mappings/a-bootiful-client-prod.json b/integration-tests/spring-cloud-config-client/src/main/resources/mappings/a-bootiful-client-prod.json new file mode 100644 index 0000000000000..c297faba491d6 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/src/main/resources/mappings/a-bootiful-client-prod.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/a-bootiful-client/prod", + "basicAuth" : { + "username" : "user", + "password" : "pass" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "a-bootiful-client-prod.json" + } +} \ No newline at end of file diff --git a/integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceIT.java b/integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceIT.java new file mode 100644 index 0000000000000..7b169a30b08e5 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceIT.java @@ -0,0 +1,12 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import io.quarkus.test.junit.NativeImageTest; + +@NativeImageTest +public class GreetingResourceIT extends GreetingResourceTest { + + @Override + protected String getExpectedValue() { + return "hello from spring cloud config server"; + } +} diff --git a/integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceTest.java b/integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceTest.java new file mode 100644 index 0000000000000..50aabe5bac9d6 --- /dev/null +++ b/integration-tests/spring-cloud-config-client/src/test/java/io/quarkus/spring/cloud/config/client/runtime/GreetingResourceTest.java @@ -0,0 +1,26 @@ +package io.quarkus.spring.cloud.config.client.runtime; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +class GreetingResourceTest { + + @Test + void testGreeting() { + given() + .when().get("/greeting") + .then() + .statusCode(200) + .body(is(getExpectedValue())); + + } + + protected String getExpectedValue() { + return "hello from application.properties"; + } +}