diff --git a/.travis.yml b/.travis.yml index 4139a57..b31c207 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ +services: + - docker before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ diff --git a/README.md b/README.md index 23b5833..1e96b00 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Baigan configuration is an easy to use framework for Java applications allowing Its simple but extensible interfaces allow for powerful integrations. -* fetch your configuration from arbitrary sources serving any format (e.g. local files, S3, ...) +* fetch your configuration from arbitrary sources serving any format (e.g. local files, S3, etcd ...) * integrate with the [Spring Framework](https://spring.io/) enabling access to your configuration by simply annotating an interface. * integrate with Baigan configuration in seconds, via the provided [Spring Boot](https://spring.io/projects/spring-boot) Auto-configuration library ## Usage -The following example makes use of the [File](file), [S3](s3) and [Spring Boot](spring-boot-autoconfigure) module. +The following example makes use of the [File](file), [S3](s3), [etcd](etcd) and [Spring Boot](spring-boot-autoconfigure) module. Usage of Baigan configuration is as easy as: @@ -56,12 +56,16 @@ class StoreConfiguration { .cached(ofMinutes(2)) .onLocalFile(Path.of("example.yaml")) .asYaml(), - "OtherConfiguration", chain( + "BackendConfiguration", chain( FileStores.builder() .cached(ofMinutes(3)) .on(s3("my-bucket", "config.json")) .asJson(), - new CustomInMemoryStore()) + new CustomInMemoryStore()), + "BusinessConfiguration", FileStores.builder() + .cached(ofMinutes(25)) + .on(etcd(URI.create("http://etcd/v2/keys/configuration"))) + .asYaml() )); } } @@ -88,6 +92,7 @@ Baigan configuration comes with a set of powerful integrations. * The [Spring Boot](spring-boot-autoconfigure) module simplifies configuration in Spring applications. * The [File](file) module provides a `ConfigurationStore` implementation serving from JSON and YAML files. * The [S3](s3) module allows for fetching configuration files from S3 buckets. +* The [etcd](etcd) module allows using an etcd cluster as the configuration backend. ## Development @@ -96,3 +101,5 @@ To build the project run ```bash $ ./gradlew clean check ``` + +Be aware that the build requires Docker. diff --git a/core/src/main/java/org/zalando/baigan/CachedConfigurationStore.java b/core/src/main/java/org/zalando/baigan/CachedConfigurationStore.java new file mode 100644 index 0000000..daa8676 --- /dev/null +++ b/core/src/main/java/org/zalando/baigan/CachedConfigurationStore.java @@ -0,0 +1,132 @@ +package org.zalando.baigan; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static java.time.Clock.systemUTC; +import static java.util.Objects.requireNonNull; + +public final class CachedConfigurationStore implements ConfigurationStore { + + private static final Logger LOG = LoggerFactory.getLogger(CachedConfigurationStore.class); + + private final ConcurrentMap cache = new ConcurrentHashMap<>(20); + private final ConfigurationStore delegate; + private final Duration cacheEntryLifetime; + private final Clock clock; + + public CachedConfigurationStore(final Duration cacheEntryLifetime, final ConfigurationStore delegate) { + this(systemUTC(), cacheEntryLifetime, delegate); + } + + CachedConfigurationStore(final Clock clock, final Duration cacheEntryLifetime, final ConfigurationStore delegate) { + this.cacheEntryLifetime = cacheEntryLifetime; + this.clock = clock; + this.delegate = delegate; + } + + @Override + public Optional getConfiguration(final String namespace, final String key) { + final CacheEntry entry = fetchFromCache(namespace, key); + return Optional.ofNullable(entry).map(CacheEntry::getConfiguration); + } + + private CacheEntry fetchFromCache(final String namespace, final String key) { + final NamespacedKey cacheKey = new NamespacedKey(namespace, key); + + final Instant now = Instant.now(clock); + final CacheEntry entry = computeIfAbsent(cacheKey, now); + if (entry != null && isExpired(entry, now)) { + LOG.debug("Cache entry [{}] is expired, re-computing.", entry); + cache.remove(cacheKey, entry); + return computeIfAbsent(cacheKey, now); + } + return entry; + } + + private boolean isExpired(final CacheEntry entry, final Instant now) { + final Duration between = Duration.between(entry.getCachedAt(), now).abs(); + return between.compareTo(cacheEntryLifetime) > 0; + } + + private CacheEntry computeIfAbsent(final NamespacedKey key, final Instant now) { + return cache.computeIfAbsent(key, $ -> + delegate.getConfiguration(key.getNamespace(), key.getKey()).map(c -> + new CacheEntry(now, c)).orElse(null)); + } + + private static final class CacheEntry { + private final Instant cachedAt; + private final Configuration configuration; + + private CacheEntry(final Instant cachedAt, final Configuration configuration) { + this.cachedAt = cachedAt; + this.configuration = configuration; + } + + Instant getCachedAt() { + return cachedAt; + } + + Configuration getConfiguration() { + return configuration; + } + + @Override + public String toString() { + return new StringJoiner(", ", CacheEntry.class.getSimpleName() + "[", "]") + .add("cachedAt=" + cachedAt) + .add("configuration=" + configuration) + .toString(); + } + } + + private static final class NamespacedKey { + private final String namespace; + private final String key; + + private NamespacedKey(final String namespace, final String key) { + this.namespace = requireNonNull(namespace); + this.key = requireNonNull(key); + } + + String getNamespace() { + return namespace; + } + + String getKey() { + return key; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final NamespacedKey that = (NamespacedKey) o; + return namespace.equals(that.namespace) && + key.equals(that.key); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, key); + } + + @Override + public String toString() { + return new StringJoiner(", ", NamespacedKey.class.getSimpleName() + "[", "]") + .add("namespace='" + namespace + "'") + .add("key='" + key + "'") + .toString(); + } + } + +} \ No newline at end of file diff --git a/core/src/main/java/org/zalando/baigan/Configuration.java b/core/src/main/java/org/zalando/baigan/Configuration.java index 34005b8..78d21fa 100644 --- a/core/src/main/java/org/zalando/baigan/Configuration.java +++ b/core/src/main/java/org/zalando/baigan/Configuration.java @@ -1,5 +1,7 @@ package org.zalando.baigan; +import java.util.StringJoiner; + import static java.util.Objects.requireNonNull; public final class Configuration { @@ -25,4 +27,13 @@ public String getDescription() { public T getValue() { return value; } + + @Override + public String toString() { + return new StringJoiner(", ", Configuration.class.getSimpleName() + "[", "]") + .add("key='" + key + "'") + .add("description='" + description + "'") + .add("value=" + value) + .toString(); + } } diff --git a/core/src/main/java/org/zalando/baigan/ConfigurationStore.java b/core/src/main/java/org/zalando/baigan/ConfigurationStore.java index 4aa521a..2c5ecf1 100644 --- a/core/src/main/java/org/zalando/baigan/ConfigurationStore.java +++ b/core/src/main/java/org/zalando/baigan/ConfigurationStore.java @@ -1,8 +1,15 @@ package org.zalando.baigan; import java.util.Optional; +import java.util.function.BiFunction; -public interface ConfigurationStore { +@FunctionalInterface +public interface ConfigurationStore extends BiFunction> { + + @Override + default Optional apply(String namespace, String key) { + return getConfiguration(namespace, key); + } @SuppressWarnings("unchecked") default Optional> getTypedConfiguration(final String namespace, final String key) { diff --git a/core/src/test/java/org/zalando/baigan/CachedConfigurationStoreTest.java b/core/src/test/java/org/zalando/baigan/CachedConfigurationStoreTest.java new file mode 100644 index 0000000..973f951 --- /dev/null +++ b/core/src/test/java/org/zalando/baigan/CachedConfigurationStoreTest.java @@ -0,0 +1,96 @@ +package org.zalando.baigan; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static java.time.Duration.ofMinutes; + +class CachedConfigurationStoreTest { + + private final MockClock clock = new MockClock(Instant.now()); + private final ConfigurationStore delegate = mock(ConfigurationStore.class); + private final CachedConfigurationStore store = new CachedConfigurationStore(clock, ofMinutes(1), delegate); + + @BeforeEach + void setUp() { + when(store.getConfiguration("ns", "key")) + .thenReturn(Optional.of(new Configuration<>("ns.key", "description", "value"))); + } + + @Test + void cachesConfiguration() { + store.getConfiguration("ns", "key"); + store.getConfiguration("ns", "key"); + + verify(delegate).getConfiguration("ns", "key"); + verifyNoMoreInteractions(delegate); + } + + @Test + void expiresCacheEntry() { + store.getConfiguration("ns", "key"); + clock.forward(ofMinutes(2)); + store.getConfiguration("ns", "key"); + + verify(delegate, times(2)).getConfiguration("ns", "key"); + verifyNoMoreInteractions(delegate); + } + + @Test + void cachesAfterExpiringCacheEntry() { + store.getConfiguration("ns", "key"); + clock.forward(ofMinutes(2)); + store.getConfiguration("ns", "key"); + store.getConfiguration("ns", "key"); + + verify(delegate, times(2)).getConfiguration("ns", "key"); + verifyNoMoreInteractions(delegate); + } + + @Test + void doesNotCacheMisses() { + assertEquals(Optional.empty(), store.getConfiguration("ns", "missing")); + assertEquals(Optional.empty(), store.getConfiguration("ns", "missing")); + + verify(delegate, times(2)).getConfiguration("ns", "missing"); + verifyNoMoreInteractions(delegate); + } + + private static class MockClock extends Clock { + private Instant instant; + + MockClock(final Instant instant) { + this.instant = instant; + } + + void forward(final Duration duration) { + this.instant = instant.plus(duration); + } + + @Override + public Instant instant() { + return instant; + } + + @Override + public ZoneId getZone() { + throw new UnsupportedOperationException(); + } + + @Override + public Clock withZone(final ZoneId zone) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/etcd/README.md b/etcd/README.md new file mode 100644 index 0000000..f7160f3 --- /dev/null +++ b/etcd/README.md @@ -0,0 +1,78 @@ +# Baigan configuration (etcd module) + +[![Maven Central](https://img.shields.io/maven-central/v/org.zalando/baigan-config.svg)](https://maven-badges.herokuapp.com/maven-central/org.zalando/baigan-config) +[![Build Status](https://img.shields.io/travis/lukasniemeier-zalando/baigan-config/master.svg)](https://travis-ci.org/lukasniemeier-zalando/baigan-config) + +Baigan configuration module enabling [etcd](https://github.com/etcd-io/etcd) as the configuration backend. + +* Serve complete *configuration files* from an etcd cluster (via `FilesStores` of the [File](../file) module). +* Serve individual configuration entries from an etcd cluster. + +## Usage + +Define your `ConfigurationStore` serving individual configuration entries from an etcd cluster: + +```java +class Example { + static ConfigurationStore store() { + return EtcdStores.builder() + .cached(Duration.ofMinutes(15)) + .on(URI.create("http://etcd/v2/keys/")) + .asYaml(); + } +} +``` + +In above's example a configuration in namespace `Business` with key `feature` will be resolved via `"http://etcd/v2/keys/Business.feature"`. +A valid example configuration stored in etcd looks like this: + +```yaml +value: true +description: My example business feature toggle. +``` + +Such configuration entries must adhere to the following JSON schema: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A baigan configuration entry.", + "properties": { + "description": { + "description": "Summary of the configuration.", + "type": "string" + }, + "value": { + "description": "The configuration's value." + } + }, + "required": [ + "value", + "description" + ], + "type": "object" +} +``` + +### Serving *configuration files* + +Alternatively you may want to serve a complete *configuration file* from your etcd cluster. +This module provides an integration with the [File](../file) module to support this: + +```java +class Example { + static ConfigurationStore store() { + return FileStores.builder() + .cached(ofMinutes(25)) + .on(etcd(URI.create("http://etcd/v2/keys/configuration"))) + .asYaml(); + } +} +``` + +Above's example expects a *configuration file* serialized as YAML at etcd key `configuration`. + +## Dependencies + +* Jackson Databind 2.9+ +* Jackson Dataformat YAML 2.9+ diff --git a/etcd/build.gradle b/etcd/build.gradle new file mode 100644 index 0000000..610fe18 --- /dev/null +++ b/etcd/build.gradle @@ -0,0 +1,8 @@ +dependencies { + api project(':core') + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: versions.jackson + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: versions.jackson + implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: versions.jackson + + testCompile group: 'org.testcontainers', name: 'junit-jupiter', version: '1.10.6' +} diff --git a/etcd/src/main/java/org/zalando/baigan/etcd/ConfigurationHolder.java b/etcd/src/main/java/org/zalando/baigan/etcd/ConfigurationHolder.java new file mode 100644 index 0000000..c87524c --- /dev/null +++ b/etcd/src/main/java/org/zalando/baigan/etcd/ConfigurationHolder.java @@ -0,0 +1,35 @@ +package org.zalando.baigan.etcd; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.StringJoiner; + +import static java.util.Objects.requireNonNull; + +final class ConfigurationHolder { + private final String description; + private final Object value; + + @JsonCreator + ConfigurationHolder(@JsonProperty("description") final String description, @JsonProperty("value") final Object value) { + this.description = requireNonNull(description, "Required description is missing"); + this.value = requireNonNull(value, "Required value is missing"); + } + + String getDescription() { + return description; + } + + Object getValue() { + return value; + } + + @Override + public String toString() { + return new StringJoiner(", ", ConfigurationHolder.class.getSimpleName() + "[", "]") + .add("description='" + description + "'") + .add("value=" + value) + .toString(); + } +} diff --git a/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationFileSupplier.java b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationFileSupplier.java new file mode 100644 index 0000000..1da7cfe --- /dev/null +++ b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationFileSupplier.java @@ -0,0 +1,24 @@ +package org.zalando.baigan.etcd; + +import java.io.FileNotFoundException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +final class EtcdConfigurationFileSupplier implements Supplier { + + private final Function> valueFetcher; + private final URI uri; + + EtcdConfigurationFileSupplier(final Function> valueFetcher, final URI uri) { + this.valueFetcher = valueFetcher; + this.uri = uri; + } + + @Override + public String get() { + return valueFetcher.apply(uri).orElseThrow(() -> new UncheckedIOException(new FileNotFoundException("No configuration file at given URI"))); + } +} diff --git a/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationHolderMapper.java b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationHolderMapper.java new file mode 100644 index 0000000..2c07476 --- /dev/null +++ b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationHolderMapper.java @@ -0,0 +1,26 @@ +package org.zalando.baigan.etcd; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.zalando.baigan.Configuration; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.function.BiFunction; + +final class EtcdConfigurationHolderMapper implements BiFunction { + + private final ObjectMapper mapper; + + EtcdConfigurationHolderMapper(final ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public Configuration apply(final String key, final String value) { + try { + final ConfigurationHolder holder = mapper.readValue(value, ConfigurationHolder.class); + return new Configuration<>(key, holder.getDescription(), holder.getValue()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationStore.java b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationStore.java new file mode 100644 index 0000000..7c6b3fc --- /dev/null +++ b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdConfigurationStore.java @@ -0,0 +1,32 @@ +package org.zalando.baigan.etcd; + +import org.zalando.baigan.Configuration; +import org.zalando.baigan.ConfigurationStore; +import java.net.URI; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; + +final class EtcdConfigurationStore implements ConfigurationStore { + + private final Function> valueFetcher; + private final BiFunction configurationMapper; + private final URI baseUri; + + EtcdConfigurationStore(final Function> valueFetcher, + final BiFunction configurationMapper, + final URI baseUri) { + this.valueFetcher = valueFetcher; + this.configurationMapper = configurationMapper; + this.baseUri = baseUri; + } + + @Override + public Optional getConfiguration(final String namespace, final String key) { + final URI fullUri = baseUri.resolve(namespace + "." + key); + + return valueFetcher.apply(fullUri) + .map(node -> configurationMapper.apply(key, node)); + } + +} diff --git a/etcd/src/main/java/org/zalando/baigan/etcd/EtcdStores.java b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdStores.java new file mode 100644 index 0000000..e32f7fd --- /dev/null +++ b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdStores.java @@ -0,0 +1,56 @@ +package org.zalando.baigan.etcd; + +import com.fasterxml.jackson.databind.MappingJsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.zalando.baigan.CachedConfigurationStore; +import org.zalando.baigan.Configuration; +import org.zalando.baigan.ConfigurationStore; +import java.net.URI; +import java.time.Duration; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +public final class EtcdStores { + + public static StoreBuilder builder() { + return duration -> uri -> mapper -> new CachedConfigurationStore( + duration, + new EtcdConfigurationStore( + new EtcdValueFetcher(new ObjectMapper()), + mapper, + uri)); + } + + public interface StoreBuilder { + UriBuilder cached(final Duration duration); + + interface UriBuilder { + + FormatBuilder on(final URI uri); + + interface FormatBuilder { + ConfigurationStore as(final BiFunction mapper); + + default ConfigurationStore asJson() { + return as(new EtcdConfigurationHolderMapper(new ObjectMapper(new MappingJsonFactory()))); + } + + default ConfigurationStore asYaml() { + return as(new EtcdConfigurationHolderMapper(new ObjectMapper(new YAMLFactory()))); + } + } + } + } + + private EtcdStores() { + // utility class + } + + public static Supplier etcd(final URI uri) { + return new EtcdConfigurationFileSupplier( + new EtcdValueFetcher(new ObjectMapper()), + uri); + } + +} diff --git a/etcd/src/main/java/org/zalando/baigan/etcd/EtcdValueFetcher.java b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdValueFetcher.java new file mode 100644 index 0000000..5980db5 --- /dev/null +++ b/etcd/src/main/java/org/zalando/baigan/etcd/EtcdValueFetcher.java @@ -0,0 +1,69 @@ +package org.zalando.baigan.etcd; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.util.Optional; +import java.util.function.Function; + +class EtcdValueFetcher implements Function> { + + private final ObjectMapper mapper; + + EtcdValueFetcher(final ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public Optional apply(final URI uri) { + try { + final ResultNode node = mapper.readValue(uri.toURL(), ResultNode.class); + return Optional.of(node.getNode().getValue()); + } catch (final FileNotFoundException e) { + return Optional.empty(); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static final class ResultNode { + + private final String action; + private final KeyNode node; + + @JsonCreator + ResultNode(@JsonProperty("action") final String action, @JsonProperty("node") final KeyNode node) { + this.action = action; + this.node = node; + } + + String getAction() { + return action; + } + + KeyNode getNode() { + return node; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static final class KeyNode { + + private final String value; + + @JsonCreator + KeyNode(@JsonProperty("value") final String value) { + this.value = value; + } + + String getValue() { + return value; + } + } + } +} diff --git a/etcd/src/test/java/org/zalando/baigan/etcd/EtcdConfigurationStoreIntegrationTest.java b/etcd/src/test/java/org/zalando/baigan/etcd/EtcdConfigurationStoreIntegrationTest.java new file mode 100644 index 0000000..2d201f9 --- /dev/null +++ b/etcd/src/test/java/org/zalando/baigan/etcd/EtcdConfigurationStoreIntegrationTest.java @@ -0,0 +1,68 @@ +package org.zalando.baigan.etcd; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.zalando.baigan.Configuration; +import org.zalando.baigan.ConfigurationStore; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static java.net.URLEncoder.encode; +import static java.net.http.HttpRequest.BodyPublishers.ofString; +import static java.nio.charset.StandardCharsets.UTF_8; + +@Testcontainers +class EtcdConfigurationStoreIntegrationTest { + + private static final int ETCD_PORT = 2379; + + @Container + private final DockerComposeContainer container = new DockerComposeContainer( + new File("src/test/resources/compose-etcd.yaml")) + .withExposedService("etcd", ETCD_PORT); + + @Test + void simpleTest() throws Exception { + final String host = container.getServiceHost("etcd", ETCD_PORT); + final int port = container.getServicePort("etcd", ETCD_PORT); + + final URI baseUri = URI.create("http://" + host + ":" + port + "/v2/keys/"); + final String expectedValue = "{'value':'foobar','description':'desc'}".replace('\'', '"'); + final String namespace = "ns1"; + final String key = "key1"; + + putEtcdValue(baseUri.resolve(namespace + "." + key), expectedValue); + + final ObjectMapper mapper = new ObjectMapper(); + final ConfigurationStore store = EtcdStores.builder() + .cached(Duration.ofSeconds(2)) + .on(baseUri) + .asJson(); + final Optional configuration = store.getConfiguration(namespace, key); + + assertEquals("foobar", configuration.map(Configuration::getValue).orElse("nope")); + } + + private void putEtcdValue(final URI uri, final String value) throws IOException, InterruptedException { + final HttpClient client = HttpClient.newHttpClient(); + final HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .header("Content-Type", "application/x-www-form-urlencoded") + .PUT(ofString("value=" + encode(value, UTF_8))) + .build(); + + final Integer status = client.send(request, BodyHandlers.ofString()).statusCode(); + assertEquals(201, status); + } + +} diff --git a/etcd/src/test/resources/compose-etcd.yaml b/etcd/src/test/resources/compose-etcd.yaml new file mode 100644 index 0000000..aa0fe4d --- /dev/null +++ b/etcd/src/test/resources/compose-etcd.yaml @@ -0,0 +1,16 @@ +version: '2' +services: + etcd: + image: quay.io/coreos/etcd:v3.1.20 + entrypoint: /usr/local/bin/etcd + command: ['--name=etcd', + '--listen-client-urls=http://etcd:2379,http://localhost:2379', + '--advertise-client-urls=http://etcd:2379'] + ports: + - 2379 + expose: + - 2379 + volumes: + - etcd:/etcd_data +volumes: + etcd: diff --git a/example/build.gradle b/example/build.gradle index a2f8a8b..c70d312 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -24,6 +24,7 @@ application { dependencies { implementation project(':file') implementation project(':s3') + implementation project(':etcd') implementation project(':spring-boot-autoconfigure') implementation("org.springframework.boot:spring-boot-starter") diff --git a/example/src/main/java/org/zalando/baigan/example/Application.java b/example/src/main/java/org/zalando/baigan/example/Application.java index a8eec41..3ffae3d 100644 --- a/example/src/main/java/org/zalando/baigan/example/Application.java +++ b/example/src/main/java/org/zalando/baigan/example/Application.java @@ -2,21 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.zalando.baigan.ConfigurationStore; -import org.zalando.baigan.file.FileStores; -import java.nio.file.Path; -import java.time.Duration; @SpringBootApplication class Application { public static void main(final String[] args) { SpringApplication.run(Application.class, args); - final ConfigurationStore store = FileStores.builder() - .cached(Duration.ofMinutes(5)) - .onLocalFile(Path.of("configuration.yaml")) - .asYaml(); - } } diff --git a/file/README.md b/file/README.md index 47a2fa5..ef67962 100644 --- a/file/README.md +++ b/file/README.md @@ -59,10 +59,10 @@ Such configuration file must adhere to the following JSON schema: "type": "object", "properties": { "value": { - "descrption": "The configuration's value." + "description": "The configuration's value." }, "description": { - "descrption": "Summary of the configuration.", + "description": "Summary of the configuration.", "type": "string" } }, diff --git a/file/build.gradle b/file/build.gradle index ac45ca7..28cdfbf 100644 --- a/file/build.gradle +++ b/file/build.gradle @@ -1,10 +1,6 @@ -compileJava { - options.compilerArgs << '-parameters' -} - dependencies { api project(':core') implementation group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: versions.jackson implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: versions.jackson implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: versions.jackson -} \ No newline at end of file +} diff --git a/file/src/main/java/org/zalando/baigan/file/CachingConfigurationFileSupplier.java b/file/src/main/java/org/zalando/baigan/file/CachingConfigurationFileSupplier.java index 6c1e834..e2fa46e 100644 --- a/file/src/main/java/org/zalando/baigan/file/CachingConfigurationFileSupplier.java +++ b/file/src/main/java/org/zalando/baigan/file/CachingConfigurationFileSupplier.java @@ -36,6 +36,7 @@ private void scheduleRefresh(final ScheduledExecutorService executor, final Dura private void refreshCache() { try { + LOG.debug("Refreshing cache..."); cached.set(delegate.get()); } catch (final RuntimeException e) { LOG.error("Unable to refresh cache, keeping old value.", e); diff --git a/file/src/main/java/org/zalando/baigan/file/ConfigurationFileSupplier.java b/file/src/main/java/org/zalando/baigan/file/ConfigurationFileAdapter.java similarity index 68% rename from file/src/main/java/org/zalando/baigan/file/ConfigurationFileSupplier.java rename to file/src/main/java/org/zalando/baigan/file/ConfigurationFileAdapter.java index 2c2f993..7a67aa4 100644 --- a/file/src/main/java/org/zalando/baigan/file/ConfigurationFileSupplier.java +++ b/file/src/main/java/org/zalando/baigan/file/ConfigurationFileAdapter.java @@ -3,12 +3,12 @@ import java.util.function.Function; import java.util.function.Supplier; -final class ConfigurationFileSupplier implements Supplier { +final class ConfigurationFileAdapter implements Supplier { private final Supplier supplier; private final Function mapper; - ConfigurationFileSupplier(final Supplier supplier, final Function mapper) { + ConfigurationFileAdapter(final Supplier supplier, final Function mapper) { this.supplier = supplier; this.mapper = mapper; } diff --git a/file/src/main/java/org/zalando/baigan/file/JacksonConfigurationFileMapper.java b/file/src/main/java/org/zalando/baigan/file/ConfigurationFileMapper.java similarity index 77% rename from file/src/main/java/org/zalando/baigan/file/JacksonConfigurationFileMapper.java rename to file/src/main/java/org/zalando/baigan/file/ConfigurationFileMapper.java index 85e813d..130bab3 100644 --- a/file/src/main/java/org/zalando/baigan/file/JacksonConfigurationFileMapper.java +++ b/file/src/main/java/org/zalando/baigan/file/ConfigurationFileMapper.java @@ -5,11 +5,11 @@ import java.io.UncheckedIOException; import java.util.function.Function; -class JacksonConfigurationFileMapper implements Function { +final class ConfigurationFileMapper implements Function { private final ObjectMapper mapper; - JacksonConfigurationFileMapper(final ObjectMapper objectMapper) { + ConfigurationFileMapper(final ObjectMapper objectMapper) { this.mapper = objectMapper; } diff --git a/file/src/main/java/org/zalando/baigan/file/FileBasedConfigurationStore.java b/file/src/main/java/org/zalando/baigan/file/FileBasedConfigurationStore.java index ab7a3e9..f51bfe9 100644 --- a/file/src/main/java/org/zalando/baigan/file/FileBasedConfigurationStore.java +++ b/file/src/main/java/org/zalando/baigan/file/FileBasedConfigurationStore.java @@ -7,7 +7,7 @@ import static java.util.Optional.ofNullable; -class FileBasedConfigurationStore implements ConfigurationStore { +final class FileBasedConfigurationStore implements ConfigurationStore { private final Supplier configurationSupplier; diff --git a/file/src/main/java/org/zalando/baigan/file/FileStores.java b/file/src/main/java/org/zalando/baigan/file/FileStores.java index b1e3a8f..c1287ac 100644 --- a/file/src/main/java/org/zalando/baigan/file/FileStores.java +++ b/file/src/main/java/org/zalando/baigan/file/FileStores.java @@ -15,7 +15,7 @@ public static StoreBuilder builder() { return duration -> supplier -> mapper -> new FileBasedConfigurationStore( new CachingConfigurationFileSupplier( duration, - new ConfigurationFileSupplier( + new ConfigurationFileAdapter( supplier, mapper))); } @@ -28,18 +28,18 @@ interface SupplierBuilder { FormatBuilder on(final Supplier supplier); default FormatBuilder onLocalFile(final Path path) { - return on(new LocalFileSupplier(path)); + return on(new LocalConfigurationFileSupplier(path)); } interface FormatBuilder { ConfigurationStore asFormat(final Function mapper); default ConfigurationStore asJson() { - return asFormat(new JacksonConfigurationFileMapper(new ObjectMapper(new MappingJsonFactory()))); + return asFormat(new ConfigurationFileMapper(new ObjectMapper(new MappingJsonFactory()))); } default ConfigurationStore asYaml() { - return asFormat(new JacksonConfigurationFileMapper(new ObjectMapper(new YAMLFactory()))); + return asFormat(new ConfigurationFileMapper(new ObjectMapper(new YAMLFactory()))); } } } diff --git a/file/src/main/java/org/zalando/baigan/file/LocalFileSupplier.java b/file/src/main/java/org/zalando/baigan/file/LocalConfigurationFileSupplier.java similarity index 80% rename from file/src/main/java/org/zalando/baigan/file/LocalFileSupplier.java rename to file/src/main/java/org/zalando/baigan/file/LocalConfigurationFileSupplier.java index 9072096..33521e5 100644 --- a/file/src/main/java/org/zalando/baigan/file/LocalFileSupplier.java +++ b/file/src/main/java/org/zalando/baigan/file/LocalConfigurationFileSupplier.java @@ -8,11 +8,11 @@ import static java.lang.String.join; import static java.nio.file.Files.readAllLines; -final class LocalFileSupplier implements Supplier { +final class LocalConfigurationFileSupplier implements Supplier { private final Path path; - LocalFileSupplier(final Path path) { + LocalConfigurationFileSupplier(final Path path) { this.path = path; } diff --git a/file/src/test/java/org/zalando/baigan/file/ConfigurationFileSupplierTest.java b/file/src/test/java/org/zalando/baigan/file/ConfigurationFileAdapterTest.java similarity index 85% rename from file/src/test/java/org/zalando/baigan/file/ConfigurationFileSupplierTest.java rename to file/src/test/java/org/zalando/baigan/file/ConfigurationFileAdapterTest.java index 5b45f77..244ce6d 100644 --- a/file/src/test/java/org/zalando/baigan/file/ConfigurationFileSupplierTest.java +++ b/file/src/test/java/org/zalando/baigan/file/ConfigurationFileAdapterTest.java @@ -9,11 +9,11 @@ import static org.mockito.Mockito.when; @SuppressWarnings("unchecked") -class ConfigurationFileSupplierTest { +class ConfigurationFileAdapterTest { private final Supplier supplier = mock(Supplier.class); private final Function mapper = mock(Function.class); - private final ConfigurationFileSupplier unit = new ConfigurationFileSupplier(supplier, mapper); + private final ConfigurationFileAdapter unit = new ConfigurationFileAdapter(supplier, mapper); @Test void mapsSuppliedContent() { diff --git a/file/src/test/java/org/zalando/baigan/file/JacksonConfigurationFileMapperTest.java b/file/src/test/java/org/zalando/baigan/file/ConfigurationFileMapperTest.java similarity index 89% rename from file/src/test/java/org/zalando/baigan/file/JacksonConfigurationFileMapperTest.java rename to file/src/test/java/org/zalando/baigan/file/ConfigurationFileMapperTest.java index a738a2a..e48cea6 100644 --- a/file/src/test/java/org/zalando/baigan/file/JacksonConfigurationFileMapperTest.java +++ b/file/src/test/java/org/zalando/baigan/file/ConfigurationFileMapperTest.java @@ -12,10 +12,10 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -class JacksonConfigurationFileMapperTest { +class ConfigurationFileMapperTest { private final ObjectMapper mapper = mock(ObjectMapper.class); - private final JacksonConfigurationFileMapper unit = new JacksonConfigurationFileMapper(mapper); + private final ConfigurationFileMapper unit = new ConfigurationFileMapper(mapper); @Test void mapsValueToConfiguration() throws IOException { diff --git a/file/src/test/java/org/zalando/baigan/file/LocalFileSupplierTest.java b/file/src/test/java/org/zalando/baigan/file/LocalConfigurationFileSupplierTest.java similarity index 72% rename from file/src/test/java/org/zalando/baigan/file/LocalFileSupplierTest.java rename to file/src/test/java/org/zalando/baigan/file/LocalConfigurationFileSupplierTest.java index 207d1ed..adf8295 100644 --- a/file/src/test/java/org/zalando/baigan/file/LocalFileSupplierTest.java +++ b/file/src/test/java/org/zalando/baigan/file/LocalConfigurationFileSupplierTest.java @@ -8,9 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static java.lang.ClassLoader.getSystemResource; -class LocalFileSupplierTest { +class LocalConfigurationFileSupplierTest { - private final LocalFileSupplier unit = unit(); + private final LocalConfigurationFileSupplier unit = unit(); @Test void keepsNewlines() { @@ -24,9 +24,9 @@ void readsFile() { assertTrue(content.contains("42")); } - private LocalFileSupplier unit() { + private LocalConfigurationFileSupplier unit() { try { - return new LocalFileSupplier(Paths.get(getSystemResource("example.json").toURI())); + return new LocalConfigurationFileSupplier(Paths.get(getSystemResource("example.json").toURI())); } catch (final URISyntaxException e) { throw new RuntimeException(e); } diff --git a/settings.gradle b/settings.gradle index 7544d32..1d0a149 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ rootProject.name = 'baigan' -include 'core', 'file', 's3', 'spring', 'spring-boot-autoconfigure', 'example' \ No newline at end of file +include 'core', 'file', 's3', 'etcd', 'spring', 'spring-boot-autoconfigure', 'example'