From c3adbc57de36e6a77c81fa85ca80655f048742a6 Mon Sep 17 00:00:00 2001 From: Ayushman-Gaur <120575155+Ayushman-Gaur@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:45:45 +0530 Subject: [PATCH 01/22] fix: fixing null pointer exception error (#1709) Signed-off-by: Ayushman Gaur Signed-off-by: Mahesh Patil --- .../contrib/tools/flagd/core/FlagdCore.java | 9 +++++--- .../tools/flagd/core/FlagdCoreTest.java | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java index 6fb1869ad..bcf320637 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java @@ -270,11 +270,14 @@ private ProviderEvaluation resolve(Class type, String key, T defaultVa private static ImmutableMetadata getFlagMetadata(Map currentFlagSetMetadata, FeatureFlag flag) { ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder(); - for (Map.Entry entry : currentFlagSetMetadata.entrySet()) { - addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + + if (currentFlagSetMetadata != null) { + for (Map.Entry entry : currentFlagSetMetadata.entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } } - if (flag != null) { + if (flag != null && flag.getMetadata() != null) { for (Map.Entry entry : flag.getMetadata().entrySet()) { addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java index 45a1b1408..1d7b73294 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.tools.flagd.core; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import dev.openfeature.contrib.tools.flagd.api.FlagStoreException; import dev.openfeature.sdk.ImmutableContext; @@ -127,4 +128,25 @@ void setFlagsAndGetChangedKeys_detectsRemovedFlags() throws FlagStoreException { // Then: boolFlag should be in the changed keys (it was removed) assertThat(changedKeys).contains("boolFlag"); } + + @Test + void resolveBooleanValue_flagWithNullMetadata_doesNotThrowNPE() { + String configWithNullMetadata = "{" + + "\"$schema\": \"https://flagd.dev/schema/v0/flags.json\"," + + "\"flags\": {" + + " \"nullMetadataFlag\": {" + + " \"state\": \"ENABLED\"," + + " \"defaultVariant\": \"on\"," + + " \"variants\": { \"on\": true }" + + " }" + + "}" + + "}"; + + assertThatNoException().isThrownBy(() -> { + flagdCore.setFlags(configWithNullMetadata); + ProviderEvaluation result = + flagdCore.resolveBooleanValue("nullMetadataFlag", false, new ImmutableContext()); + assertThat(result.getFlagMetadata()).isNotNull(); + }); + } } From 065763fda10454dd717030b717ea85e1e6baf467 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:41:26 -0400 Subject: [PATCH 02/22] chore(main): release dev.openfeature.contrib.tools.flagdcore 1.0.1 (#1780) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Mahesh Patil --- .release-please-manifest.json | 2 +- tools/flagd-core/CHANGELOG.md | 7 +++++++ tools/flagd-core/README.md | 2 +- tools/flagd-core/pom.xml | 2 +- tools/flagd-core/version.txt | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b96f60247..6f95b533e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -15,6 +15,6 @@ "tools/flagd-http-connector": "0.0.4", "tools/flagd-api": "1.0.0", "tools/flagd-api-testkit": "0.2.1", - "tools/flagd-core": "1.0.0", + "tools/flagd-core": "1.0.1", ".": "1.0.0" } diff --git a/tools/flagd-core/CHANGELOG.md b/tools/flagd-core/CHANGELOG.md index f107f953c..508db5a43 100644 --- a/tools/flagd-core/CHANGELOG.md +++ b/tools/flagd-core/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.0.1](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdcore-v1.0.0...dev.openfeature.contrib.tools.flagdcore-v1.0.1) (2026-04-21) + + +### 🐛 Bug Fixes + +* fixing null pointer exception error ([#1709](https://github.com/open-feature/java-sdk-contrib/issues/1709)) ([03d7966](https://github.com/open-feature/java-sdk-contrib/commit/03d796679e4cbc311c42413563fb8571be69da82)) + ## [1.0.0](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdcore-v0.2.0...dev.openfeature.contrib.tools.flagdcore-v1.0.0) (2026-04-02) diff --git a/tools/flagd-core/README.md b/tools/flagd-core/README.md index 653905736..aa9a18766 100644 --- a/tools/flagd-core/README.md +++ b/tools/flagd-core/README.md @@ -28,7 +28,7 @@ ProviderEvaluation result = core.resolveBooleanValue("myBoolFlag", eval dev.openfeature.contrib.tools flagd-core - 1.0.0 + 1.0.1 ``` diff --git a/tools/flagd-core/pom.xml b/tools/flagd-core/pom.xml index c63e99480..bcf10666f 100644 --- a/tools/flagd-core/pom.xml +++ b/tools/flagd-core/pom.xml @@ -10,7 +10,7 @@ dev.openfeature.contrib.tools flagd-core - 1.0.0 + 1.0.1 ${groupId}.flagdcore diff --git a/tools/flagd-core/version.txt b/tools/flagd-core/version.txt index 3eefcb9dd..7dea76edb 100644 --- a/tools/flagd-core/version.txt +++ b/tools/flagd-core/version.txt @@ -1 +1 @@ -1.0.0 +1.0.1 From 23f579935cf53557e9c08bb15b497cee195fe361 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Fri, 24 Apr 2026 09:53:52 +0100 Subject: [PATCH 03/22] feat: add GCP Secret Manager OpenFeature provider - TTL-based in-memory FlagCache with Clock injection for deterministic testing - Thread-safe cache: synchronized get/remove block eliminates check-then-act race - Double-checked locking in fetchWithCache prevents thundering herd on GCP API - FlagValueConverter uses Boolean.parseBoolean for boolean flag values - secretVersion validation added to GcpSecretManagerProviderOptions - VmLens concurrent cache test covering concurrent get/put/expiry scenarios - Tests for negative and exponential number formats in FlagValueConverterTest - Integration test null guard for provider Signed-off-by: Mahesh Patil --- .release-please-manifest.json | 1 + pom.xml | 1 + providers/gcp-secret-manager/CHANGELOG.md | 7 + providers/gcp-secret-manager/README.md | 121 ++++++++ providers/gcp-secret-manager/pom.xml | 114 ++++++++ .../providers/gcpsecretmanager/FlagCache.java | 100 +++++++ .../gcpsecretmanager/FlagValueConverter.java | 159 +++++++++++ .../GcpSecretManagerProvider.java | 183 +++++++++++++ .../GcpSecretManagerProviderOptions.java | 86 ++++++ .../SecretManagerClientFactory.java | 34 +++ .../gcpsecretmanager/FlagCacheCTest.java | 102 +++++++ .../gcpsecretmanager/FlagCacheTest.java | 102 +++++++ .../FlagValueConverterTest.java | 152 ++++++++++ ...pSecretManagerProviderIntegrationTest.java | 104 +++++++ .../GcpSecretManagerProviderTest.java | 259 ++++++++++++++++++ .../src/test/resources/fixtures/bool-flag.txt | 1 + .../test/resources/fixtures/double-flag.txt | 1 + .../src/test/resources/fixtures/int-flag.txt | 1 + .../test/resources/fixtures/object-flag.json | 1 + .../test/resources/fixtures/string-flag.txt | 1 + .../src/test/resources/log4j2-test.xml | 13 + providers/gcp-secret-manager/version.txt | 1 + release-please-config.json | 11 + samples/gcp-secret-manager-sample/README.md | 154 +++++++++++ samples/gcp-secret-manager-sample/pom.xml | 69 +++++ samples/gcp-secret-manager-sample/setup.sh | 70 +++++ .../SecretManagerSampleApp.java | 130 +++++++++ samples/gcp-secret-manager-sample/teardown.sh | 26 ++ 28 files changed, 2004 insertions(+) create mode 100644 providers/gcp-secret-manager/CHANGELOG.md create mode 100644 providers/gcp-secret-manager/README.md create mode 100644 providers/gcp-secret-manager/pom.xml create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/log4j2-test.xml create mode 100644 providers/gcp-secret-manager/version.txt create mode 100644 samples/gcp-secret-manager-sample/README.md create mode 100644 samples/gcp-secret-manager-sample/pom.xml create mode 100644 samples/gcp-secret-manager-sample/setup.sh create mode 100644 samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java create mode 100644 samples/gcp-secret-manager-sample/teardown.sh diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6f95b533e..c0d25d1da 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,6 +11,7 @@ "providers/statsig": "0.2.1", "providers/multiprovider": "0.0.3", "providers/ofrep": "0.0.1", + "providers/gcp-secret-manager": "0.0.1", "tools/junit-openfeature": "0.2.1", "tools/flagd-http-connector": "0.0.4", "tools/flagd-api": "1.0.0", diff --git a/pom.xml b/pom.xml index dc6500e99..d4e76f544 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ providers/optimizely providers/multiprovider providers/ofrep + providers/gcp-secret-manager tools/flagd-http-connector diff --git a/providers/gcp-secret-manager/CHANGELOG.md b/providers/gcp-secret-manager/CHANGELOG.md new file mode 100644 index 000000000..916324863 --- /dev/null +++ b/providers/gcp-secret-manager/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.0.1 + +### ✨ New Features + +* Initial release of the GCP Secret Manager OpenFeature provider. diff --git a/providers/gcp-secret-manager/README.md b/providers/gcp-secret-manager/README.md new file mode 100644 index 000000000..00e1a82a0 --- /dev/null +++ b/providers/gcp-secret-manager/README.md @@ -0,0 +1,121 @@ +# GCP Secret Manager Provider + +An OpenFeature provider that reads feature flags from [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control. + +## Installation + + +```xml + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + +``` + + +## Quick Start + +```java +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.sdk.OpenFeatureAPI; + +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .build(); + +OpenFeatureAPI.getInstance().setProvider(new GcpSecretManagerProvider(options)); + +// Evaluate a boolean flag stored as secret "enable-dark-mode" with value "true" +boolean darkMode = OpenFeatureAPI.getInstance().getClient() + .getBooleanValue("enable-dark-mode", false); +``` + +## How It Works + +Each feature flag is stored as an individual **secret** in GCP Secret Manager. The flag key maps directly to the secret name (with an optional prefix). The `latest` version is accessed by default. + +Supported raw value formats: + +| Flag type | Secret value example | +|-----------|---------------------| +| Boolean | `true` or `false` | +| Integer | `42` | +| Double | `3.14` | +| String | `dark-mode` | +| Object | `{"color":"blue","level":3}` | + +## Authentication + +The provider uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc) by default. No explicit credentials are required when running on GCP infrastructure (Cloud Run, GKE, Compute Engine) or when `gcloud auth application-default login` has been run locally. + +To use explicit credentials: + +```java +import com.google.auth.oauth2.ServiceAccountCredentials; +import java.io.FileInputStream; + +GoogleCredentials credentials = ServiceAccountCredentials.fromStream( + new FileInputStream("/path/to/service-account-key.json")); + +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .credentials(credentials) + .build(); +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `projectId` | `String` | *(required)* | GCP project ID that owns the secrets | +| `credentials` | `GoogleCredentials` | `null` (ADC) | Explicit credentials; falls back to Application Default Credentials when null | +| `secretVersion` | `String` | `"latest"` | Secret version to access. Use `"latest"` for the current version or a numeric string (e.g. `"3"`) to pin to a specific version | +| `cacheExpiry` | `Duration` | `5 minutes` | How long fetched secret values are cached before re-fetching from GCP | +| `cacheMaxSize` | `int` | `500` | Maximum number of secret values held in the in-memory cache | +| `secretNamePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` | + +## Advanced Usage + +### Pinning to a specific secret version + +```java +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .secretVersion("5") // always use version 5 + .build(); +``` + +### Secret name prefix + +```java +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .secretNamePrefix("feature-flags/") + .build(); +``` + +### Tuning cache for high-throughput scenarios + +Secret Manager has API quotas (10,000 access operations per minute per project). Use a longer `cacheExpiry` to stay within quota. + +```java +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .cacheExpiry(Duration.ofMinutes(10)) + .cacheMaxSize(1000) + .build(); +``` + +## Running Integration Tests + +Integration tests require real GCP credentials and pre-created test secrets. + +1. Configure ADC: `gcloud auth application-default login` +2. Create test secrets in your project (see `GcpSecretManagerProviderIntegrationTest` for the required secret names and values) +3. Run: + +```bash +GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-secret-manager -Dgroups=integration +``` diff --git a/providers/gcp-secret-manager/pom.xml b/providers/gcp-secret-manager/pom.xml new file mode 100644 index 000000000..82c9b60a9 --- /dev/null +++ b/providers/gcp-secret-manager/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + + + + ${groupId}.gcpsecretmanager + + + gcp-secret-manager + GCP Secret Manager provider for OpenFeature Java SDK + https://openfeature.dev + + + + openfeaturebot + OpenFeature Bot + OpenFeature + https://openfeature.dev/ + + + + + + + com.google.cloud + google-cloud-secretmanager + 2.57.0 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.21.1 + + + + org.slf4j + slf4j-api + 2.0.17 + + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.25.0 + test + + + + + com.vmlens + api + 1.2.27 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + integration + + + + + + + + concurrency-tests + + + + com.vmlens + vmlens-maven-plugin + 1.2.27 + + + test + + test + + + + **/*CTest.java + + true + + + + + + + + + diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java new file mode 100644 index 000000000..e5ca9e3c0 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java @@ -0,0 +1,100 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Thread-safe TTL-based in-memory cache for flag values fetched from GCP Secret Manager. + * + *

Entries expire after the configured {@code ttl}. When the cache reaches {@code maxSize}, + * the entry with the earliest insertion time is evicted in O(1) via {@link LinkedHashMap}'s + * insertion-order iteration and {@code removeEldestEntry}. + */ +class FlagCache { + + private final Map store; + private final Duration ttl; + private final Clock clock; + + FlagCache(Duration ttl, int maxSize) { + this(ttl, maxSize, Clock.systemUTC()); + } + + FlagCache(Duration ttl, int maxSize, Clock clock) { + this.ttl = ttl; + this.clock = clock; + this.store = Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + }); + } + + /** + * Returns the cached value for {@code key} if present and not expired. + * + * @param key the cache key + * @return an {@link Optional} containing the cached string, or empty if absent/expired + */ + Optional get(String key) { + synchronized (store) { + CacheEntry entry = store.get(key); + if (entry == null) { + return Optional.empty(); + } + if (entry.isExpired()) { + store.remove(key); + return Optional.empty(); + } + return Optional.of(entry.value); + } + } + + /** + * Stores {@code value} under {@code key}. Eviction of the oldest entry (when the cache is + * full) is handled automatically by the underlying {@link LinkedHashMap}. + * + * @param key the cache key + * @param value the value to cache + */ + void put(String key, String value) { + store.put(key, new CacheEntry(value, ttl, clock)); + } + + /** + * Removes the entry for {@code key}, forcing re-fetch on next access. + * + * @param key the cache key to invalidate + */ + void invalidate(String key) { + store.remove(key); + } + + /** Removes all entries from the cache. */ + void clear() { + store.clear(); + } + + private static final class CacheEntry { + + final String value; + final Instant expiresAt; + final Clock clock; + + CacheEntry(String value, Duration ttl, Clock clock) { + this.value = value; + this.clock = clock; + this.expiresAt = Instant.now(clock).plus(ttl); + } + + boolean isExpired() { + return Instant.now(clock).isAfter(expiresAt); + } + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java new file mode 100644 index 000000000..e75f40082 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java @@ -0,0 +1,159 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Converts raw string secret values (fetched from GCP Secret Manager) into the + * typed values expected by the OpenFeature SDK evaluation methods. + */ +@Slf4j +final class FlagValueConverter { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private FlagValueConverter() {} + + /** + * Converts a raw string value to the given target type. + * + *

Supported conversions: + *

    + *
  • {@code Boolean.class}: "true"/"false" (case-insensitive)
  • + *
  • {@code Integer.class}: numeric string
  • + *
  • {@code Double.class}: numeric string
  • + *
  • {@code String.class}: raw string as-is
  • + *
  • {@code Value.class}: JSON string parsed to {@link Value}/{@link dev.openfeature.sdk.Structure}; + * non-JSON strings are wrapped in a string {@link Value}
  • + *
+ * + * @param raw the raw string value from GCP Secret Manager + * @param targetType the desired OpenFeature type + * @param the target type + * @return the converted value + * @throws ParseError when the string cannot be parsed into the expected type + * @throws TypeMismatchError when the runtime type of the parsed value does not match + */ + @SuppressWarnings("unchecked") + static T convert(String raw, Class targetType) { + if (raw == null) { + throw new ParseError("Flag value is null"); + } + + if (targetType == Boolean.class) { + return (T) convertBoolean(raw); + } + if (targetType == Integer.class) { + return (T) convertInteger(raw); + } + if (targetType == Double.class) { + return (T) convertDouble(raw); + } + if (targetType == String.class) { + return (T) raw; + } + if (targetType == Value.class) { + return (T) convertValue(raw); + } + + throw new TypeMismatchError("Unsupported target type: " + targetType.getName()); + } + + private static Boolean convertBoolean(String raw) { + String trimmed = raw.trim(); + if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) { + return Boolean.parseBoolean(trimmed); + } + throw new ParseError("Cannot convert '" + raw + "' to Boolean. Expected \"true\" or \"false\"."); + } + + private static Integer convertInteger(String raw) { + try { + return Integer.valueOf(raw.trim()); + } catch (NumberFormatException e) { + throw new ParseError("Cannot convert '" + raw + "' to Integer: " + e.getMessage(), e); + } + } + + private static Double convertDouble(String raw) { + try { + return Double.valueOf(raw.trim()); + } catch (NumberFormatException e) { + throw new ParseError("Cannot convert '" + raw + "' to Double: " + e.getMessage(), e); + } + } + + private static Value convertValue(String raw) { + String trimmed = raw.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + JsonNode node = OBJECT_MAPPER.readTree(trimmed); + return jsonNodeToValue(node); + } catch (JsonProcessingException e) { + log.debug("Value '{}' is not valid JSON, wrapping as plain string", raw); + } + } + return new Value(raw); + } + + /** + * Recursively converts a Jackson {@link JsonNode} to an OpenFeature {@link Value}. + */ + static Value jsonNodeToValue(JsonNode node) { + if (node == null || node.isNull()) { + return new Value(); + } + if (node.isBoolean()) { + return new Value(node.booleanValue()); + } + if (node.isInt()) { + return new Value(node.intValue()); + } + if (node.isDouble() || node.isFloat()) { + return new Value(node.doubleValue()); + } + if (node.isNumber()) { + return new Value(node.doubleValue()); + } + if (node.isTextual()) { + return new Value(node.textValue()); + } + if (node.isObject()) { + return new Value(objectNodeToStructure((ObjectNode) node)); + } + if (node.isArray()) { + return new Value(arrayNodeToList((ArrayNode) node)); + } + return new Value(node.toString()); + } + + private static MutableStructure objectNodeToStructure(ObjectNode objectNode) { + MutableStructure structure = new MutableStructure(); + Iterator> fields = objectNode.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + structure.add(field.getKey(), jsonNodeToValue(field.getValue())); + } + return structure; + } + + private static List arrayNodeToList(ArrayNode arrayNode) { + List list = new ArrayList<>(arrayNode.size()); + for (JsonNode element : arrayNode) { + list.add(jsonNodeToValue(element)); + } + return list; + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java new file mode 100644 index 000000000..f5ef19db3 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java @@ -0,0 +1,183 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretVersionName; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +/** + * OpenFeature {@link FeatureProvider} backed by Google Cloud Secret Manager. + * + *

Each feature flag is stored as an individual secret in GCP Secret Manager. The flag key + * maps directly to the secret name (with an optional prefix configured via + * {@link GcpSecretManagerProviderOptions#getSecretNamePrefix()}). + * + *

Flag values are read as UTF-8 strings from the secret payload and parsed to the requested + * type. Supported raw value formats: + *

    + *
  • Boolean: {@code "true"} / {@code "false"} (case-insensitive)
  • + *
  • Integer: numeric string, e.g. {@code "42"}
  • + *
  • Double: numeric string, e.g. {@code "3.14"}
  • + *
  • String: any string value
  • + *
  • Object: JSON string that is parsed into an OpenFeature {@link Value}
  • + *
+ * + *

Results are cached in-process for the duration configured in + * {@link GcpSecretManagerProviderOptions#getCacheExpiry()}. + * + *

Example: + *

{@code
+ * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .build();
+ * OpenFeatureAPI.getInstance().setProvider(new GcpSecretManagerProvider(opts));
+ * }
+ */ +@Slf4j +public class GcpSecretManagerProvider implements FeatureProvider { + + static final String PROVIDER_NAME = "GCP Secret Manager Provider"; + + private final GcpSecretManagerProviderOptions options; + private SecretManagerServiceClient client; + private FlagCache cache; + + /** + * Creates a new provider using the given options. The GCP client is created lazily + * during {@link #initialize(EvaluationContext)}. + * + * @param options provider configuration; must not be null + */ + public GcpSecretManagerProvider(GcpSecretManagerProviderOptions options) { + this.options = options; + } + + /** + * Package-private constructor allowing injection of a pre-built client for testing. + */ + GcpSecretManagerProvider(GcpSecretManagerProviderOptions options, SecretManagerServiceClient client) { + this.options = options; + this.client = client; + } + + @Override + public Metadata getMetadata() { + return () -> PROVIDER_NAME; + } + + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + options.validate(); + if (client == null) { + client = SecretManagerClientFactory.create(options); + } + cache = new FlagCache(options.getCacheExpiry(), options.getCacheMaxSize()); + log.info("GcpSecretManagerProvider initialized for project '{}'", options.getProjectId()); + } + + @Override + public void shutdown() { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + log.warn("Error closing SecretManagerServiceClient", e); + } + client = null; + } + log.info("GcpSecretManagerProvider shut down"); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return evaluate(key, Boolean.class); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return evaluate(key, String.class); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return evaluate(key, Integer.class); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return evaluate(key, Double.class); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return evaluate(key, Value.class); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private ProviderEvaluation evaluate(String key, Class targetType) { + String rawValue = fetchWithCache(key); + T value = FlagValueConverter.convert(rawValue, targetType); + return ProviderEvaluation.builder() + .value(value) + .reason(Reason.STATIC.toString()) + .build(); + } + + private String fetchWithCache(String key) { + String secretName = buildSecretName(key); + Optional cached = cache.get(secretName); + if (cached.isPresent()) { + return cached.get(); + } + synchronized (this) { + return cache.get(secretName).orElseGet(() -> { + String value = fetchFromGcp(secretName); + cache.put(secretName, value); + return value; + }); + } + } + + /** + * Applies the configured prefix (if any) and returns the GCP secret name for the flag. + */ + private String buildSecretName(String flagKey) { + String prefix = options.getSecretNamePrefix(); + return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey; + } + + /** + * Accesses the configured version of the named secret from GCP Secret Manager. + * + * @param secretName the GCP secret name (without project path) + * @return the UTF-8 string value of the secret payload + * @throws FlagNotFoundError when the secret does not exist + * @throws GeneralError for any other GCP API error + */ + private String fetchFromGcp(String secretName) { + try { + SecretVersionName versionName = + SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion()); + log.debug("Accessing secret '{}' from GCP", versionName); + AccessSecretVersionResponse response = client.accessSecretVersion(versionName); + return response.getPayload().getData().toStringUtf8(); + } catch (NotFoundException e) { + throw new FlagNotFoundError("Secret not found: " + secretName); + } catch (Exception e) { + throw new GeneralError("Error accessing secret '" + secretName + "': " + e.getMessage()); + } + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java new file mode 100644 index 000000000..e65f826e0 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java @@ -0,0 +1,86 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.google.auth.oauth2.GoogleCredentials; +import java.time.Duration; +import lombok.Builder; +import lombok.Getter; + +/** + * Configuration options for {@link GcpSecretManagerProvider}. + * + *

Example usage: + *

{@code
+ * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .secretVersion("latest")
+ *     .cacheExpiry(Duration.ofMinutes(2))
+ *     .build();
+ * }
+ */ +@Getter +@Builder +public class GcpSecretManagerProviderOptions { + + /** + * GCP project ID that owns the secrets. Required. + * Example: "my-gcp-project" or numeric project number "123456789". + */ + private final String projectId; + + /** + * Explicit Google credentials to use when creating the Secret Manager client. + * When {@code null} (default), Application Default Credentials (ADC) are used + * automatically by the GCP client library. + */ + private final GoogleCredentials credentials; + + /** + * The secret version to retrieve. Defaults to {@code "latest"}. + * Override with a specific version number (e.g. {@code "3"}) for pinned deployments + * where you want consistent behaviour regardless of secret rotation. + */ + @Builder.Default + private final String secretVersion = "latest"; + + /** + * How long a fetched secret value is retained in the in-memory cache before + * the next evaluation triggers a fresh GCP API call. + * + *

Secret Manager has API quotas (10,000 access operations per minute per project + * by default). Set this to at least {@code Duration.ofSeconds(30)} in + * high-throughput scenarios. + * + *

Default: 5 minutes. + */ + @Builder.Default + private final Duration cacheExpiry = Duration.ofMinutes(5); + + /** + * Maximum number of distinct secret names held in the cache at once. + * When the cache is full, the oldest entry is evicted before inserting a new one. + * Default: 500. + */ + @Builder.Default + private final int cacheMaxSize = 500; + + /** + * Optional prefix prepended to every flag key before constructing the GCP + * secret name. For example, setting {@code secretNamePrefix = "ff-"} maps + * flag key {@code "my-flag"} to secret name {@code "ff-my-flag"}. + */ + private final String secretNamePrefix; + + /** + * Validates that required options are present and well-formed. + * + * @throws IllegalArgumentException when {@code projectId} is null or blank + */ + public void validate() { + if (projectId == null || projectId.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank"); + } + if (secretVersion == null || secretVersion.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: secretVersion must not be blank"); + } + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java new file mode 100644 index 000000000..cb535c31a --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java @@ -0,0 +1,34 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings; +import java.io.IOException; + +/** + * Factory for creating a {@link SecretManagerServiceClient}, separated to allow injection + * of mock clients in unit tests. + */ +final class SecretManagerClientFactory { + + private SecretManagerClientFactory() {} + + /** + * Creates a new {@link SecretManagerServiceClient} using the provided options. + * + *

When {@link GcpSecretManagerProviderOptions#getCredentials()} is non-null, those + * credentials are used explicitly. Otherwise, the GCP client library falls back to + * Application Default Credentials (ADC) automatically. + * + * @param options the provider options + * @return a configured {@link SecretManagerServiceClient} + * @throws IOException if the client cannot be created + */ + static SecretManagerServiceClient create(GcpSecretManagerProviderOptions options) throws IOException { + SecretManagerServiceSettings.Builder settingsBuilder = SecretManagerServiceSettings.newBuilder(); + if (options.getCredentials() != null) { + settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(options.getCredentials())); + } + return SecretManagerServiceClient.create(settingsBuilder.build()); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java new file mode 100644 index 000000000..5ac7545a4 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java @@ -0,0 +1,102 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.vmlens.api.AllInterleavings; +import com.vmlens.api.Runner; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Concurrency tests for {@link FlagCache} using VmLens to explore thread interleavings. + * + *

Run with: {@code mvn verify -pl providers/gcp-secret-manager -P concurrency-tests} + */ +class FlagCacheCTest { + + /** + * Verifies that a concurrent expiry-removal does not accidentally evict a freshly inserted + * entry that reuses the same key. The scenario is: + *

    + *
  1. Thread A inserts "key" with a TTL that is about to expire.
  2. + *
  3. Thread B calls get("key") and observes the entry as expired — about to remove it.
  4. + *
  5. Thread A inserts "key" again with a fresh TTL.
  6. + *
  7. Thread B completes the removal — the new entry must survive.
  8. + *
+ */ + @Test + void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { + AtomicReference now = new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); + Clock controllableClock = new Clock() { + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + + FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, controllableClock); + cache.put("key", "old-value"); + + // Advance clock so the entry is expired before threads start + now.set(now.get().plusSeconds(31)); + + try (AllInterleavings interleavings = new AllInterleavings("FlagCache expiry vs insert")) { + while (interleavings.hasNext()) { + // Reset: insert an already-expired entry + cache.put("key", "old-value"); + now.set(now.get().plusSeconds(31)); + + // Wind the clock forward to fresh time so new entries won't expire + AtomicReference fresh = new AtomicReference<>(Instant.now()); + Clock freshClock = new Clock() { + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return fresh.get(); + } + }; + FlagCache sharedCache = new FlagCache(Duration.ofMinutes(5), 100, freshClock); + sharedCache.put("key", "expired-value"); + // Make entry expire + fresh.set(fresh.get().plus(Duration.ofMinutes(6))); + + Runner.runParallel( + // Thread A: re-insert fresh value for the same key + () -> sharedCache.put("key", "new-value"), + // Thread B: get() triggers expiry-removal of the old entry + () -> sharedCache.get("key")); + + // After both threads complete, either the new value is present or the cache is + // empty — the new value must never silently disappear after being inserted. + Optional result = sharedCache.get("key"); + if (result.isPresent()) { + assertThat(result.get()).isEqualTo("new-value"); + } + } + } + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java new file mode 100644 index 000000000..77db01838 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java @@ -0,0 +1,102 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("FlagCache") +class FlagCacheTest { + + private FlagCache cache; + + @BeforeEach + void setUp() { + cache = new FlagCache(Duration.ofMinutes(5), 100); + } + + @Test + @DisplayName("get() returns empty for an unknown key") + void getUnknownKeyReturnsEmpty() { + assertThat(cache.get("unknown")).isEmpty(); + } + + @Test + @DisplayName("put() then get() returns value before expiry") + void putAndGetBeforeExpiry() { + cache.put("my-flag", "true"); + Optional result = cache.get("my-flag"); + assertThat(result).isPresent().hasValue("true"); + } + + @Test + @DisplayName("get() returns empty after TTL expires") + void getAfterTtlExpiry() { + AtomicReference now = new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); + Clock controllableClock = new Clock() { + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + + FlagCache timedCache = new FlagCache(Duration.ofSeconds(30), 100, controllableClock); + timedCache.put("flag", "value"); + + now.set(now.get().plusSeconds(31)); + + assertThat(timedCache.get("flag")).isEmpty(); + } + + @Test + @DisplayName("invalidate() removes the entry") + void invalidateRemovesEntry() { + cache.put("flag", "hello"); + cache.invalidate("flag"); + assertThat(cache.get("flag")).isEmpty(); + } + + @Test + @DisplayName("clear() removes all entries") + void clearRemovesAll() { + cache.put("a", "1"); + cache.put("b", "2"); + cache.clear(); + assertThat(cache.get("a")).isEmpty(); + assertThat(cache.get("b")).isEmpty(); + } + + @Test + @DisplayName("maxSize evicts the oldest entry on overflow") + void maxSizeEvictsOldest() { + FlagCache tinyCache = new FlagCache(Duration.ofMinutes(5), 2); + tinyCache.put("first", "1"); + tinyCache.put("second", "2"); + tinyCache.put("third", "3"); + assertThat(tinyCache.get("third")).isPresent().hasValue("3"); + int present = 0; + for (String key : new String[] {"first", "second", "third"}) { + if (tinyCache.get(key).isPresent()) { + present++; + } + } + assertThat(present).isLessThanOrEqualTo(2); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java new file mode 100644 index 000000000..099e8a3e3 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java @@ -0,0 +1,152 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("FlagValueConverter") +class FlagValueConverterTest { + + @Nested + @DisplayName("Boolean conversion") + class BooleanConversion { + + @ParameterizedTest + @ValueSource(strings = {"true", "True", "TRUE", "tRuE"}) + @DisplayName("converts truthy strings to true") + void trueVariants(String input) { + assertThat(FlagValueConverter.convert(input, Boolean.class)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"false", "False", "FALSE", "fAlSe"}) + @DisplayName("converts falsy strings to false") + void falseVariants(String input) { + assertThat(FlagValueConverter.convert(input, Boolean.class)).isFalse(); + } + + @Test + @DisplayName("throws ParseError for non-boolean string") + void nonBooleanThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("Integer conversion") + class IntegerConversion { + + @Test + @DisplayName("converts numeric string to Integer") + void numericString() { + assertThat(FlagValueConverter.convert("42", Integer.class)).isEqualTo(42); + } + + @Test + @DisplayName("converts negative numeric string to Integer") + void negativeNumericString() { + assertThat(FlagValueConverter.convert("-42", Integer.class)).isEqualTo(-42); + } + + @Test + @DisplayName("throws ParseError for non-numeric string") + void nonNumericThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("Double conversion") + class DoubleConversion { + + @Test + @DisplayName("converts numeric string to Double") + void numericString() { + assertThat(FlagValueConverter.convert("3.14", Double.class)).isEqualTo(3.14); + } + + @Test + @DisplayName("converts negative numeric string to Double") + void negativeNumericString() { + assertThat(FlagValueConverter.convert("-3.14", Double.class)).isEqualTo(-3.14); + } + + @Test + @DisplayName("converts exponential notation string to Double") + void exponentialNotation() { + assertThat(FlagValueConverter.convert("3141.5e-3", Double.class)).isEqualTo(3.1415); + } + + @Test + @DisplayName("throws ParseError for non-numeric string") + void nonNumericThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("String conversion") + class StringConversion { + + @Test + @DisplayName("returns string as-is") + void returnsAsIs() { + assertThat(FlagValueConverter.convert("dark-mode", String.class)).isEqualTo("dark-mode"); + } + } + + @Nested + @DisplayName("Value (object) conversion") + class ValueConversion { + + @Test + @DisplayName("parses JSON object string to Value with Structure") + void jsonObject() { + Value value = FlagValueConverter.convert("{\"color\":\"blue\",\"count\":3}", Value.class); + assertThat(value).isNotNull(); + assertThat(value.asStructure()).isNotNull(); + assertThat(value.asStructure().getValue("color").asString()).isEqualTo("blue"); + assertThat(value.asStructure().getValue("count").asInteger()).isEqualTo(3); + } + + @Test + @DisplayName("parses JSON array string to Value with List") + void jsonArray() { + Value value = FlagValueConverter.convert("[\"a\",\"b\"]", Value.class); + assertThat(value).isNotNull(); + assertThat(value.asList()).hasSize(2); + } + + @Test + @DisplayName("wraps plain string in Value") + void plainStringWrapped() { + Value value = FlagValueConverter.convert("plain", Value.class); + assertThat(value.asString()).isEqualTo("plain"); + } + } + + @Test + @DisplayName("throws TypeMismatchError for unsupported type") + void unsupportedTypeThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)) + .isInstanceOf(TypeMismatchError.class); + } + + @Test + @DisplayName("throws ParseError when raw value is null") + void nullRawThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)) + .isInstanceOf(ParseError.class); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java new file mode 100644 index 000000000..a68cd3411 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java @@ -0,0 +1,104 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.sdk.ImmutableContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; + +/** + * Integration tests for {@link GcpSecretManagerProvider}. + * + *

These tests require real GCP credentials and a pre-configured project with test secrets. + * They are excluded from the default test run via {@code @Tag("integration")}. + * + *

Pre-requisites: + *

    + *
  1. Set the {@code GCP_PROJECT_ID} environment variable to your GCP project ID.
  2. + *
  3. Ensure Application Default Credentials are configured + * ({@code gcloud auth application-default login}).
  4. + *
  5. Create the following secrets in GCP Secret Manager under the project (each with a + * {@code "latest"} version): + *
      + *
    • {@code it-bool-flag} with value {@code "true"}
    • + *
    • {@code it-string-flag} with value {@code "hello"}
    • + *
    • {@code it-int-flag} with value {@code "99"}
    • + *
    • {@code it-double-flag} with value {@code "2.71"}
    • + *
    • {@code it-object-flag} with value {@code {"key":"val"}}
    • + *
    + *
  6. + *
+ * + *

To run these tests: + *

{@code
+ * GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-secret-manager -Dgroups=integration
+ * }
+ */ +@Tag("integration") +@DisabledIfEnvironmentVariable(named = "GCP_PROJECT_ID", matches = "") +@DisplayName("GcpSecretManagerProvider integration tests") +class GcpSecretManagerProviderIntegrationTest { + + private GcpSecretManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + String projectId = System.getenv("GCP_PROJECT_ID"); + GcpSecretManagerProviderOptions opts = + GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + provider = new GcpSecretManagerProvider(opts); + provider.initialize(new ImmutableContext()); + } + + @AfterEach + void tearDown() { + provider.shutdown(); + } + + @Test + @DisplayName("evaluates boolean secret") + void booleanFlag() { + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) + .getValue()) + .isTrue(); + } + + @Test + @DisplayName("evaluates string secret") + void stringFlag() { + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) + .getValue()) + .isEqualTo("hello"); + } + + @Test + @DisplayName("evaluates integer secret") + void integerFlag() { + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) + .getValue()) + .isEqualTo(99); + } + + @Test + @DisplayName("evaluates double secret") + void doubleFlag() { + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) + .getValue()) + .isEqualTo(2.71); + } + + @Test + @DisplayName("evaluates object secret as Value/Structure") + void objectFlag() { + assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString()) + .isEqualTo("val"); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java new file mode 100644 index 000000000..298e08bc0 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java @@ -0,0 +1,259 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretPayload; +import com.google.cloud.secretmanager.v1.SecretVersionName; +import com.google.protobuf.ByteString; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("GcpSecretManagerProvider") +@ExtendWith(MockitoExtension.class) +class GcpSecretManagerProviderTest { + + @Mock + private SecretManagerServiceClient mockClient; + + private GcpSecretManagerProviderOptions options; + private GcpSecretManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + options = GcpSecretManagerProviderOptions.builder() + .projectId("test-project") + .build(); + provider = new GcpSecretManagerProvider(options, mockClient); + provider.initialize(new ImmutableContext()); + } + + private void stubSecret(String value) { + AccessSecretVersionResponse response = AccessSecretVersionResponse.newBuilder() + .setPayload(SecretPayload.newBuilder() + .setData(ByteString.copyFromUtf8(value)) + .build()) + .build(); + when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenReturn(response); + } + + private void stubSecretNotFound() { + when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenThrow(NotFoundException.class); + } + + private void stubSecretError(String message) { + when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenThrow(new RuntimeException(message)); + } + + @Nested + @DisplayName("Metadata") + class MetadataTests { + @Test + @DisplayName("returns the correct provider name") + void providerName() { + assertThat(provider.getMetadata().getName()).isEqualTo(GcpSecretManagerProvider.PROVIDER_NAME); + } + } + + @Nested + @DisplayName("Initialization") + class InitializationTests { + @Test + @DisplayName("throws IllegalArgumentException when projectId is blank") + void blankProjectIdThrows() { + GcpSecretManagerProviderOptions badOpts = + GcpSecretManagerProviderOptions.builder().projectId("").build(); + GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws IllegalArgumentException when projectId is null") + void nullProjectIdThrows() { + GcpSecretManagerProviderOptions badOpts = + GcpSecretManagerProviderOptions.builder().build(); + GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("Boolean evaluation") + class BooleanEvaluation { + @Test + @DisplayName("returns true for secret value 'true'") + void trueValue() { + stubSecret("true"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()); + assertThat(result.getValue()).isTrue(); + assertThat(result.getReason()).isEqualTo(Reason.STATIC.toString()); + } + + @Test + @DisplayName("returns false for secret value 'false'") + void falseValue() { + stubSecret("false"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + assertThat(result.getValue()).isFalse(); + } + + @Test + @DisplayName("throws ParseError for malformed boolean value") + void malformedBooleanThrows() { + stubSecret("not-a-bool"); + assertThatThrownBy(() -> provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext())) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("String evaluation") + class StringEvaluation { + @Test + @DisplayName("returns string value as-is") + void stringValue() { + stubSecret("dark-mode"); + ProviderEvaluation result = + provider.getStringEvaluation("str-flag", "light-mode", new ImmutableContext()); + assertThat(result.getValue()).isEqualTo("dark-mode"); + } + } + + @Nested + @DisplayName("Integer evaluation") + class IntegerEvaluation { + @Test + @DisplayName("parses numeric string to Integer") + void integerValue() { + stubSecret("42"); + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(42); + } + + @Test + @DisplayName("throws ParseError for non-numeric value") + void nonNumericThrows() { + stubSecret("abc"); + assertThatThrownBy(() -> provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext())) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("Double evaluation") + class DoubleEvaluation { + @Test + @DisplayName("parses numeric string to Double") + void doubleValue() { + stubSecret("3.14"); + ProviderEvaluation result = + provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(3.14); + } + } + + @Nested + @DisplayName("Object evaluation") + class ObjectEvaluation { + @Test + @DisplayName("parses JSON string to Value/Structure") + void jsonValue() { + stubSecret("{\"color\":\"blue\",\"count\":3}"); + ProviderEvaluation result = + provider.getObjectEvaluation("obj-flag", new Value(), new ImmutableContext()); + assertThat(result.getValue().asStructure()).isNotNull(); + assertThat(result.getValue().asStructure().getValue("color").asString()) + .isEqualTo("blue"); + } + } + + @Nested + @DisplayName("Error handling") + class ErrorHandling { + @Test + @DisplayName("throws FlagNotFoundError when secret does not exist") + void flagNotFound() { + stubSecretNotFound(); + assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) + .isInstanceOf(FlagNotFoundError.class); + } + + @Test + @DisplayName("throws GeneralError on unexpected GCP API exception") + void gcpApiError() { + stubSecretError("Connection refused"); + assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())) + .isInstanceOf(GeneralError.class); + } + } + + @Nested + @DisplayName("Caching") + class CachingTests { + @Test + @DisplayName("cache hit: GCP client called only once for two consecutive evaluations") + void cacheHit() { + stubSecret("true"); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + verify(mockClient, times(1)).accessSecretVersion(any(SecretVersionName.class)); + } + } + + @Nested + @DisplayName("Secret name prefix") + class PrefixTests { + @Test + @DisplayName("prefix is prepended to the flag key when building secret name") + void prefixApplied() { + GcpSecretManagerProviderOptions prefixedOpts = GcpSecretManagerProviderOptions.builder() + .projectId("test-project") + .secretNamePrefix("ff-") + .build(); + stubSecret("true"); + GcpSecretManagerProvider prefixedProvider = new GcpSecretManagerProvider(prefixedOpts, mockClient); + try { + prefixedProvider.initialize(new ImmutableContext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + ProviderEvaluation result = + prefixedProvider.getBooleanEvaluation("my-flag", false, new ImmutableContext()); + assertThat(result.getValue()).isTrue(); + } + } + + @Nested + @DisplayName("Lifecycle") + class LifecycleTests { + @Test + @DisplayName("shutdown() closes the GCP client") + void shutdownClosesClient() { + provider.shutdown(); + verify(mockClient, times(1)).close(); + } + } +} diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt new file mode 100644 index 000000000..f32a5804e --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt @@ -0,0 +1 @@ +true \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt new file mode 100644 index 000000000..3767b4b17 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt @@ -0,0 +1 @@ +3.14 \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt new file mode 100644 index 000000000..f70d7bba4 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json b/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json new file mode 100644 index 000000000..5b3335c01 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json @@ -0,0 +1 @@ +{"color":"blue","count":3} \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt new file mode 100644 index 000000000..3643aef41 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt @@ -0,0 +1 @@ +dark-mode \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml b/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..c52aaeaf2 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/providers/gcp-secret-manager/version.txt b/providers/gcp-secret-manager/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/gcp-secret-manager/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/release-please-config.json b/release-please-config.json index 33ad09f17..1e9ab07d7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -135,6 +135,17 @@ "README.md" ] }, + "providers/gcp-secret-manager": { + "package-name": "dev.openfeature.contrib.providers.gcp-secret-manager", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "hooks/open-telemetry": { "package-name": "dev.openfeature.contrib.hooks.otel", "release-type": "simple", diff --git a/samples/gcp-secret-manager-sample/README.md b/samples/gcp-secret-manager-sample/README.md new file mode 100644 index 000000000..99fffaa9e --- /dev/null +++ b/samples/gcp-secret-manager-sample/README.md @@ -0,0 +1,154 @@ +# GCP Secret Manager — OpenFeature Sample + +A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp-secret-manager). + +It evaluates five feature flags (covering every supported type) that are stored as secrets in +Google Cloud Secret Manager. + +## Feature Flags Used + +| Secret name (GCP) | Flag key | Type | Example value | +|---|---|---|---| +| `of-sample-dark-mode` | `dark-mode` | boolean | `true` | +| `of-sample-banner-text` | `banner-text` | string | `"Welcome! 10% off today only"` | +| `of-sample-max-cart-items` | `max-cart-items` | integer | `25` | +| `of-sample-discount-rate` | `discount-rate` | double | `0.10` | +| `of-sample-checkout-config` | `checkout-config` | object (JSON) | `{"paymentMethods":["card","paypal"],...}` | + +All secrets are prefixed with `of-sample-` so they are easy to identify and clean up. + +--- + +## Prerequisites + +| Tool | Version | Install | +|---|---|---| +| Java | 17+ | [adoptium.net](https://adoptium.net) | +| Maven | 3.8+ | [maven.apache.org](https://maven.apache.org) | +| gcloud CLI | any | [cloud.google.com/sdk](https://cloud.google.com/sdk/docs/install) | +| GCP project | — | [console.cloud.google.com](https://console.cloud.google.com) | + +Your GCP account needs the **Secret Manager Secret Accessor** role (`roles/secretmanager.secretAccessor`) +on the project. + +--- + +## Step 1 — Enable the API + +```bash +export GCP_PROJECT_ID=my-gcp-project # replace with your project ID + +gcloud services enable secretmanager.googleapis.com --project="$GCP_PROJECT_ID" +``` + +## Step 2 — Authenticate + +```bash +gcloud auth application-default login +``` + +## Step 3 — Build the provider + +From the **root** of `java-sdk-contrib`: + +```bash +mvn install -DskipTests -P '!deploy' +``` + +This installs the provider JAR to your local Maven repository (`~/.m2`). + +## Step 4 — Create the feature-flag secrets + +```bash +cd samples/gcp-secret-manager-sample +bash setup.sh +``` + +You should see output like: + +``` +Creating sample feature-flag secrets in project: my-gcp-project + [CREATED] of-sample-dark-mode + [VERSION] of-sample-dark-mode → true + [CREATED] of-sample-banner-text + ... +✓ All secrets created successfully. +``` + +## Step 5 — Run the sample + +```bash +mvn exec:java +``` + +The app reads `GCP_PROJECT_ID` from the environment. You can also pass it explicitly: + +```bash +mvn exec:java -DGCP_PROJECT_ID=my-gcp-project +``` + +### Expected output + +``` +======================================================= + GCP Secret Manager — OpenFeature Sample +======================================================= +Project : my-gcp-project +Prefix : of-sample- + +── Boolean Flag » dark-mode ────────────────────────────────────── +Value : true +Effect : Dark theme activated + +── String Flag » banner-text ───────────────────────────────────── +Value : Welcome! 10% off today only + +── Integer Flag » max-cart-items ───────────────────────────────── +Value : 25 +Effect : Cart is capped at 25 items + +── Double Flag » discount-rate ─────────────────────────────────── +Value : 0.10 +Effect : 10% discount applied to cart total + +── Object Flag » checkout-config ───────────────────────────────── +Value : Structure{...} +Payment methods : ["card", "paypal"] +Express checkout : true + +======================================================= + All flags evaluated successfully. +======================================================= +``` + +## Step 6 — Clean up + +```bash +bash teardown.sh +``` + +--- + +## Changing flag values + +To update a flag, add a new secret version: + +```bash +echo -n "false" | gcloud secrets versions add of-sample-dark-mode \ + --project="$GCP_PROJECT_ID" --data-file=- +``` + +Re-run the sample to see the new value (cache expires after 30 seconds in this sample). + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GCP_PROJECT_ID is not set` | Env var missing | `export GCP_PROJECT_ID=my-project` | +| `FlagNotFoundError` | Secret doesn't exist | Run `setup.sh` first | +| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/secretmanager.secretAccessor` | +| `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | +| `secretmanager.googleapis.com is not enabled` | API disabled | Run Step 1 | +| `Could not find artifact ...gcp-secret-manager` | Provider not installed | Run Step 3 | diff --git a/samples/gcp-secret-manager-sample/pom.xml b/samples/gcp-secret-manager-sample/pom.xml new file mode 100644 index 000000000..3689ae726 --- /dev/null +++ b/samples/gcp-secret-manager-sample/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + dev.openfeature.contrib.samples + gcp-secret-manager-sample + 1.0-SNAPSHOT + jar + + GCP Secret Manager OpenFeature Sample + + Runnable sample demonstrating the GCP Secret Manager OpenFeature provider. + Evaluates five feature flags (bool, string, int, double, object) stored as + GCP secrets against a real GCP project. + + + + 17 + ${java.version} + ${java.version} + UTF-8 + + ${env.GCP_PROJECT_ID} + + + + + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + + + + + dev.openfeature + sdk + 1.12.1 + + + + + org.slf4j + slf4j-simple + 2.0.17 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + dev.openfeature.contrib.samples.gcpsecretmanager.SecretManagerSampleApp + + + GCP_PROJECT_ID + ${GCP_PROJECT_ID} + + + + + + + diff --git a/samples/gcp-secret-manager-sample/setup.sh b/samples/gcp-secret-manager-sample/setup.sh new file mode 100644 index 000000000..777a1a25a --- /dev/null +++ b/samples/gcp-secret-manager-sample/setup.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# setup.sh — Creates the five sample feature-flag secrets in GCP Secret Manager. +# +# Prerequisites: +# - gcloud CLI installed and authenticated (gcloud auth application-default login) +# - Secret Manager API enabled: gcloud services enable secretmanager.googleapis.com +# - GCP_PROJECT_ID environment variable set to your GCP project ID +# +# Usage: +# export GCP_PROJECT_ID=my-gcp-project +# bash setup.sh + +set -euo pipefail + +PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" + +echo "Creating sample feature-flag secrets in project: ${PROJECT}" +echo "All secrets are prefixed with 'of-sample-' to match the sample app." +echo "" + +# ──────────────────────────────────────────────────────────────────────────────── +create_secret() { + local name="$1" + local value="$2" + local full_name="of-sample-${name}" + + # Create the secret resource (idempotent — ignores "already exists") + if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then + echo " [EXISTS] ${full_name} — adding new version" + else + gcloud secrets create "${full_name}" \ + --project="${PROJECT}" \ + --replication-policy=automatic \ + --quiet + echo " [CREATED] ${full_name}" + fi + + # Add a secret version with the flag value + echo -n "${value}" | gcloud secrets versions add "${full_name}" \ + --project="${PROJECT}" \ + --data-file=- \ + --quiet + echo " [VERSION] ${full_name} → ${value}" +} +# ──────────────────────────────────────────────────────────────────────────────── + +# Boolean flag: dark UI theme toggle +create_secret "dark-mode" "true" + +# String flag: hero banner text +create_secret "banner-text" "Welcome! 10% off today only" + +# Integer flag: maximum items in cart +create_secret "max-cart-items" "25" + +# Double flag: discount multiplier (10%) +create_secret "discount-rate" "0.10" + +# Object flag: structured checkout configuration (JSON) +create_secret "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' + +echo "" +echo "✓ All secrets created successfully." +echo "" +echo "Next steps:" +echo " 1. Authenticate: gcloud auth application-default login" +echo " 2. Run the sample:" +echo " cd gcp-secret-manager-sample" +echo " mvn exec:java # GCP_PROJECT_ID must still be set in your shell" +echo " 3. To clean up: bash teardown.sh" diff --git a/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java b/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java new file mode 100644 index 000000000..4e1d24276 --- /dev/null +++ b/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java @@ -0,0 +1,130 @@ +package dev.openfeature.contrib.samples.gcpsecretmanager; + +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import java.time.Duration; + +/** + * Sample application demonstrating the GCP Secret Manager OpenFeature provider. + * + *

This app evaluates five feature flags backed by GCP Secret Manager secrets: + *

    + *
  • {@code of-sample-dark-mode} (boolean) — whether the dark UI theme is enabled
  • + *
  • {@code of-sample-banner-text} (string) — hero banner copy shown to users
  • + *
  • {@code of-sample-max-cart-items} (integer) — maximum items allowed in the cart
  • + *
  • {@code of-sample-discount-rate} (double) — discount multiplier (0.0 – 1.0)
  • + *
  • {@code of-sample-checkout-config} (object/JSON) — structured checkout settings
  • + *
+ * + *

Run {@code setup.sh} first to create these secrets in your GCP project, then: + *

+ *   export GCP_PROJECT_ID=my-gcp-project
+ *   mvn exec:java
+ * 
+ */ +public class SecretManagerSampleApp { + + private static final String PREFIX = "of-sample-"; + + public static void main(String[] args) throws Exception { + String projectId = resolveProjectId(args); + + System.out.println("======================================================="); + System.out.println(" GCP Secret Manager — OpenFeature Sample"); + System.out.println("======================================================="); + System.out.println("Project : " + projectId); + System.out.println("Prefix : " + PREFIX); + System.out.println(); + + // Build provider options + GcpSecretManagerProviderOptions options = + GcpSecretManagerProviderOptions.builder() + .projectId(projectId) + .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .secretVersion("latest") + .cacheExpiry(Duration.ofSeconds(30)) + .build(); + + // Register the provider with OpenFeature + GcpSecretManagerProvider provider = new GcpSecretManagerProvider(options); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(provider); + Client client = api.getClient(); + + // Evaluation context (optional — demonstrates passing user context) + MutableContext ctx = new MutableContext(); + ctx.add("userId", "user-42"); + + // ── Boolean flag ──────────────────────────────────────────────────────────── + printHeader("Boolean Flag » dark-mode"); + boolean darkMode = client.getBooleanValue("dark-mode", false, ctx); + System.out.println("Value : " + darkMode); + System.out.println("Effect : " + (darkMode ? "Dark theme activated" : "Light theme active")); + + // ── String flag ───────────────────────────────────────────────────────────── + printHeader("String Flag » banner-text"); + String bannerText = client.getStringValue("banner-text", "Welcome!", ctx); + System.out.println("Value : " + bannerText); + + // ── Integer flag ───────────────────────────────────────────────────────────── + printHeader("Integer Flag » max-cart-items"); + int maxCartItems = client.getIntegerValue("max-cart-items", 10, ctx); + System.out.println("Value : " + maxCartItems); + System.out.println("Effect : Cart is capped at " + maxCartItems + " items"); + + // ── Double flag ────────────────────────────────────────────────────────────── + printHeader("Double Flag » discount-rate"); + double discountRate = client.getDoubleValue("discount-rate", 0.0, ctx); + System.out.printf("Value : %.2f%n", discountRate); + System.out.printf("Effect : %.0f%% discount applied to cart total%n", discountRate * 100); + + // ── Object flag (JSON) ─────────────────────────────────────────────────────── + printHeader("Object Flag » checkout-config"); + Value checkoutConfig = client.getObjectValue("checkout-config", new Value(), ctx); + System.out.println("Value : " + checkoutConfig); + if (checkoutConfig.isStructure()) { + Value paymentMethods = checkoutConfig.asStructure().getValue("paymentMethods"); + Value expressCheckout = checkoutConfig.asStructure().getValue("expressCheckout"); + System.out.println("Payment methods : " + paymentMethods); + System.out.println("Express checkout : " + expressCheckout); + } + + System.out.println(); + System.out.println("======================================================="); + System.out.println(" All flags evaluated successfully."); + System.out.println("======================================================="); + + api.shutdown(); + } + + private static String resolveProjectId(String[] args) { + // 1. CLI argument + if (args.length > 0 && !args[0].isBlank()) { + return args[0]; + } + // 2. Environment variable + String fromEnv = System.getenv("GCP_PROJECT_ID"); + if (fromEnv != null && !fromEnv.isBlank()) { + return fromEnv; + } + // 3. System property (set via -DGCP_PROJECT_ID=... or exec plugin config) + String fromProp = System.getProperty("GCP_PROJECT_ID"); + if (fromProp != null && !fromProp.isBlank()) { + return fromProp; + } + System.err.println("ERROR: GCP_PROJECT_ID is not set."); + System.err.println("Usage: export GCP_PROJECT_ID=my-project && mvn exec:java"); + System.err.println(" or: mvn exec:java -DGCP_PROJECT_ID=my-project"); + System.exit(1); + return null; // unreachable + } + + private static void printHeader(String title) { + System.out.println(); + System.out.println("── " + title + " " + "─".repeat(Math.max(0, 50 - title.length()))); + } +} diff --git a/samples/gcp-secret-manager-sample/teardown.sh b/samples/gcp-secret-manager-sample/teardown.sh new file mode 100644 index 000000000..ec5997e77 --- /dev/null +++ b/samples/gcp-secret-manager-sample/teardown.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# teardown.sh — Deletes the sample feature-flag secrets from GCP Secret Manager. +# +# Usage: +# export GCP_PROJECT_ID=my-gcp-project +# bash teardown.sh + +set -euo pipefail + +PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" + +echo "Deleting sample secrets from project: ${PROJECT}" +echo "" + +for name in dark-mode banner-text max-cart-items discount-rate checkout-config; do + full_name="of-sample-${name}" + if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then + gcloud secrets delete "${full_name}" --project="${PROJECT}" --quiet + echo " [DELETED] ${full_name}" + else + echo " [SKIP] ${full_name} (not found)" + fi +done + +echo "" +echo "✓ Cleanup complete." From a917da4e4ace90bec99b6a88ba2656f4d6e4db9c Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Fri, 24 Apr 2026 20:46:19 +0100 Subject: [PATCH 04/22] fix: simplify and strengthen FlagCacheCTest concurrent expiry test - Move all setup (clock, cache, expired entry) inside the VmLens loop so each interleaving starts with a clean, deterministic state - Replace the conditional assertion with an unconditional check: assertThat(cache.get("key")).isPresent().hasValue("new-value") After Thread A's put() completes, the new value must always be present; the previous if-present guard would silently pass a correctness bug - Remove unused outer cache/controllableClock/now variables and the redundant sharedCache/freshClock inside the loop Signed-off-by: Mahesh Patil --- .../gcpsecretmanager/FlagCacheCTest.java | 76 ++++++------------- 1 file changed, 24 insertions(+), 52 deletions(-) diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java index 5ac7545a4..3fa08fe27 100644 --- a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java @@ -8,7 +8,6 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; -import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -23,47 +22,21 @@ class FlagCacheCTest { * Verifies that a concurrent expiry-removal does not accidentally evict a freshly inserted * entry that reuses the same key. The scenario is: *
    - *
  1. Thread A inserts "key" with a TTL that is about to expire.
  2. - *
  3. Thread B calls get("key") and observes the entry as expired — about to remove it.
  4. - *
  5. Thread A inserts "key" again with a fresh TTL.
  6. - *
  7. Thread B completes the removal — the new entry must survive.
  8. + *
  9. An entry for "key" is inserted with a TTL that has already elapsed.
  10. + *
  11. Thread A re-inserts "key" with a fresh TTL ("new-value").
  12. + *
  13. Thread B calls get("key"), observes the original entry as expired, and removes it.
  14. + *
  15. After both threads finish, "new-value" must still be present — Thread B's removal + * must not accidentally evict the entry written by Thread A.
  16. *
*/ @Test void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { - AtomicReference now = new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); - Clock controllableClock = new Clock() { - @Override - public ZoneOffset getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(java.time.ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return now.get(); - } - }; - - FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, controllableClock); - cache.put("key", "old-value"); - - // Advance clock so the entry is expired before threads start - now.set(now.get().plusSeconds(31)); - - try (AllInterleavings interleavings = new AllInterleavings("FlagCache expiry vs insert")) { + try (AllInterleavings interleavings = + new AllInterleavings("FlagCache expiry vs insert")) { while (interleavings.hasNext()) { - // Reset: insert an already-expired entry - cache.put("key", "old-value"); - now.set(now.get().plusSeconds(31)); - - // Wind the clock forward to fresh time so new entries won't expire - AtomicReference fresh = new AtomicReference<>(Instant.now()); - Clock freshClock = new Clock() { + AtomicReference now = + new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); + Clock clock = new Clock() { @Override public ZoneOffset getZone() { return ZoneOffset.UTC; @@ -76,26 +49,25 @@ public Clock withZone(java.time.ZoneId zone) { @Override public Instant instant() { - return fresh.get(); + return now.get(); } }; - FlagCache sharedCache = new FlagCache(Duration.ofMinutes(5), 100, freshClock); - sharedCache.put("key", "expired-value"); - // Make entry expire - fresh.set(fresh.get().plus(Duration.ofMinutes(6))); + + FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, clock); + cache.put("key", "expired-value"); + + // Advance clock past TTL so "expired-value" is expired when threads start + now.set(now.get().plusSeconds(31)); Runner.runParallel( - // Thread A: re-insert fresh value for the same key - () -> sharedCache.put("key", "new-value"), - // Thread B: get() triggers expiry-removal of the old entry - () -> sharedCache.get("key")); + // Thread A: re-insert a fresh value under the same key + () -> cache.put("key", "new-value"), + // Thread B: get() finds expired entry and attempts removal + () -> cache.get("key")); - // After both threads complete, either the new value is present or the cache is - // empty — the new value must never silently disappear after being inserted. - Optional result = sharedCache.get("key"); - if (result.isPresent()) { - assertThat(result.get()).isEqualTo("new-value"); - } + // Thread A's put() has completed, so "new-value" must be present. + // Thread B's expiry-removal must not have evicted Thread A's new entry. + assertThat(cache.get("key")).isPresent().hasValue("new-value"); } } } From f73f21ce0e24c6278ba134e2ade486a803648c79 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Fri, 24 Apr 2026 22:54:02 +0100 Subject: [PATCH 05/22] Revert "fix: simplify and strengthen FlagCacheCTest concurrent expiry test" This reverts commit 58cf2a632d41735c8a943dda804f49c2b274aed7. Signed-off-by: Mahesh Patil --- .../gcpsecretmanager/FlagCacheCTest.java | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java index 3fa08fe27..5ac7545a4 100644 --- a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java @@ -8,6 +8,7 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -22,21 +23,47 @@ class FlagCacheCTest { * Verifies that a concurrent expiry-removal does not accidentally evict a freshly inserted * entry that reuses the same key. The scenario is: *
    - *
  1. An entry for "key" is inserted with a TTL that has already elapsed.
  2. - *
  3. Thread A re-inserts "key" with a fresh TTL ("new-value").
  4. - *
  5. Thread B calls get("key"), observes the original entry as expired, and removes it.
  6. - *
  7. After both threads finish, "new-value" must still be present — Thread B's removal - * must not accidentally evict the entry written by Thread A.
  8. + *
  9. Thread A inserts "key" with a TTL that is about to expire.
  10. + *
  11. Thread B calls get("key") and observes the entry as expired — about to remove it.
  12. + *
  13. Thread A inserts "key" again with a fresh TTL.
  14. + *
  15. Thread B completes the removal — the new entry must survive.
  16. *
*/ @Test void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { - try (AllInterleavings interleavings = - new AllInterleavings("FlagCache expiry vs insert")) { + AtomicReference now = new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); + Clock controllableClock = new Clock() { + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + + FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, controllableClock); + cache.put("key", "old-value"); + + // Advance clock so the entry is expired before threads start + now.set(now.get().plusSeconds(31)); + + try (AllInterleavings interleavings = new AllInterleavings("FlagCache expiry vs insert")) { while (interleavings.hasNext()) { - AtomicReference now = - new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); - Clock clock = new Clock() { + // Reset: insert an already-expired entry + cache.put("key", "old-value"); + now.set(now.get().plusSeconds(31)); + + // Wind the clock forward to fresh time so new entries won't expire + AtomicReference fresh = new AtomicReference<>(Instant.now()); + Clock freshClock = new Clock() { @Override public ZoneOffset getZone() { return ZoneOffset.UTC; @@ -49,25 +76,26 @@ public Clock withZone(java.time.ZoneId zone) { @Override public Instant instant() { - return now.get(); + return fresh.get(); } }; - - FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, clock); - cache.put("key", "expired-value"); - - // Advance clock past TTL so "expired-value" is expired when threads start - now.set(now.get().plusSeconds(31)); + FlagCache sharedCache = new FlagCache(Duration.ofMinutes(5), 100, freshClock); + sharedCache.put("key", "expired-value"); + // Make entry expire + fresh.set(fresh.get().plus(Duration.ofMinutes(6))); Runner.runParallel( - // Thread A: re-insert a fresh value under the same key - () -> cache.put("key", "new-value"), - // Thread B: get() finds expired entry and attempts removal - () -> cache.get("key")); + // Thread A: re-insert fresh value for the same key + () -> sharedCache.put("key", "new-value"), + // Thread B: get() triggers expiry-removal of the old entry + () -> sharedCache.get("key")); - // Thread A's put() has completed, so "new-value" must be present. - // Thread B's expiry-removal must not have evicted Thread A's new entry. - assertThat(cache.get("key")).isPresent().hasValue("new-value"); + // After both threads complete, either the new value is present or the cache is + // empty — the new value must never silently disappear after being inserted. + Optional result = sharedCache.get("key"); + if (result.isPresent()) { + assertThat(result.get()).isEqualTo("new-value"); + } } } } From 41ec35a8829a04358c851fbf3249abfb794c0cc9 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Fri, 24 Apr 2026 23:03:07 +0100 Subject: [PATCH 06/22] fix: rewrite FlagCacheCTest with correct VmLens structure The previous implementation had several structural problems: - clock, cache (and an unused outer cache) were recreated inside the VmLens while-loop. VmLens needs stable object references across iterations to correctly track which shared memory locations are concurrently accessed; recreating objects each iteration means VmLens sees different heap addresses and cannot build a reliable access model. - now.set(now.get().plusSeconds(31)) inside the loop kept advancing the clock monotonically, so state was never cleanly reset between interleavings and could allow "new-value" entries to expire. - The assertion was too weak: if (result.isPresent()) { ... } would silently pass even when the cache is empty, hiding the exact bug the test is meant to catch. Fixed by: - Creating clock and cache once outside the loop (stable references) - Resetting state inside the loop to fixed instants (t0/t1) via cache.clear() + now.set(t0) before each interleaving - Using an unconditional assertion: assertThat(cache.get("key")).isPresent().hasValue("new-value") After Runner.runParallel returns, Thread A's put() has completed so "new-value" must always survive Thread B's expiry-removal. Signed-off-by: Mahesh Patil --- .../gcpsecretmanager/FlagCacheCTest.java | 79 +++++++------------ 1 file changed, 30 insertions(+), 49 deletions(-) diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java index 5ac7545a4..73fa7b2eb 100644 --- a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java @@ -8,7 +8,6 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; -import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -23,16 +22,23 @@ class FlagCacheCTest { * Verifies that a concurrent expiry-removal does not accidentally evict a freshly inserted * entry that reuses the same key. The scenario is: *
    - *
  1. Thread A inserts "key" with a TTL that is about to expire.
  2. - *
  3. Thread B calls get("key") and observes the entry as expired — about to remove it.
  4. - *
  5. Thread A inserts "key" again with a fresh TTL.
  6. - *
  7. Thread B completes the removal — the new entry must survive.
  8. + *
  9. An entry for "key" exists with a TTL that has already elapsed.
  10. + *
  11. Thread A re-inserts "key" with a fresh value while the clock is past the old TTL.
  12. + *
  13. Thread B calls get("key"), observes the old entry as expired, and removes it.
  14. + *
  15. After both threads finish, "new-value" must still be present — Thread B's stale + * removal must not accidentally evict the entry written by Thread A.
  16. *
*/ @Test void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { - AtomicReference now = new AtomicReference<>(Instant.parse("2024-01-01T00:00:00Z")); - Clock controllableClock = new Clock() { + // T0: entries inserted at this instant expire at T0 + 30 s + Instant t0 = Instant.parse("2024-01-01T00:00:00Z"); + // T1: 31 s later — the "expired-value" entry is stale, but a new entry inserted here + // won't expire until T1 + 30 s = T0 + 61 s, which is safely in the future + Instant t1 = t0.plusSeconds(31); + + AtomicReference now = new AtomicReference<>(t0); + Clock clock = new Clock() { @Override public ZoneOffset getZone() { return ZoneOffset.UTC; @@ -49,53 +55,28 @@ public Instant instant() { } }; - FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, controllableClock); - cache.put("key", "old-value"); - - // Advance clock so the entry is expired before threads start - now.set(now.get().plusSeconds(31)); + FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, clock); - try (AllInterleavings interleavings = new AllInterleavings("FlagCache expiry vs insert")) { + try (AllInterleavings interleavings = + new AllInterleavings("FlagCache concurrent expiry and re-insert")) { while (interleavings.hasNext()) { - // Reset: insert an already-expired entry - cache.put("key", "old-value"); - now.set(now.get().plusSeconds(31)); - - // Wind the clock forward to fresh time so new entries won't expire - AtomicReference fresh = new AtomicReference<>(Instant.now()); - Clock freshClock = new Clock() { - @Override - public ZoneOffset getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(java.time.ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return fresh.get(); - } - }; - FlagCache sharedCache = new FlagCache(Duration.ofMinutes(5), 100, freshClock); - sharedCache.put("key", "expired-value"); - // Make entry expire - fresh.set(fresh.get().plus(Duration.ofMinutes(6))); + // Reset state for this interleaving: + // - clock at T0 so the inserted entry records expiresAt = T0 + 30 s + now.set(t0); + cache.clear(); + cache.put("key", "expired-value"); + // - advance clock to T1 so the entry is now expired + now.set(t1); Runner.runParallel( - // Thread A: re-insert fresh value for the same key - () -> sharedCache.put("key", "new-value"), - // Thread B: get() triggers expiry-removal of the old entry - () -> sharedCache.get("key")); + // Thread A: re-insert the same key with a fresh value + () -> cache.put("key", "new-value"), + // Thread B: get() detects the stale entry and attempts removal + () -> cache.get("key")); - // After both threads complete, either the new value is present or the cache is - // empty — the new value must never silently disappear after being inserted. - Optional result = sharedCache.get("key"); - if (result.isPresent()) { - assertThat(result.get()).isEqualTo("new-value"); - } + // Thread A's put() has returned, so "new-value" must be in the cache. + // Thread B's expiry-removal must not have silently evicted it. + assertThat(cache.get("key")).isPresent().hasValue("new-value"); } } } From 44c2ef1dee6a8e5be0c70bb42171fbe4b733e24a Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Sat, 25 Apr 2026 08:23:04 +0100 Subject: [PATCH 07/22] test: add VmLens concurrent test for get on timed-out entry with insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds getOnTimedOutEntryWhileConcurrentInsertNeverReturnsStaleValue following the FlagdProviderCTest pattern: shared state prepared once before the interleaving loop in @BeforeEach, only Runner.runParallel inside the loop, and assertions embedded in the parallel lambdas. Verifies that get() on a timed-out entry concurrent with a put() of the same key never returns the stale value — result must be empty or the freshly inserted value. Signed-off-by: Mahesh Patil --- .../gcpsecretmanager/FlagCacheCTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java index 73fa7b2eb..8913bcea1 100644 --- a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java @@ -9,6 +9,7 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** @@ -18,6 +19,38 @@ */ class FlagCacheCTest { + // ------------------------------------------------------------------------- + // Shared state for getOnTimedOutEntryWhileConcurrentInsertNeverReturnsStaleValue + // ------------------------------------------------------------------------- + + private static final Instant T0 = Instant.parse("2024-01-01T00:00:00Z"); + private static final Instant T1 = T0.plusSeconds(31); + + private AtomicReference now; + private FlagCache cache; + + @BeforeEach + void setUp() { + now = new AtomicReference<>(T0); + Clock clock = new Clock() { + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + cache = new FlagCache(Duration.ofSeconds(30), 100, clock); + } + /** * Verifies that a concurrent expiry-removal does not accidentally evict a freshly inserted * entry that reuses the same key. The scenario is: @@ -80,4 +113,36 @@ public Instant instant() { } } } + + /** + * Verifies that while a timed-out entry is being read and a concurrent insert of the same key + * is ongoing, {@code get} never surfaces the stale value — it returns either nothing (expired + * entry removed before the insert) or the freshly inserted value (insert won the race). + * + *

Follows the VmLens pattern from {@code FlagdProviderCTest}: shared state is prepared once + * before the interleaving loop; only {@link Runner#runParallel} lives inside the loop; and + * assertions are embedded in the parallel lambdas so VmLens evaluates them under every + * explored scheduling. + */ + @Test + void getOnTimedOutEntryWhileConcurrentInsertNeverReturnsStaleValue() throws Exception { + // Prepare a single expired entry once — the clock stays at T1 for all interleavings + cache.put("key", "stale-value"); + now.set(T1); + + try (var interleavings = + new AllInterleavings("FlagCache: get on timed-out entry concurrent with insert")) { + while (interleavings.hasNext()) { + Runner.runParallel( + // Thread A: insert a fresh value for the same key + () -> cache.put("key", "new-value"), + // Thread B: get() on the timed-out entry must return nothing + // (expired entry removed) or "new-value" (Thread A won the race) — + // never the stale "stale-value" whose TTL has elapsed + () -> assertThat(cache.get("key")).satisfiesAnyOf( + opt -> assertThat(opt).isEmpty(), + opt -> assertThat(opt).hasValue("new-value"))); + } + } + } } From 177b40c91d102824db1b1747a5652767e7789096 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 16:26:42 +0100 Subject: [PATCH 08/22] Rename provider to gcp 1. Per review comments so secret and parameter manager both live in single provider 2. Common code/scaffolding will be part of initial PR along with Secret Manager provider. Signed-off-by: Mahesh Patil --- providers/gcp-secret-manager/CHANGELOG.md | 7 ------- providers/gcp/CHANGELOG.md | 7 +++++++ .../{gcp-secret-manager => gcp}/README.md | 6 +++--- providers/{gcp-secret-manager => gcp}/pom.xml | 20 ++++++++++--------- .../contrib/providers/gcp}/FlagCache.java | 16 ++++++++------- .../providers/gcp}/FlagValueConverter.java | 6 +++--- .../gcp}/GcpSecretManagerProvider.java | 0 .../gcp}/GcpSecretManagerProviderOptions.java | 0 .../gcp}/SecretManagerClientFactory.java | 0 .../gcpsecretmanager/FlagCacheCTest.java | 0 .../gcpsecretmanager/FlagCacheTest.java | 0 .../FlagValueConverterTest.java | 0 ...pSecretManagerProviderIntegrationTest.java | 0 .../GcpSecretManagerProviderTest.java | 0 .../src/test/resources/fixtures/bool-flag.txt | 0 .../test/resources/fixtures/double-flag.txt | 0 .../src/test/resources/fixtures/int-flag.txt | 0 .../test/resources/fixtures/object-flag.json | 0 .../test/resources/fixtures/string-flag.txt | 0 .../src/test/resources/log4j2-test.xml | 0 .../{gcp-secret-manager => gcp}/version.txt | 0 21 files changed, 33 insertions(+), 29 deletions(-) delete mode 100644 providers/gcp-secret-manager/CHANGELOG.md create mode 100644 providers/gcp/CHANGELOG.md rename providers/{gcp-secret-manager => gcp}/README.md (93%) rename providers/{gcp-secret-manager => gcp}/pom.xml (87%) rename providers/{gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager => gcp/src/main/java/dev/openfeature/contrib/providers/gcp}/FlagCache.java (87%) rename providers/{gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager => gcp/src/main/java/dev/openfeature/contrib/providers/gcp}/FlagValueConverter.java (96%) rename providers/{gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager => gcp/src/main/java/dev/openfeature/contrib/providers/gcp}/GcpSecretManagerProvider.java (100%) rename providers/{gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager => gcp/src/main/java/dev/openfeature/contrib/providers/gcp}/GcpSecretManagerProviderOptions.java (100%) rename providers/{gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager => gcp/src/main/java/dev/openfeature/contrib/providers/gcp}/SecretManagerClientFactory.java (100%) rename providers/{gcp-secret-manager => gcp}/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java (100%) rename providers/{gcp-secret-manager => gcp}/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java (100%) rename providers/{gcp-secret-manager => gcp}/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java (100%) rename providers/{gcp-secret-manager => gcp}/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java (100%) rename providers/{gcp-secret-manager => gcp}/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java (100%) rename providers/{gcp-secret-manager => gcp}/src/test/resources/fixtures/bool-flag.txt (100%) rename providers/{gcp-secret-manager => gcp}/src/test/resources/fixtures/double-flag.txt (100%) rename providers/{gcp-secret-manager => gcp}/src/test/resources/fixtures/int-flag.txt (100%) rename providers/{gcp-secret-manager => gcp}/src/test/resources/fixtures/object-flag.json (100%) rename providers/{gcp-secret-manager => gcp}/src/test/resources/fixtures/string-flag.txt (100%) rename providers/{gcp-secret-manager => gcp}/src/test/resources/log4j2-test.xml (100%) rename providers/{gcp-secret-manager => gcp}/version.txt (100%) diff --git a/providers/gcp-secret-manager/CHANGELOG.md b/providers/gcp-secret-manager/CHANGELOG.md deleted file mode 100644 index 916324863..000000000 --- a/providers/gcp-secret-manager/CHANGELOG.md +++ /dev/null @@ -1,7 +0,0 @@ -# Changelog - -## 0.0.1 - -### ✨ New Features - -* Initial release of the GCP Secret Manager OpenFeature provider. diff --git a/providers/gcp/CHANGELOG.md b/providers/gcp/CHANGELOG.md new file mode 100644 index 000000000..7b09cce02 --- /dev/null +++ b/providers/gcp/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.0.1 + +### ✨ New Features + +* Initial release with scaffolding for Google Cloud and GCP Secret Manager OpenFeature provider. diff --git a/providers/gcp-secret-manager/README.md b/providers/gcp/README.md similarity index 93% rename from providers/gcp-secret-manager/README.md rename to providers/gcp/README.md index 00e1a82a0..e5d69d69e 100644 --- a/providers/gcp-secret-manager/README.md +++ b/providers/gcp/README.md @@ -1,6 +1,6 @@ -# GCP Secret Manager Provider +# GCP Provider -An OpenFeature provider that reads feature flags from [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control. +An OpenFeature provider that reads feature flags from Google Cloud. Currently supports [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control. ## Installation @@ -8,7 +8,7 @@ An OpenFeature provider that reads feature flags from [Google Cloud Secret Manag ```xml dev.openfeature.contrib.providers - gcp-secret-manager + gcp 0.0.1 ``` diff --git a/providers/gcp-secret-manager/pom.xml b/providers/gcp/pom.xml similarity index 87% rename from providers/gcp-secret-manager/pom.xml rename to providers/gcp/pom.xml index 82c9b60a9..101a07043 100644 --- a/providers/gcp-secret-manager/pom.xml +++ b/providers/gcp/pom.xml @@ -1,8 +1,10 @@ - - + + 4.0.0 dev.openfeature.contrib @@ -12,16 +14,16 @@ dev.openfeature.contrib.providers - gcp-secret-manager + gcp 0.0.1 - ${groupId}.gcpsecretmanager + ${groupId}.gcp - gcp-secret-manager - GCP Secret Manager provider for OpenFeature Java SDK + gcp + GCP provider for OpenFeature Java SDK https://openfeature.dev diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java similarity index 87% rename from providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java rename to providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java index e5ca9e3c0..6b27a9ef7 100644 --- a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import java.time.Clock; import java.time.Duration; @@ -9,7 +9,7 @@ import java.util.Optional; /** - * Thread-safe TTL-based in-memory cache for flag values fetched from GCP Secret Manager. + * Thread-safe TTL-based in-memory cache for flag values fetched from GCP services. * *

Entries expire after the configured {@code ttl}. When the cache reaches {@code maxSize}, * the entry with the earliest insertion time is evicted in O(1) via {@link LinkedHashMap}'s @@ -28,12 +28,14 @@ class FlagCache { FlagCache(Duration ttl, int maxSize, Clock clock) { this.ttl = ttl; this.clock = clock; - this.store = Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, false) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxSize; + this.store = Collections.synchronizedMap( + new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } } - }); + ); } /** diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagValueConverter.java similarity index 96% rename from providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java rename to providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagValueConverter.java index e75f40082..9902c96f6 100644 --- a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagValueConverter.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -16,7 +16,7 @@ import lombok.extern.slf4j.Slf4j; /** - * Converts raw string secret values (fetched from GCP Secret Manager) into the + * Converts raw string values (fetched from GCP services) into the * typed values expected by the OpenFeature SDK evaluation methods. */ @Slf4j @@ -39,7 +39,7 @@ private FlagValueConverter() {} * non-JSON strings are wrapped in a string {@link Value} * * - * @param raw the raw string value from GCP Secret Manager + * @param raw the raw string value from GCP * @param targetType the desired OpenFeature type * @param the target type * @return the converted value diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java similarity index 100% rename from providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java rename to providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java similarity index 100% rename from providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java rename to providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java similarity index 100% rename from providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java rename to providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java similarity index 100% rename from providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java similarity index 100% rename from providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java similarity index 100% rename from providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java similarity index 100% rename from providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java similarity index 100% rename from providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt b/providers/gcp/src/test/resources/fixtures/bool-flag.txt similarity index 100% rename from providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt rename to providers/gcp/src/test/resources/fixtures/bool-flag.txt diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt b/providers/gcp/src/test/resources/fixtures/double-flag.txt similarity index 100% rename from providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt rename to providers/gcp/src/test/resources/fixtures/double-flag.txt diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt b/providers/gcp/src/test/resources/fixtures/int-flag.txt similarity index 100% rename from providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt rename to providers/gcp/src/test/resources/fixtures/int-flag.txt diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json b/providers/gcp/src/test/resources/fixtures/object-flag.json similarity index 100% rename from providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json rename to providers/gcp/src/test/resources/fixtures/object-flag.json diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt b/providers/gcp/src/test/resources/fixtures/string-flag.txt similarity index 100% rename from providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt rename to providers/gcp/src/test/resources/fixtures/string-flag.txt diff --git a/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml b/providers/gcp/src/test/resources/log4j2-test.xml similarity index 100% rename from providers/gcp-secret-manager/src/test/resources/log4j2-test.xml rename to providers/gcp/src/test/resources/log4j2-test.xml diff --git a/providers/gcp-secret-manager/version.txt b/providers/gcp/version.txt similarity index 100% rename from providers/gcp-secret-manager/version.txt rename to providers/gcp/version.txt From 26a50a60d5ee70151a1f7b0290d28dd1a5cec5d0 Mon Sep 17 00:00:00 2001 From: Mahesh Patil <17205424+mahpatil@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:55:20 +0100 Subject: [PATCH 09/22] refactor: use @BeforeEach fixtures in concurrentExpiryAndInsertDoNotLoseNewEntry Signed-off-by: Mahesh Patil --- .../gcpsecretmanager/FlagCacheCTest.java | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java index 8913bcea1..d6ac2c8f3 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java @@ -64,42 +64,16 @@ public Instant instant() { */ @Test void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { - // T0: entries inserted at this instant expire at T0 + 30 s - Instant t0 = Instant.parse("2024-01-01T00:00:00Z"); - // T1: 31 s later — the "expired-value" entry is stale, but a new entry inserted here - // won't expire until T1 + 30 s = T0 + 61 s, which is safely in the future - Instant t1 = t0.plusSeconds(31); - - AtomicReference now = new AtomicReference<>(t0); - Clock clock = new Clock() { - @Override - public ZoneOffset getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(java.time.ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return now.get(); - } - }; - - FlagCache cache = new FlagCache(Duration.ofSeconds(30), 100, clock); - try (AllInterleavings interleavings = new AllInterleavings("FlagCache concurrent expiry and re-insert")) { while (interleavings.hasNext()) { // Reset state for this interleaving: // - clock at T0 so the inserted entry records expiresAt = T0 + 30 s - now.set(t0); + now.set(T0); cache.clear(); cache.put("key", "expired-value"); // - advance clock to T1 so the entry is now expired - now.set(t1); + now.set(T1); Runner.runParallel( // Thread A: re-insert the same key with a fresh value From 836b27f0b94c95df2ea83b7462819e6d752a9210 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Sat, 25 Apr 2026 11:36:05 +0200 Subject: [PATCH 10/22] fix(gofeatureflag): issue when using inProcess with high concurency (#1781) Signed-off-by: Thomas Poignant Signed-off-by: Mahesh Patil --- providers/go-feature-flag/README.md | 1 + .../GoFeatureFlagProviderOptions.java | 12 +++ .../evaluator/InProcessEvaluator.java | 94 +++++++++++-------- .../providers/gofeatureflag/util/Const.java | 2 + .../gofeatureflag/wasm/WasmEvaluatorPool.java | 66 +++++++++++++ .../GoFeatureFlagProviderTest.java | 46 +++++++++ 6 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index e2eaa6bb1..ddadb5ab8 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -82,6 +82,7 @@ You can configure the provider with several options to customize its behavior. T | **`exporterMetadata`** | `false` | exporterMetadata is the metadata we send to the GO Feature Flag relay proxy when we report the evaluation data usage. | | **`evaluationFlagList`** | `false` | If you are using in process evaluation, by default we will load in memory all the flags available in the relay proxy. If you want to limit the number of flags loaded in memory, you can use this parameter. By setting this parameter, you will only load the flags available in the list.

If null or empty, all the flags available in the relay proxy will be loaded.

| | **`flagChangePollingIntervalMs`** | `false` | interval time we poll the proxy to check if the configuration has changed. It is used for the in process evaluation to check if we should refresh our internal cache. default: `120000` | +| **`wasmEvaluatorPoolSize`** | `false` | _(IN_PROCESS only)_ Number of WASM instances kept in the evaluation pool. Each instance owns independent memory, allowing fully concurrent flag evaluations without serialisation. Must be `>= 1`. _(default: number of available CPU cores)_ | ### Evaluate a feature flag The OpenFeature client is used to retrieve values for the current `EvaluationContext`. For example, retrieving a boolean value for the flag **"my-flag"**: diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java index cfa45664a..9cd620553 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderOptions.java @@ -91,6 +91,14 @@ public class GoFeatureFlagProviderOptions { */ private Long flagChangePollingIntervalMs; + /** + * (optional) Number of WASM instances kept in the evaluation pool for in-process evaluation. + * Each instance owns independent WASM linear memory, allowing fully concurrent evaluations + * without serialisation. Must be >= 1 when set explicitly. + * Default: number of available CPU cores. + */ + private Integer wasmEvaluatorPoolSize; + /** * Validate the options provided to the provider. * @@ -107,6 +115,10 @@ public void validate() throws InvalidOptions { throw new InvalidEndpoint("malformed endpoint: " + getEndpoint()); } + if (getWasmEvaluatorPoolSize() != null && getWasmEvaluatorPoolSize() < 1) { + throw new InvalidOptions("wasmEvaluatorPoolSize must be at least 1"); + } + if (getExporterMetadata() != null) { val acceptableExporterMetadataTypes = List.of("String", "Boolean", "Integer", "Double"); for (Map.Entry entry : getExporterMetadata().entrySet()) { diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java index d8c752db7..4a63cb3ad 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/evaluator/InProcessEvaluator.java @@ -6,7 +6,7 @@ import dev.openfeature.contrib.providers.gofeatureflag.bean.FlagConfigResponse; import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; import dev.openfeature.contrib.providers.gofeatureflag.util.Const; -import dev.openfeature.contrib.providers.gofeatureflag.wasm.EvaluationWasm; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.WasmEvaluatorPool; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.FlagContext; import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; import dev.openfeature.sdk.ErrorCode; @@ -16,7 +16,6 @@ import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; import java.util.ArrayList; import java.util.Collections; import java.util.Date; @@ -35,23 +34,35 @@ public class InProcessEvaluator implements IEvaluator { /** API to contact GO Feature Flag. */ private final GoFeatureFlagApi api; - /** WASM evaluation engine. */ - private final EvaluationWasm evaluationEngine; + /** Pool of WASM evaluation engine instances for thread-safe concurrent evaluation. */ + private final WasmEvaluatorPool evaluationPool; /** Options to configure the provider. */ private final GoFeatureFlagProviderOptions options; /** Method to call when we have a configuration change. */ private final Consumer emitProviderConfigurationChanged; - /** Local copy of the flags' configuration. */ - private Map flags; - /** Evaluation context enrichment. */ - private Map evaluationContextEnrichment; - /** Last hash of the flags' configuration. */ - private String etag; - /** Last update of the flags' configuration. */ - private Date lastUpdate; + /** Immutable snapshot of all flag configuration state; updated atomically by the polling daemon. */ + private volatile EvaluatorState state; /** disposable which manage the polling of the flag configurations. */ private Disposable configurationDisposable; + private static final class EvaluatorState { + final Map flags; + final Map evaluationContextEnrichment; + final String etag; + final Date lastUpdate; + + EvaluatorState( + Map flags, + Map evaluationContextEnrichment, + String etag, + Date lastUpdate) { + this.flags = flags; + this.evaluationContextEnrichment = evaluationContextEnrichment; + this.etag = etag; + this.lastUpdate = lastUpdate; + } + } + /** * Constructor of the InProcessEvaluator. * @@ -64,17 +75,19 @@ public InProcessEvaluator( GoFeatureFlagProviderOptions options, Consumer emitProviderConfigurationChanged) { this.api = api; - this.flags = Collections.emptyMap(); - this.etag = ""; this.options = options; - this.lastUpdate = new Date(0); this.emitProviderConfigurationChanged = emitProviderConfigurationChanged; - this.evaluationEngine = new EvaluationWasm(); + this.state = new EvaluatorState(Collections.emptyMap(), null, "", new Date(0)); + int poolSize = options.getWasmEvaluatorPoolSize() != null + ? options.getWasmEvaluatorPoolSize() + : Const.DEFAULT_WASM_EVALUATOR_POOL_SIZE; + this.evaluationPool = new WasmEvaluatorPool(poolSize); } @Override public GoFeatureFlagResponse evaluate(String key, Object defaultValue, EvaluationContext evaluationContext) { - if (this.flags.get(key) == null) { + EvaluatorState current = this.state; + if (current.flags.get(key) == null) { val err = new GoFeatureFlagResponse(); err.setReason(Reason.ERROR.name()); err.setErrorCode(ErrorCode.FLAG_NOT_FOUND.name()); @@ -85,30 +98,29 @@ public GoFeatureFlagResponse evaluate(String key, Object defaultValue, Evaluatio val wasmInput = WasmInput.builder() .flagContext(FlagContext.builder() .defaultSdkValue(defaultValue) - .evaluationContextEnrichment(this.evaluationContextEnrichment) + .evaluationContextEnrichment(current.evaluationContextEnrichment) .build()) .evalContext(evaluationContext.asObjectMap()) - .flag(this.flags.get(key)) + .flag(current.flags.get(key)) .flagKey(key) .build(); - return this.evaluationEngine.evaluate(wasmInput); + return this.evaluationPool.evaluate(wasmInput); } @Override public boolean isFlagTrackable(final String flagKey) { - Flag flag = this.flags.get(flagKey); + Flag flag = this.state.flags.get(flagKey); return flag != null && (flag.getTrackEvents() == null || flag.getTrackEvents()); } @Override public void init() { - val configFlags = api.retrieveFlagConfiguration(this.etag, options.getEvaluationFlagList()); - this.flags = configFlags.getFlags(); - this.etag = configFlags.getEtag(); - this.lastUpdate = configFlags.getLastUpdated(); - this.evaluationContextEnrichment = configFlags.getEvaluationContextEnrichment(); - // We call the WASM engine to avoid a cold start at the 1st evaluation - this.evaluationEngine.preWarmWasm(); + val configFlags = api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList()); + this.state = new EvaluatorState( + configFlags.getFlags(), + configFlags.getEvaluationContextEnrichment(), + configFlags.getEtag(), + configFlags.getLastUpdated()); // start the polling of the flag configuration this.configurationDisposable = startCheckFlagConfigurationChangesDaemon(); @@ -131,13 +143,11 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { ? options.getFlagChangePollingIntervalMs() : Const.DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS; - PublishSubject stopSignal = PublishSubject.create(); - Observable intervalObservable = Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS); + Observable intervalObservable = + Observable.interval(pollingIntervalMs, TimeUnit.MILLISECONDS, Schedulers.io()); Observable apiCallObservable = intervalObservable - // as soon something is published in stopSignal, the interval will stop - .takeUntil(stopSignal) - .flatMap(tick -> Observable.fromCallable( - () -> this.api.retrieveFlagConfiguration(this.etag, options.getEvaluationFlagList())) + .flatMap(tick -> Observable.fromCallable(() -> + this.api.retrieveFlagConfiguration(this.state.etag, options.getEvaluationFlagList())) .onErrorResumeNext(e -> { log.error("error while calling flag configuration API", e); return Observable.empty(); @@ -146,22 +156,24 @@ private Disposable startCheckFlagConfigurationChangesDaemon() { return apiCallObservable.subscribe( response -> { - if (response.getEtag().equals(this.etag)) { + EvaluatorState current = this.state; + if (response.getEtag().equals(current.etag)) { log.debug("flag configuration has not changed: {}", response); return; } - if (response.getLastUpdated().before(this.lastUpdate)) { + if (response.getLastUpdated().before(current.lastUpdate)) { log.info("configuration received is older than the current one"); return; } log.info("flag configuration has changed"); - this.etag = response.getEtag(); - this.lastUpdate = response.getLastUpdated(); - val flagChanges = findFlagConfigurationChanges(this.flags, response.getFlags()); - this.flags = response.getFlags(); - this.evaluationContextEnrichment = response.getEvaluationContextEnrichment(); + val flagChanges = findFlagConfigurationChanges(current.flags, response.getFlags()); + this.state = new EvaluatorState( + response.getFlags(), + response.getEvaluationContextEnrichment(), + response.getEtag(), + response.getLastUpdated()); val changeDetails = ProviderEventDetails.builder() .flagsChanged(flagChanges) .message("flag configuration has changed") diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java index 38b4f8e8a..6b3c6a326 100644 --- a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/util/Const.java @@ -21,6 +21,8 @@ public class Const { public static final long DEFAULT_POLLING_CONFIG_FLAG_CHANGE_INTERVAL_MS = 2L * 60L * 1000L; public static final long DEFAULT_FLUSH_INTERVAL_MS = Duration.ofMinutes(1).toMillis(); public static final int DEFAULT_MAX_PENDING_EVENTS = 10000; + public static final int DEFAULT_WASM_EVALUATOR_POOL_SIZE = + Runtime.getRuntime().availableProcessors(); // MAPPERS public static final ObjectMapper DESERIALIZE_OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); diff --git a/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java new file mode 100644 index 000000000..dd7a2a99f --- /dev/null +++ b/providers/go-feature-flag/src/main/java/dev/openfeature/contrib/providers/gofeatureflag/wasm/WasmEvaluatorPool.java @@ -0,0 +1,66 @@ +package dev.openfeature.contrib.providers.gofeatureflag.wasm; + +import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse; +import dev.openfeature.contrib.providers.gofeatureflag.exception.WasmFileNotFound; +import dev.openfeature.contrib.providers.gofeatureflag.wasm.bean.WasmInput; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Reason; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import lombok.extern.slf4j.Slf4j; + +/** + * WasmEvaluatorPool manages a fixed pool of EvaluationWasm instances. + * Each instance owns independent WASM linear memory, allowing concurrent + * evaluate() calls without interleaving memory operations. + */ +@Slf4j +public final class WasmEvaluatorPool { + private final BlockingQueue pool; + + /** + * Creates a pool of {@code size} independent EvaluationWasm instances. + * All instances are allocated eagerly so that first-call latency is + * absorbed at provider initialisation time. + * + * @param size number of WASM instances; must be >= 1 + * @throws WasmFileNotFound if the embedded WASM module cannot be loaded + */ + public WasmEvaluatorPool(int size) throws WasmFileNotFound { + this.pool = new ArrayBlockingQueue<>(size); + for (int i = 0; i < size; i++) { + EvaluationWasm instance = new EvaluationWasm(); + instance.preWarmWasm(); + pool.add(instance); + } + } + + /** + * Evaluates a feature flag by borrowing one WASM instance from the pool, + * delegating to it, and returning it when done. + * Blocks if all instances are busy until one becomes available. + * + * @param wasmInput evaluation input + * @return evaluation result + */ + public GoFeatureFlagResponse evaluate(WasmInput wasmInput) { + EvaluationWasm instance; + try { + instance = pool.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + GoFeatureFlagResponse err = new GoFeatureFlagResponse(); + err.setErrorCode(ErrorCode.GENERAL.name()); + err.setReason(Reason.ERROR.name()); + err.setErrorDetails("WASM evaluator pool interrupted while waiting for an available instance"); + return err; + } + try { + return instance.evaluate(wasmInput); + } finally { + if (!pool.offer(instance)) { + log.error("Failed to return WASM instance to pool — instance leaked, pool capacity may be compromised"); + } + } + } +} diff --git a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java index b11593e97..dd837f796 100644 --- a/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java +++ b/providers/go-feature-flag/src/test/java/dev/openfeature/contrib/providers/gofeatureflag/GoFeatureFlagProviderTest.java @@ -28,7 +28,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -596,6 +599,49 @@ void shouldNotApplyAScheduledRolloutStepIfTheDateIsInTheFuture() { assertEquals(want, got); } } + + @DisplayName("Should evaluate flags correctly under concurrent access") + @SneakyThrows + @Test + void shouldEvaluateFlagsCorrectlyUnderConcurrentAccess() { + GoFeatureFlagProvider provider = new GoFeatureFlagProvider(GoFeatureFlagProviderOptions.builder() + .endpoint(baseUrl.toString()) + .evaluationType(EvaluationType.IN_PROCESS) + .flagChangePollingIntervalMs(999999L) + .build()); + OpenFeatureAPI.getInstance().setProviderAndWait(testName, provider); + val client = OpenFeatureAPI.getInstance().getClient(testName); + + int threadCount = 20; + int evaluationsPerThread = 100; + AtomicInteger errorCount = new AtomicInteger(0); + CountDownLatch startGate = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + for (int t = 0; t < threadCount; t++) { + new Thread(() -> { + try { + startGate.await(); + for (int i = 0; i < evaluationsPerThread; i++) { + FlagEvaluationDetails result = client.getBooleanDetails( + "bool_targeting_match", false, TestUtils.defaultEvaluationContext); + if (result.getErrorCode() != null) { + errorCount.incrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }) + .start(); + } + + startGate.countDown(); + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not finish in time"); + assertEquals(0, errorCount.get(), "Concurrent evaluations produced errors"); + } } @Nested From 28aa584dc3ebd2a69071aeaa2a7e01fe4ad25386 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Sat, 25 Apr 2026 05:47:01 -0400 Subject: [PATCH 11/22] chore(main): release dev.openfeature.contrib.providers.go-feature-flag 1.1.2 (#1697) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Mahesh Patil --- .release-please-manifest.json | 2 +- providers/go-feature-flag/CHANGELOG.md | 9 +++++++++ providers/go-feature-flag/README.md | 2 +- providers/go-feature-flag/pom.xml | 2 +- providers/go-feature-flag/version.txt | 2 +- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c0d25d1da..c6f2f6253 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,7 +1,7 @@ { "hooks/open-telemetry": "3.3.1", "providers/flagd": "0.13.0", - "providers/go-feature-flag": "1.1.1", + "providers/go-feature-flag": "1.1.2", "providers/flagsmith": "0.0.13", "providers/env-var": "0.0.12", "providers/jsonlogic-eval-provider": "1.2.1", diff --git a/providers/go-feature-flag/CHANGELOG.md b/providers/go-feature-flag/CHANGELOG.md index dcaf406a7..e5184ce67 100644 --- a/providers/go-feature-flag/CHANGELOG.md +++ b/providers/go-feature-flag/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.1.2](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.providers.go-feature-flag-v1.1.1...dev.openfeature.contrib.providers.go-feature-flag-v1.1.2) (2026-04-25) + + +### 🐛 Bug Fixes + +* **gofeatureflag:** Added Locale.ENGLISH to SimpleDateFormat for parsing the Last-Modified HTTP header ([#1626](https://github.com/open-feature/java-sdk-contrib/issues/1626)) ([ca71995](https://github.com/open-feature/java-sdk-contrib/commit/ca719956f0c6980162bd5ebb0cc78255cd6768f8)) +* **gofeatureflag:** issue when using inProcess with high concurency ([#1781](https://github.com/open-feature/java-sdk-contrib/issues/1781)) ([e9a2431](https://github.com/open-feature/java-sdk-contrib/commit/e9a2431ea718b40d229070d5848d73359b7cdff2)) +* **security:** update dependency com.fasterxml.jackson.core:jackson-core to v2.21.1 [security] ([#1705](https://github.com/open-feature/java-sdk-contrib/issues/1705)) ([7760d09](https://github.com/open-feature/java-sdk-contrib/commit/7760d098b6c965c5a299e274ddf790e10b967738)) + ## [1.1.1](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.providers.go-feature-flag-v1.1.0...dev.openfeature.contrib.providers.go-feature-flag-v1.1.1) (2026-01-15) diff --git a/providers/go-feature-flag/README.md b/providers/go-feature-flag/README.md index ddadb5ab8..7868c4c8c 100644 --- a/providers/go-feature-flag/README.md +++ b/providers/go-feature-flag/README.md @@ -28,7 +28,7 @@ For documentation related to flags management in GO Feature Flag, refer to the [ dev.openfeature.contrib.providers go-feature-flag - 1.1.1 + 1.1.2 ``` diff --git a/providers/go-feature-flag/pom.xml b/providers/go-feature-flag/pom.xml index a62ab866a..01bf190fc 100644 --- a/providers/go-feature-flag/pom.xml +++ b/providers/go-feature-flag/pom.xml @@ -10,7 +10,7 @@ dev.openfeature.contrib.providers go-feature-flag - 1.1.1 + 1.1.2 go-feature-flag GO Feature Flag provider for Java diff --git a/providers/go-feature-flag/version.txt b/providers/go-feature-flag/version.txt index 524cb5524..45a1b3f44 100644 --- a/providers/go-feature-flag/version.txt +++ b/providers/go-feature-flag/version.txt @@ -1 +1 @@ -1.1.1 +1.1.2 From 1a0c292c92afacfb68ca067d4086278201d95b71 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 16:44:06 +0100 Subject: [PATCH 12/22] Refactor tests and samples for gcp provider Signed-off-by: Mahesh Patil --- .../gcp/GcpSecretManagerProvider.java | 26 +-- .../gcp/GcpSecretManagerProviderOptions.java | 2 +- .../gcp/SecretManagerClientFactory.java | 2 +- .../FlagCacheCTest.java | 38 +++-- .../FlagCacheTest.java | 0 .../FlagValueConverterTest.java | 23 ++- ...pSecretManagerProviderIntegrationTest.java | 39 ++--- .../GcpSecretManagerProviderTest.java | 0 samples/gcp-secret-manager-sample/README.md | 154 ------------------ samples/gcp-secret-manager-sample/pom.xml | 69 -------- samples/gcp-secret-manager-sample/setup.sh | 70 -------- .../SecretManagerSampleApp.java | 130 --------------- samples/gcp-secret-manager-sample/teardown.sh | 26 --- 13 files changed, 64 insertions(+), 515 deletions(-) rename providers/gcp/src/test/java/dev/openfeature/contrib/providers/{gcpsecretmanager => gcp}/FlagCacheCTest.java (75%) rename providers/gcp/src/test/java/dev/openfeature/contrib/providers/{gcpsecretmanager => gcp}/FlagCacheTest.java (100%) rename providers/gcp/src/test/java/dev/openfeature/contrib/providers/{gcpsecretmanager => gcp}/FlagValueConverterTest.java (89%) rename providers/gcp/src/test/java/dev/openfeature/contrib/providers/{gcpsecretmanager => gcp}/GcpSecretManagerProviderIntegrationTest.java (75%) rename providers/gcp/src/test/java/dev/openfeature/contrib/providers/{gcpsecretmanager => gcp}/GcpSecretManagerProviderTest.java (100%) delete mode 100644 samples/gcp-secret-manager-sample/README.md delete mode 100644 samples/gcp-secret-manager-sample/pom.xml delete mode 100644 samples/gcp-secret-manager-sample/setup.sh delete mode 100644 samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java delete mode 100644 samples/gcp-secret-manager-sample/teardown.sh diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java index f5ef19db3..0f23244b0 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import com.google.api.gax.rpc.NotFoundException; import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse; @@ -130,10 +130,7 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa private ProviderEvaluation evaluate(String key, Class targetType) { String rawValue = fetchWithCache(key); T value = FlagValueConverter.convert(rawValue, targetType); - return ProviderEvaluation.builder() - .value(value) - .reason(Reason.STATIC.toString()) - .build(); + return ProviderEvaluation.builder().value(value).reason(Reason.STATIC.toString()).build(); } private String fetchWithCache(String key) { @@ -143,11 +140,13 @@ private String fetchWithCache(String key) { return cached.get(); } synchronized (this) { - return cache.get(secretName).orElseGet(() -> { - String value = fetchFromGcp(secretName); - cache.put(secretName, value); - return value; - }); + return cache + .get(secretName) + .orElseGet(() -> { + String value = fetchFromGcp(secretName); + cache.put(secretName, value); + return value; + }); } } @@ -169,8 +168,11 @@ private String buildSecretName(String flagKey) { */ private String fetchFromGcp(String secretName) { try { - SecretVersionName versionName = - SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion()); + SecretVersionName versionName = SecretVersionName.of( + options.getProjectId(), + secretName, + options.getSecretVersion() + ); log.debug("Accessing secret '{}' from GCP", versionName); AccessSecretVersionResponse response = client.accessSecretVersion(versionName); return response.getPayload().getData().toStringUtf8(); diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java index e65f826e0..46c1bdf60 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import com.google.auth.oauth2.GoogleCredentials; import java.time.Duration; diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java index cb535c31a..a562f8469 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import com.google.api.gax.core.FixedCredentialsProvider; import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java similarity index 75% rename from providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java index d6ac2c8f3..ecb662836 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheCTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; @@ -15,7 +15,7 @@ /** * Concurrency tests for {@link FlagCache} using VmLens to explore thread interleavings. * - *

Run with: {@code mvn verify -pl providers/gcp-secret-manager -P concurrency-tests} + *

Run with: {@code mvn verify -pl providers/gcp -P concurrency-tests} */ class FlagCacheCTest { @@ -64,8 +64,7 @@ public Instant instant() { */ @Test void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { - try (AllInterleavings interleavings = - new AllInterleavings("FlagCache concurrent expiry and re-insert")) { + try (AllInterleavings interleavings = new AllInterleavings("FlagCache concurrent expiry and re-insert")) { while (interleavings.hasNext()) { // Reset state for this interleaving: // - clock at T0 so the inserted entry records expiresAt = T0 + 30 s @@ -76,10 +75,11 @@ void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { now.set(T1); Runner.runParallel( - // Thread A: re-insert the same key with a fresh value - () -> cache.put("key", "new-value"), - // Thread B: get() detects the stale entry and attempts removal - () -> cache.get("key")); + // Thread A: re-insert the same key with a fresh value + () -> cache.put("key", "new-value"), + // Thread B: get() detects the stale entry and attempts removal + () -> cache.get("key") + ); // Thread A's put() has returned, so "new-value" must be in the cache. // Thread B's expiry-removal must not have silently evicted it. @@ -104,18 +104,20 @@ void getOnTimedOutEntryWhileConcurrentInsertNeverReturnsStaleValue() throws Exce cache.put("key", "stale-value"); now.set(T1); - try (var interleavings = - new AllInterleavings("FlagCache: get on timed-out entry concurrent with insert")) { + try (var interleavings = new AllInterleavings("FlagCache: get on timed-out entry concurrent with insert")) { while (interleavings.hasNext()) { Runner.runParallel( - // Thread A: insert a fresh value for the same key - () -> cache.put("key", "new-value"), - // Thread B: get() on the timed-out entry must return nothing - // (expired entry removed) or "new-value" (Thread A won the race) — - // never the stale "stale-value" whose TTL has elapsed - () -> assertThat(cache.get("key")).satisfiesAnyOf( - opt -> assertThat(opt).isEmpty(), - opt -> assertThat(opt).hasValue("new-value"))); + // Thread A: insert a fresh value for the same key + () -> cache.put("key", "new-value"), + // Thread B: get() on the timed-out entry must return nothing + // (expired entry removed) or "new-value" (Thread A won the race) — + // never the stale "stale-value" whose TTL has elapsed + () -> + assertThat(cache.get("key")).satisfiesAnyOf( + opt -> assertThat(opt).isEmpty(), + opt -> assertThat(opt).hasValue("new-value") + ) + ); } } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java similarity index 100% rename from providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java similarity index 89% rename from providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java index 099e8a3e3..7cb292dda 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -20,14 +20,14 @@ class FlagValueConverterTest { class BooleanConversion { @ParameterizedTest - @ValueSource(strings = {"true", "True", "TRUE", "tRuE"}) + @ValueSource(strings = { "true", "True", "TRUE", "tRuE" }) @DisplayName("converts truthy strings to true") void trueVariants(String input) { assertThat(FlagValueConverter.convert(input, Boolean.class)).isTrue(); } @ParameterizedTest - @ValueSource(strings = {"false", "False", "FALSE", "fAlSe"}) + @ValueSource(strings = { "false", "False", "FALSE", "fAlSe" }) @DisplayName("converts falsy strings to false") void falseVariants(String input) { assertThat(FlagValueConverter.convert(input, Boolean.class)).isFalse(); @@ -36,8 +36,7 @@ void falseVariants(String input) { @Test @DisplayName("throws ParseError for non-boolean string") void nonBooleanThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)).isInstanceOf(ParseError.class); } } @@ -60,8 +59,7 @@ void negativeNumericString() { @Test @DisplayName("throws ParseError for non-numeric string") void nonNumericThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)).isInstanceOf(ParseError.class); } } @@ -90,8 +88,9 @@ void exponentialNotation() { @Test @DisplayName("throws ParseError for non-numeric string") void nonNumericThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)).isInstanceOf( + ParseError.class + ); } } @@ -139,14 +138,12 @@ void plainStringWrapped() { @Test @DisplayName("throws TypeMismatchError for unsupported type") void unsupportedTypeThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)) - .isInstanceOf(TypeMismatchError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)).isInstanceOf(TypeMismatchError.class); } @Test @DisplayName("throws ParseError when raw value is null") void nullRawThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)).isInstanceOf(ParseError.class); } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java similarity index 75% rename from providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java index a68cd3411..7c3ad043f 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; @@ -48,8 +48,7 @@ class GcpSecretManagerProviderIntegrationTest { @BeforeEach void setUp() throws Exception { String projectId = System.getenv("GCP_PROJECT_ID"); - GcpSecretManagerProviderOptions opts = - GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); provider = new GcpSecretManagerProvider(opts); provider.initialize(new ImmutableContext()); } @@ -62,43 +61,41 @@ void tearDown() { @Test @DisplayName("evaluates boolean secret") void booleanFlag() { - assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) - .getValue()) - .isTrue(); + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()).getValue()).isTrue(); } @Test @DisplayName("evaluates string secret") void stringFlag() { - assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) - .getValue()) - .isEqualTo("hello"); + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()).getValue()).isEqualTo( + "hello" + ); } @Test @DisplayName("evaluates integer secret") void integerFlag() { - assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) - .getValue()) - .isEqualTo(99); + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()).getValue()).isEqualTo(99); } @Test @DisplayName("evaluates double secret") void doubleFlag() { - assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) - .getValue()) - .isEqualTo(2.71); + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()).getValue()).isEqualTo( + 2.71 + ); } @Test @DisplayName("evaluates object secret as Value/Structure") void objectFlag() { - assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) - .getValue() - .asStructure() - .getValue("key") - .asString()) - .isEqualTo("val"); + assertThat( + provider + .getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString() + ).isEqualTo("val"); } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java similarity index 100% rename from providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java rename to providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java diff --git a/samples/gcp-secret-manager-sample/README.md b/samples/gcp-secret-manager-sample/README.md deleted file mode 100644 index 99fffaa9e..000000000 --- a/samples/gcp-secret-manager-sample/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# GCP Secret Manager — OpenFeature Sample - -A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp-secret-manager). - -It evaluates five feature flags (covering every supported type) that are stored as secrets in -Google Cloud Secret Manager. - -## Feature Flags Used - -| Secret name (GCP) | Flag key | Type | Example value | -|---|---|---|---| -| `of-sample-dark-mode` | `dark-mode` | boolean | `true` | -| `of-sample-banner-text` | `banner-text` | string | `"Welcome! 10% off today only"` | -| `of-sample-max-cart-items` | `max-cart-items` | integer | `25` | -| `of-sample-discount-rate` | `discount-rate` | double | `0.10` | -| `of-sample-checkout-config` | `checkout-config` | object (JSON) | `{"paymentMethods":["card","paypal"],...}` | - -All secrets are prefixed with `of-sample-` so they are easy to identify and clean up. - ---- - -## Prerequisites - -| Tool | Version | Install | -|---|---|---| -| Java | 17+ | [adoptium.net](https://adoptium.net) | -| Maven | 3.8+ | [maven.apache.org](https://maven.apache.org) | -| gcloud CLI | any | [cloud.google.com/sdk](https://cloud.google.com/sdk/docs/install) | -| GCP project | — | [console.cloud.google.com](https://console.cloud.google.com) | - -Your GCP account needs the **Secret Manager Secret Accessor** role (`roles/secretmanager.secretAccessor`) -on the project. - ---- - -## Step 1 — Enable the API - -```bash -export GCP_PROJECT_ID=my-gcp-project # replace with your project ID - -gcloud services enable secretmanager.googleapis.com --project="$GCP_PROJECT_ID" -``` - -## Step 2 — Authenticate - -```bash -gcloud auth application-default login -``` - -## Step 3 — Build the provider - -From the **root** of `java-sdk-contrib`: - -```bash -mvn install -DskipTests -P '!deploy' -``` - -This installs the provider JAR to your local Maven repository (`~/.m2`). - -## Step 4 — Create the feature-flag secrets - -```bash -cd samples/gcp-secret-manager-sample -bash setup.sh -``` - -You should see output like: - -``` -Creating sample feature-flag secrets in project: my-gcp-project - [CREATED] of-sample-dark-mode - [VERSION] of-sample-dark-mode → true - [CREATED] of-sample-banner-text - ... -✓ All secrets created successfully. -``` - -## Step 5 — Run the sample - -```bash -mvn exec:java -``` - -The app reads `GCP_PROJECT_ID` from the environment. You can also pass it explicitly: - -```bash -mvn exec:java -DGCP_PROJECT_ID=my-gcp-project -``` - -### Expected output - -``` -======================================================= - GCP Secret Manager — OpenFeature Sample -======================================================= -Project : my-gcp-project -Prefix : of-sample- - -── Boolean Flag » dark-mode ────────────────────────────────────── -Value : true -Effect : Dark theme activated - -── String Flag » banner-text ───────────────────────────────────── -Value : Welcome! 10% off today only - -── Integer Flag » max-cart-items ───────────────────────────────── -Value : 25 -Effect : Cart is capped at 25 items - -── Double Flag » discount-rate ─────────────────────────────────── -Value : 0.10 -Effect : 10% discount applied to cart total - -── Object Flag » checkout-config ───────────────────────────────── -Value : Structure{...} -Payment methods : ["card", "paypal"] -Express checkout : true - -======================================================= - All flags evaluated successfully. -======================================================= -``` - -## Step 6 — Clean up - -```bash -bash teardown.sh -``` - ---- - -## Changing flag values - -To update a flag, add a new secret version: - -```bash -echo -n "false" | gcloud secrets versions add of-sample-dark-mode \ - --project="$GCP_PROJECT_ID" --data-file=- -``` - -Re-run the sample to see the new value (cache expires after 30 seconds in this sample). - ---- - -## Troubleshooting - -| Error | Cause | Fix | -|---|---|---| -| `GCP_PROJECT_ID is not set` | Env var missing | `export GCP_PROJECT_ID=my-project` | -| `FlagNotFoundError` | Secret doesn't exist | Run `setup.sh` first | -| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/secretmanager.secretAccessor` | -| `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | -| `secretmanager.googleapis.com is not enabled` | API disabled | Run Step 1 | -| `Could not find artifact ...gcp-secret-manager` | Provider not installed | Run Step 3 | diff --git a/samples/gcp-secret-manager-sample/pom.xml b/samples/gcp-secret-manager-sample/pom.xml deleted file mode 100644 index 3689ae726..000000000 --- a/samples/gcp-secret-manager-sample/pom.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - 4.0.0 - - dev.openfeature.contrib.samples - gcp-secret-manager-sample - 1.0-SNAPSHOT - jar - - GCP Secret Manager OpenFeature Sample - - Runnable sample demonstrating the GCP Secret Manager OpenFeature provider. - Evaluates five feature flags (bool, string, int, double, object) stored as - GCP secrets against a real GCP project. - - - - 17 - ${java.version} - ${java.version} - UTF-8 - - ${env.GCP_PROJECT_ID} - - - - - - dev.openfeature.contrib.providers - gcp-secret-manager - 0.0.1 - - - - - dev.openfeature - sdk - 1.12.1 - - - - - org.slf4j - slf4j-simple - 2.0.17 - - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.5.0 - - dev.openfeature.contrib.samples.gcpsecretmanager.SecretManagerSampleApp - - - GCP_PROJECT_ID - ${GCP_PROJECT_ID} - - - - - - - diff --git a/samples/gcp-secret-manager-sample/setup.sh b/samples/gcp-secret-manager-sample/setup.sh deleted file mode 100644 index 777a1a25a..000000000 --- a/samples/gcp-secret-manager-sample/setup.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -# setup.sh — Creates the five sample feature-flag secrets in GCP Secret Manager. -# -# Prerequisites: -# - gcloud CLI installed and authenticated (gcloud auth application-default login) -# - Secret Manager API enabled: gcloud services enable secretmanager.googleapis.com -# - GCP_PROJECT_ID environment variable set to your GCP project ID -# -# Usage: -# export GCP_PROJECT_ID=my-gcp-project -# bash setup.sh - -set -euo pipefail - -PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" - -echo "Creating sample feature-flag secrets in project: ${PROJECT}" -echo "All secrets are prefixed with 'of-sample-' to match the sample app." -echo "" - -# ──────────────────────────────────────────────────────────────────────────────── -create_secret() { - local name="$1" - local value="$2" - local full_name="of-sample-${name}" - - # Create the secret resource (idempotent — ignores "already exists") - if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then - echo " [EXISTS] ${full_name} — adding new version" - else - gcloud secrets create "${full_name}" \ - --project="${PROJECT}" \ - --replication-policy=automatic \ - --quiet - echo " [CREATED] ${full_name}" - fi - - # Add a secret version with the flag value - echo -n "${value}" | gcloud secrets versions add "${full_name}" \ - --project="${PROJECT}" \ - --data-file=- \ - --quiet - echo " [VERSION] ${full_name} → ${value}" -} -# ──────────────────────────────────────────────────────────────────────────────── - -# Boolean flag: dark UI theme toggle -create_secret "dark-mode" "true" - -# String flag: hero banner text -create_secret "banner-text" "Welcome! 10% off today only" - -# Integer flag: maximum items in cart -create_secret "max-cart-items" "25" - -# Double flag: discount multiplier (10%) -create_secret "discount-rate" "0.10" - -# Object flag: structured checkout configuration (JSON) -create_secret "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' - -echo "" -echo "✓ All secrets created successfully." -echo "" -echo "Next steps:" -echo " 1. Authenticate: gcloud auth application-default login" -echo " 2. Run the sample:" -echo " cd gcp-secret-manager-sample" -echo " mvn exec:java # GCP_PROJECT_ID must still be set in your shell" -echo " 3. To clean up: bash teardown.sh" diff --git a/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java b/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java deleted file mode 100644 index 4e1d24276..000000000 --- a/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java +++ /dev/null @@ -1,130 +0,0 @@ -package dev.openfeature.contrib.samples.gcpsecretmanager; - -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Value; -import java.time.Duration; - -/** - * Sample application demonstrating the GCP Secret Manager OpenFeature provider. - * - *

This app evaluates five feature flags backed by GCP Secret Manager secrets: - *

    - *
  • {@code of-sample-dark-mode} (boolean) — whether the dark UI theme is enabled
  • - *
  • {@code of-sample-banner-text} (string) — hero banner copy shown to users
  • - *
  • {@code of-sample-max-cart-items} (integer) — maximum items allowed in the cart
  • - *
  • {@code of-sample-discount-rate} (double) — discount multiplier (0.0 – 1.0)
  • - *
  • {@code of-sample-checkout-config} (object/JSON) — structured checkout settings
  • - *
- * - *

Run {@code setup.sh} first to create these secrets in your GCP project, then: - *

- *   export GCP_PROJECT_ID=my-gcp-project
- *   mvn exec:java
- * 
- */ -public class SecretManagerSampleApp { - - private static final String PREFIX = "of-sample-"; - - public static void main(String[] args) throws Exception { - String projectId = resolveProjectId(args); - - System.out.println("======================================================="); - System.out.println(" GCP Secret Manager — OpenFeature Sample"); - System.out.println("======================================================="); - System.out.println("Project : " + projectId); - System.out.println("Prefix : " + PREFIX); - System.out.println(); - - // Build provider options - GcpSecretManagerProviderOptions options = - GcpSecretManagerProviderOptions.builder() - .projectId(projectId) - .secretNamePrefix(PREFIX) // secrets are named "of-sample-" - .secretVersion("latest") - .cacheExpiry(Duration.ofSeconds(30)) - .build(); - - // Register the provider with OpenFeature - GcpSecretManagerProvider provider = new GcpSecretManagerProvider(options); - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(provider); - Client client = api.getClient(); - - // Evaluation context (optional — demonstrates passing user context) - MutableContext ctx = new MutableContext(); - ctx.add("userId", "user-42"); - - // ── Boolean flag ──────────────────────────────────────────────────────────── - printHeader("Boolean Flag » dark-mode"); - boolean darkMode = client.getBooleanValue("dark-mode", false, ctx); - System.out.println("Value : " + darkMode); - System.out.println("Effect : " + (darkMode ? "Dark theme activated" : "Light theme active")); - - // ── String flag ───────────────────────────────────────────────────────────── - printHeader("String Flag » banner-text"); - String bannerText = client.getStringValue("banner-text", "Welcome!", ctx); - System.out.println("Value : " + bannerText); - - // ── Integer flag ───────────────────────────────────────────────────────────── - printHeader("Integer Flag » max-cart-items"); - int maxCartItems = client.getIntegerValue("max-cart-items", 10, ctx); - System.out.println("Value : " + maxCartItems); - System.out.println("Effect : Cart is capped at " + maxCartItems + " items"); - - // ── Double flag ────────────────────────────────────────────────────────────── - printHeader("Double Flag » discount-rate"); - double discountRate = client.getDoubleValue("discount-rate", 0.0, ctx); - System.out.printf("Value : %.2f%n", discountRate); - System.out.printf("Effect : %.0f%% discount applied to cart total%n", discountRate * 100); - - // ── Object flag (JSON) ─────────────────────────────────────────────────────── - printHeader("Object Flag » checkout-config"); - Value checkoutConfig = client.getObjectValue("checkout-config", new Value(), ctx); - System.out.println("Value : " + checkoutConfig); - if (checkoutConfig.isStructure()) { - Value paymentMethods = checkoutConfig.asStructure().getValue("paymentMethods"); - Value expressCheckout = checkoutConfig.asStructure().getValue("expressCheckout"); - System.out.println("Payment methods : " + paymentMethods); - System.out.println("Express checkout : " + expressCheckout); - } - - System.out.println(); - System.out.println("======================================================="); - System.out.println(" All flags evaluated successfully."); - System.out.println("======================================================="); - - api.shutdown(); - } - - private static String resolveProjectId(String[] args) { - // 1. CLI argument - if (args.length > 0 && !args[0].isBlank()) { - return args[0]; - } - // 2. Environment variable - String fromEnv = System.getenv("GCP_PROJECT_ID"); - if (fromEnv != null && !fromEnv.isBlank()) { - return fromEnv; - } - // 3. System property (set via -DGCP_PROJECT_ID=... or exec plugin config) - String fromProp = System.getProperty("GCP_PROJECT_ID"); - if (fromProp != null && !fromProp.isBlank()) { - return fromProp; - } - System.err.println("ERROR: GCP_PROJECT_ID is not set."); - System.err.println("Usage: export GCP_PROJECT_ID=my-project && mvn exec:java"); - System.err.println(" or: mvn exec:java -DGCP_PROJECT_ID=my-project"); - System.exit(1); - return null; // unreachable - } - - private static void printHeader(String title) { - System.out.println(); - System.out.println("── " + title + " " + "─".repeat(Math.max(0, 50 - title.length()))); - } -} diff --git a/samples/gcp-secret-manager-sample/teardown.sh b/samples/gcp-secret-manager-sample/teardown.sh deleted file mode 100644 index ec5997e77..000000000 --- a/samples/gcp-secret-manager-sample/teardown.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# teardown.sh — Deletes the sample feature-flag secrets from GCP Secret Manager. -# -# Usage: -# export GCP_PROJECT_ID=my-gcp-project -# bash teardown.sh - -set -euo pipefail - -PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" - -echo "Deleting sample secrets from project: ${PROJECT}" -echo "" - -for name in dark-mode banner-text max-cart-items discount-rate checkout-config; do - full_name="of-sample-${name}" - if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then - gcloud secrets delete "${full_name}" --project="${PROJECT}" --quiet - echo " [DELETED] ${full_name}" - else - echo " [SKIP] ${full_name} (not found)" - fi -done - -echo "" -echo "✓ Cleanup complete." From 4db90cdc7c30dab2648ef54d3df03bbcfd3b94eb Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 16:44:48 +0100 Subject: [PATCH 13/22] Restore flagd-core tools back to upstream Signed-off-by: Mahesh Patil --- samples/gcp/README.md | 154 ++++++++++++++++++ samples/gcp/pom.xml | 72 ++++++++ samples/gcp/setup.sh | 70 ++++++++ .../samples/gcp/SecretManagerSampleApp.java | 130 +++++++++++++++ samples/gcp/teardown.sh | 26 +++ tools/flagd-core/CHANGELOG.md | 21 +++ tools/flagd-core/README.md | 2 +- tools/flagd-core/pom.xml | 6 +- .../contrib/tools/flagd/core/FlagdCore.java | 14 +- .../tools/flagd/core/model/FlagParser.java | 19 +-- .../flagd/core/targeting/Fractional.java | 39 +++-- .../tools/flagd/core/targeting/Operator.java | 13 +- .../tools/flagd/core/targeting/SemVer.java | 66 +++++++- .../core/e2e/FlagdCoreEvaluatorTest.java | 2 +- .../flagd/core/targeting/FractionalTest.java | 70 ++++++++ .../flagd/core/targeting/SemVerTest.java | 32 +++- .../flagd/core/targeting/StringCompTest.java | 27 ++- tools/flagd-core/version.txt | 2 +- 18 files changed, 718 insertions(+), 47 deletions(-) create mode 100644 samples/gcp/README.md create mode 100644 samples/gcp/pom.xml create mode 100644 samples/gcp/setup.sh create mode 100644 samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java create mode 100644 samples/gcp/teardown.sh diff --git a/samples/gcp/README.md b/samples/gcp/README.md new file mode 100644 index 000000000..585816e3b --- /dev/null +++ b/samples/gcp/README.md @@ -0,0 +1,154 @@ +# GCP — OpenFeature Sample + +A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp-secret-manager). + +It evaluates five feature flags (covering every supported type) that are stored as secrets in +Google Cloud Secret Manager. + +## Feature Flags Used + +| Secret name (GCP) | Flag key | Type | Example value | +|---|---|---|---| +| `of-sample-dark-mode` | `dark-mode` | boolean | `true` | +| `of-sample-banner-text` | `banner-text` | string | `"Welcome! 10% off today only"` | +| `of-sample-max-cart-items` | `max-cart-items` | integer | `25` | +| `of-sample-discount-rate` | `discount-rate` | double | `0.10` | +| `of-sample-checkout-config` | `checkout-config` | object (JSON) | `{"paymentMethods":["card","paypal"],...}` | + +All secrets are prefixed with `of-sample-` so they are easy to identify and clean up. + +--- + +## Prerequisites + +| Tool | Version | Install | +|---|---|---| +| Java | 17+ | [adoptium.net](https://adoptium.net) | +| Maven | 3.8+ | [maven.apache.org](https://maven.apache.org) | +| gcloud CLI | any | [cloud.google.com/sdk](https://cloud.google.com/sdk/docs/install) | +| GCP project | — | [console.cloud.google.com](https://console.cloud.google.com) | + +Your GCP account needs the **Secret Manager Secret Accessor** role (`roles/secretmanager.secretAccessor`) +on the project. + +--- + +## Step 1 — Enable the API + +```bash +export GCP_PROJECT_ID=my-gcp-project # replace with your project ID + +gcloud services enable secretmanager.googleapis.com --project="$GCP_PROJECT_ID" +``` + +## Step 2 — Authenticate + +```bash +gcloud auth application-default login +``` + +## Step 3 — Build the provider + +From the **root** of `java-sdk-contrib`: + +```bash +mvn install -DskipTests -P '!deploy' +``` + +This installs the provider JAR to your local Maven repository (`~/.m2`). + +## Step 4 — Create the feature-flag secrets + +```bash +cd samples/gcp-secret-manager-sample +bash setup.sh +``` + +You should see output like: + +``` +Creating sample feature-flag secrets in project: my-gcp-project + [CREATED] of-sample-dark-mode + [VERSION] of-sample-dark-mode → true + [CREATED] of-sample-banner-text + ... +✓ All secrets created successfully. +``` + +## Step 5 — Run the sample + +```bash +mvn exec:java +``` + +The app reads `GCP_PROJECT_ID` from the environment. You can also pass it explicitly: + +```bash +mvn exec:java -DGCP_PROJECT_ID=my-gcp-project +``` + +### Expected output + +``` +======================================================= + GCP Secret Manager — OpenFeature Sample +======================================================= +Project : my-gcp-project +Prefix : of-sample- + +── Boolean Flag » dark-mode ────────────────────────────────────── +Value : true +Effect : Dark theme activated + +── String Flag » banner-text ───────────────────────────────────── +Value : Welcome! 10% off today only + +── Integer Flag » max-cart-items ───────────────────────────────── +Value : 25 +Effect : Cart is capped at 25 items + +── Double Flag » discount-rate ─────────────────────────────────── +Value : 0.10 +Effect : 10% discount applied to cart total + +── Object Flag » checkout-config ───────────────────────────────── +Value : Structure{...} +Payment methods : ["card", "paypal"] +Express checkout : true + +======================================================= + All flags evaluated successfully. +======================================================= +``` + +## Step 6 — Clean up + +```bash +bash teardown.sh +``` + +--- + +## Changing flag values + +To update a flag, add a new secret version: + +```bash +echo -n "false" | gcloud secrets versions add of-sample-dark-mode \ + --project="$GCP_PROJECT_ID" --data-file=- +``` + +Re-run the sample to see the new value (cache expires after 30 seconds in this sample). + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GCP_PROJECT_ID is not set` | Env var missing | `export GCP_PROJECT_ID=my-project` | +| `FlagNotFoundError` | Secret doesn't exist | Run `setup.sh` first | +| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/secretmanager.secretAccessor` | +| `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | +| `secretmanager.googleapis.com is not enabled` | API disabled | Run Step 1 | +| `Could not find artifact ...gcp-secret-manager` | Provider not installed | Run Step 3 | diff --git a/samples/gcp/pom.xml b/samples/gcp/pom.xml new file mode 100644 index 000000000..f781e470b --- /dev/null +++ b/samples/gcp/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + dev.openfeature.contrib.samples + gcp-sample + 1.0-SNAPSHOT + jar + + GCP OpenFeature Sample + + Runnable sample demonstrating the GCP OpenFeature provider. + Evaluates five feature flags (bool, string, int, double, object) stored as + GCP secrets against a real GCP project. + + + + 17 + ${java.version} + ${java.version} + UTF-8 + + ${env.GCP_PROJECT_ID} + + + + + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + + + + + dev.openfeature + sdk + 1.12.1 + + + + + org.slf4j + slf4j-simple + 2.0.17 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + dev.openfeature.contrib.samples.gcpsecretmanager.SecretManagerSampleApp + + + GCP_PROJECT_ID + ${GCP_PROJECT_ID} + + + + + + + diff --git a/samples/gcp/setup.sh b/samples/gcp/setup.sh new file mode 100644 index 000000000..777a1a25a --- /dev/null +++ b/samples/gcp/setup.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# setup.sh — Creates the five sample feature-flag secrets in GCP Secret Manager. +# +# Prerequisites: +# - gcloud CLI installed and authenticated (gcloud auth application-default login) +# - Secret Manager API enabled: gcloud services enable secretmanager.googleapis.com +# - GCP_PROJECT_ID environment variable set to your GCP project ID +# +# Usage: +# export GCP_PROJECT_ID=my-gcp-project +# bash setup.sh + +set -euo pipefail + +PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" + +echo "Creating sample feature-flag secrets in project: ${PROJECT}" +echo "All secrets are prefixed with 'of-sample-' to match the sample app." +echo "" + +# ──────────────────────────────────────────────────────────────────────────────── +create_secret() { + local name="$1" + local value="$2" + local full_name="of-sample-${name}" + + # Create the secret resource (idempotent — ignores "already exists") + if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then + echo " [EXISTS] ${full_name} — adding new version" + else + gcloud secrets create "${full_name}" \ + --project="${PROJECT}" \ + --replication-policy=automatic \ + --quiet + echo " [CREATED] ${full_name}" + fi + + # Add a secret version with the flag value + echo -n "${value}" | gcloud secrets versions add "${full_name}" \ + --project="${PROJECT}" \ + --data-file=- \ + --quiet + echo " [VERSION] ${full_name} → ${value}" +} +# ──────────────────────────────────────────────────────────────────────────────── + +# Boolean flag: dark UI theme toggle +create_secret "dark-mode" "true" + +# String flag: hero banner text +create_secret "banner-text" "Welcome! 10% off today only" + +# Integer flag: maximum items in cart +create_secret "max-cart-items" "25" + +# Double flag: discount multiplier (10%) +create_secret "discount-rate" "0.10" + +# Object flag: structured checkout configuration (JSON) +create_secret "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' + +echo "" +echo "✓ All secrets created successfully." +echo "" +echo "Next steps:" +echo " 1. Authenticate: gcloud auth application-default login" +echo " 2. Run the sample:" +echo " cd gcp-secret-manager-sample" +echo " mvn exec:java # GCP_PROJECT_ID must still be set in your shell" +echo " 3. To clean up: bash teardown.sh" diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java new file mode 100644 index 000000000..4e1d24276 --- /dev/null +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java @@ -0,0 +1,130 @@ +package dev.openfeature.contrib.samples.gcpsecretmanager; + +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import java.time.Duration; + +/** + * Sample application demonstrating the GCP Secret Manager OpenFeature provider. + * + *

This app evaluates five feature flags backed by GCP Secret Manager secrets: + *

    + *
  • {@code of-sample-dark-mode} (boolean) — whether the dark UI theme is enabled
  • + *
  • {@code of-sample-banner-text} (string) — hero banner copy shown to users
  • + *
  • {@code of-sample-max-cart-items} (integer) — maximum items allowed in the cart
  • + *
  • {@code of-sample-discount-rate} (double) — discount multiplier (0.0 – 1.0)
  • + *
  • {@code of-sample-checkout-config} (object/JSON) — structured checkout settings
  • + *
+ * + *

Run {@code setup.sh} first to create these secrets in your GCP project, then: + *

+ *   export GCP_PROJECT_ID=my-gcp-project
+ *   mvn exec:java
+ * 
+ */ +public class SecretManagerSampleApp { + + private static final String PREFIX = "of-sample-"; + + public static void main(String[] args) throws Exception { + String projectId = resolveProjectId(args); + + System.out.println("======================================================="); + System.out.println(" GCP Secret Manager — OpenFeature Sample"); + System.out.println("======================================================="); + System.out.println("Project : " + projectId); + System.out.println("Prefix : " + PREFIX); + System.out.println(); + + // Build provider options + GcpSecretManagerProviderOptions options = + GcpSecretManagerProviderOptions.builder() + .projectId(projectId) + .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .secretVersion("latest") + .cacheExpiry(Duration.ofSeconds(30)) + .build(); + + // Register the provider with OpenFeature + GcpSecretManagerProvider provider = new GcpSecretManagerProvider(options); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(provider); + Client client = api.getClient(); + + // Evaluation context (optional — demonstrates passing user context) + MutableContext ctx = new MutableContext(); + ctx.add("userId", "user-42"); + + // ── Boolean flag ──────────────────────────────────────────────────────────── + printHeader("Boolean Flag » dark-mode"); + boolean darkMode = client.getBooleanValue("dark-mode", false, ctx); + System.out.println("Value : " + darkMode); + System.out.println("Effect : " + (darkMode ? "Dark theme activated" : "Light theme active")); + + // ── String flag ───────────────────────────────────────────────────────────── + printHeader("String Flag » banner-text"); + String bannerText = client.getStringValue("banner-text", "Welcome!", ctx); + System.out.println("Value : " + bannerText); + + // ── Integer flag ───────────────────────────────────────────────────────────── + printHeader("Integer Flag » max-cart-items"); + int maxCartItems = client.getIntegerValue("max-cart-items", 10, ctx); + System.out.println("Value : " + maxCartItems); + System.out.println("Effect : Cart is capped at " + maxCartItems + " items"); + + // ── Double flag ────────────────────────────────────────────────────────────── + printHeader("Double Flag » discount-rate"); + double discountRate = client.getDoubleValue("discount-rate", 0.0, ctx); + System.out.printf("Value : %.2f%n", discountRate); + System.out.printf("Effect : %.0f%% discount applied to cart total%n", discountRate * 100); + + // ── Object flag (JSON) ─────────────────────────────────────────────────────── + printHeader("Object Flag » checkout-config"); + Value checkoutConfig = client.getObjectValue("checkout-config", new Value(), ctx); + System.out.println("Value : " + checkoutConfig); + if (checkoutConfig.isStructure()) { + Value paymentMethods = checkoutConfig.asStructure().getValue("paymentMethods"); + Value expressCheckout = checkoutConfig.asStructure().getValue("expressCheckout"); + System.out.println("Payment methods : " + paymentMethods); + System.out.println("Express checkout : " + expressCheckout); + } + + System.out.println(); + System.out.println("======================================================="); + System.out.println(" All flags evaluated successfully."); + System.out.println("======================================================="); + + api.shutdown(); + } + + private static String resolveProjectId(String[] args) { + // 1. CLI argument + if (args.length > 0 && !args[0].isBlank()) { + return args[0]; + } + // 2. Environment variable + String fromEnv = System.getenv("GCP_PROJECT_ID"); + if (fromEnv != null && !fromEnv.isBlank()) { + return fromEnv; + } + // 3. System property (set via -DGCP_PROJECT_ID=... or exec plugin config) + String fromProp = System.getProperty("GCP_PROJECT_ID"); + if (fromProp != null && !fromProp.isBlank()) { + return fromProp; + } + System.err.println("ERROR: GCP_PROJECT_ID is not set."); + System.err.println("Usage: export GCP_PROJECT_ID=my-project && mvn exec:java"); + System.err.println(" or: mvn exec:java -DGCP_PROJECT_ID=my-project"); + System.exit(1); + return null; // unreachable + } + + private static void printHeader(String title) { + System.out.println(); + System.out.println("── " + title + " " + "─".repeat(Math.max(0, 50 - title.length()))); + } +} diff --git a/samples/gcp/teardown.sh b/samples/gcp/teardown.sh new file mode 100644 index 000000000..ec5997e77 --- /dev/null +++ b/samples/gcp/teardown.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# teardown.sh — Deletes the sample feature-flag secrets from GCP Secret Manager. +# +# Usage: +# export GCP_PROJECT_ID=my-gcp-project +# bash teardown.sh + +set -euo pipefail + +PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" + +echo "Deleting sample secrets from project: ${PROJECT}" +echo "" + +for name in dark-mode banner-text max-cart-items discount-rate checkout-config; do + full_name="of-sample-${name}" + if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then + gcloud secrets delete "${full_name}" --project="${PROJECT}" --quiet + echo " [DELETED] ${full_name}" + else + echo " [SKIP] ${full_name} (not found)" + fi +done + +echo "" +echo "✓ Cleanup complete." diff --git a/tools/flagd-core/CHANGELOG.md b/tools/flagd-core/CHANGELOG.md index 508db5a43..69e3b6fc8 100644 --- a/tools/flagd-core/CHANGELOG.md +++ b/tools/flagd-core/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.2.0](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdcore-v1.1.0...dev.openfeature.contrib.tools.flagdcore-v1.2.0) (2026-05-11) + + +### ✨ New Features + +* intelligent enum for rule pre-compilation ([#1791](https://github.com/open-feature/java-sdk-contrib/issues/1791)) ([d79f27c](https://github.com/open-feature/java-sdk-contrib/commit/d79f27cb38c09cd82a5e807ed04f5e583195e75f)) + +## [1.1.0](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdcore-v1.0.2...dev.openfeature.contrib.tools.flagdcore-v1.1.0) (2026-05-07) + + +### ✨ New Features + +* switch to io.github.gzsombor json logic lib ([#1786](https://github.com/open-feature/java-sdk-contrib/issues/1786)) ([4f2c023](https://github.com/open-feature/java-sdk-contrib/commit/4f2c023a233f8a898b232cb18effd920a91dd64f)) + +## [1.0.2](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdcore-v1.0.1...dev.openfeature.contrib.tools.flagdcore-v1.0.2) (2026-04-30) + + +### 🐛 Bug Fixes + +* various custom operator conformance fixes ([#1778](https://github.com/open-feature/java-sdk-contrib/issues/1778)) ([cced215](https://github.com/open-feature/java-sdk-contrib/commit/cced215af4ecbff010ae7bb4af82072224bc6eb0)) + ## [1.0.1](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.flagdcore-v1.0.0...dev.openfeature.contrib.tools.flagdcore-v1.0.1) (2026-04-21) diff --git a/tools/flagd-core/README.md b/tools/flagd-core/README.md index aa9a18766..aed70d5e4 100644 --- a/tools/flagd-core/README.md +++ b/tools/flagd-core/README.md @@ -28,7 +28,7 @@ ProviderEvaluation result = core.resolveBooleanValue("myBoolFlag", eval dev.openfeature.contrib.tools flagd-core - 1.0.1 + 1.2.0 ``` diff --git a/tools/flagd-core/pom.xml b/tools/flagd-core/pom.xml index bcf10666f..70f481242 100644 --- a/tools/flagd-core/pom.xml +++ b/tools/flagd-core/pom.xml @@ -10,7 +10,7 @@ dev.openfeature.contrib.tools flagd-core - 1.0.1 + 1.2.0 ${groupId}.flagdcore @@ -47,9 +47,9 @@ - io.github.jamsesso + io.github.gzsombor json-logic-java - 1.1.0 + 1.1.3 diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java index bcf320637..b774c215b 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java @@ -63,7 +63,7 @@ public class FlagdCore implements Evaluator { * Construct a FlagdCore instance. */ public FlagdCore() { - this(false); + this(false, false); } /** @@ -72,7 +72,17 @@ public FlagdCore() { * @param throwIfInvalid whether to throw an exception if flag configuration is invalid */ public FlagdCore(boolean throwIfInvalid) { - this.operator = new Operator(); + this(throwIfInvalid, false); + } + + /** + * Construct a FlagdCore instance. + * + * @param throwIfInvalid whether to throw an exception if flag configuration is invalid + * @param compileTargeting whether to compile targeting rules for better performance + */ + public FlagdCore(boolean throwIfInvalid, boolean compileTargeting) { + this.operator = new Operator(compileTargeting); this.throwIfInvalid = throwIfInvalid; } diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java index d15d93419..3c8da2225 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java @@ -16,7 +16,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -28,7 +27,6 @@ public class FlagParser { private static final String FLAG_KEY = "flags"; private static final String METADATA_KEY = "metadata"; private static final String EVALUATOR_KEY = "$evaluators"; - private static final String REPLACER_FORMAT = "\"\\$ref\":(\\s)*\"%s\""; private static final ObjectMapper MAPPER = new ObjectMapper(); private static JsonSchema SCHEMA_VALIDATOR; @@ -116,7 +114,6 @@ private static Map parseMetadata(TreeNode metadataNode) throws J private static String transposeEvaluators(final String configuration) throws IOException { try (JsonParser parser = MAPPER.createParser(configuration)) { - final Map patternMap = new HashMap<>(); final TreeNode treeNode = parser.readValueAsTree(); final TreeNode evaluators = treeNode.get(EVALUATOR_KEY); @@ -124,24 +121,16 @@ private static String transposeEvaluators(final String configuration) throws IOE return configuration; } - String replacedConfigurations = configuration; + // round-trip to normalize whitespace so we can use plain string matching + String replacedConfigurations = MAPPER.writeValueAsString(MAPPER.readTree(configuration)); final Iterator evalFields = evaluators.fieldNames(); while (evalFields.hasNext()) { final String evalName = evalFields.next(); - // first replace outmost brackets final String evaluator = evaluators.get(evalName).toString(); - final String replacer = evaluator.substring(1, evaluator.length() - 1); + final String refPattern = "{\"$ref\":\"" + evalName + "\"}"; - final String replacePattern = String.format(REPLACER_FORMAT, evalName); - - // then derive pattern - final Pattern regReplace = - patternMap.computeIfAbsent(replacePattern, s -> Pattern.compile(replacePattern)); - - // finally replace all references - replacedConfigurations = - regReplace.matcher(replacedConfigurations).replaceAll(replacer); + replacedConfigurations = replacedConfigurations.replace(refPattern, evaluator); } return replacedConfigurations; diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 156d00e8a..b0800ed75 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -5,7 +5,6 @@ import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -25,6 +24,7 @@ public String key() { } @Override + @SuppressWarnings("unchecked") // json-logic-java's PreEvaluatedArgumentsExpression uses raw List public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException { if (arguments.size() < 1) { return null; @@ -32,26 +32,30 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json final Operator.FlagProperties properties = new Operator.FlagProperties(data); - // check optional string target in first arg - Object arg1 = arguments.get(0); - final String bucketBy; - final Object[] distributions; + final List distributions; - if (arg1 instanceof String) { + // json-logic pre-evaluation flattens a single-entry fractional + // e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap + if (isFlattened(arguments)) { + if (properties.getTargetingKey() == null) { + log.debug("Missing fallback targeting key"); + return null; + } + bucketBy = properties.getFlagKey() + properties.getTargetingKey(); + distributions = List.of(arguments); + } else if (arguments.get(0) instanceof String) { // first arg is a String, use for bucketing - bucketBy = (String) arg1; - Object[] source = arguments.toArray(); - distributions = Arrays.copyOfRange(source, 1, source.length); + bucketBy = (String) arguments.get(0); + distributions = arguments.subList(1, arguments.size()); } else { // fallback to targeting key if present if (properties.getTargetingKey() == null) { log.debug("Missing fallback targeting key"); return null; } - bucketBy = properties.getFlagKey() + properties.getTargetingKey(); - distributions = arguments.toArray(); + distributions = arguments; } final List propertyList = new ArrayList<>(); @@ -93,6 +97,19 @@ private static Object distributeValue( return distributeValueFromHash(mmrHash, propertyList, totalWeight, jsonPath); } + /** + * Checks if arguments have been flattened by json-logic pre-evaluation. + * A flattened list contains no List elements (e.g. ["single", 1] instead of [["single", 1]]). + */ + private static boolean isFlattened(List arguments) { + for (Object arg : arguments) { + if (arg instanceof List) { + return false; + } + } + return true; + } + static Object distributeValueFromHash( final int hash, final List propertyList, final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java index b3b97e5df..fbb4024a0 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java @@ -22,10 +22,19 @@ public class Operator { private final JsonLogic jsonLogicHandler; /** - * Construct a targeting operator. + * Construct a targeting operator with compilation disabled. */ public Operator() { - jsonLogicHandler = new JsonLogic(); + this(false); + } + + /** + * Construct a targeting operator. + * + * @param compileExpressions whether to compile JsonLogic expressions for better performance + */ + public Operator(boolean compileExpressions) { + jsonLogicHandler = new JsonLogic(compileExpressions); jsonLogicHandler.addOperation(new Fractional()); jsonLogicHandler.addOperation(new SemVer()); jsonLogicHandler.addOperation(new StringComp(StringComp.Type.STARTS_WITH)); diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java index 44573904b..049fa2983 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java @@ -49,17 +49,25 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json return null; } - for (int i = 0; i < 3; i++) { - if (!(arguments.get(i) instanceof String)) { - log.debug("Invalid argument type. Require Strings"); - return null; - } + // arg 1 and arg 3 must be strings or numbers (coerced to string) + // arg 2 must be a string (operator) + final String arg1Str = coerceToString(arguments.get(0)); + final String arg3Str = coerceToString(arguments.get(2)); + + if (arg1Str == null || arg3Str == null) { + log.debug("Arguments 1 and 3 must be strings or numbers"); + return null; + } + + if (!(arguments.get(1) instanceof String)) { + log.debug("Argument 2 (operator) must be a string"); + return null; } // arg 1 should be a SemVer final Semver arg1Parsed; - if ((arg1Parsed = Semver.parse((String) arguments.get(0))) == null) { + if ((arg1Parsed = normalizeVersion(arg1Str)) == null) { log.debug("Argument one is not a valid SemVer"); return null; } @@ -75,7 +83,7 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json // arg 3 should be a SemVer final Semver arg3Parsed; - if ((arg3Parsed = Semver.parse((String) arguments.get(2))) == null) { + if ((arg3Parsed = normalizeVersion(arg3Str)) == null) { log.debug("Argument three is not a valid SemVer"); return null; } @@ -83,6 +91,50 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json return compare(arg2Parsed, arg1Parsed, arg3Parsed, jsonPath); } + /** + * Coerce a value to a string representation suitable for semver parsing. + */ + private static String coerceToString(Object value) { + if (value instanceof String) { + return (String) value; + } + if (value instanceof Number) { + Number num = (Number) value; + double dub = num.doubleValue(); + if (dub == Math.floor(dub) && !Double.isInfinite(dub)) { + return String.valueOf(num.longValue()); + } + return String.valueOf(dub); + } + return null; + } + + /** + * Parse a semver string, handling v-prefix (case-insensitive) and partial versions. + */ + private static Semver normalizeVersion(String version) { + // strip v/V prefix + String stripped = version; + if (stripped.startsWith("v") || stripped.startsWith("V")) { + stripped = stripped.substring(1); + } + + // try strict parse first + Semver result = Semver.parse(stripped); + if (result != null) { + return result; + } + + // fall back to coerce for partial versions (fewer than 2 dots) + // do not coerce strings that have too many parts (e.g. "2.0.0.0") + long dotCount = stripped.chars().filter(c -> c == '.').count(); + if (dotCount < 2) { + return Semver.coerce(stripped); + } + + return null; + } + private static boolean compare(final String operator, final Semver arg1, final Semver arg2, final String jsonPath) throws JsonLogicEvaluationException { diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java index a7d2ecc12..941d978d5 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java @@ -11,7 +11,7 @@ * configuration. Registered as an {@link dev.openfeature.contrib.tools.flagd.api.testkit.EvaluatorFactory} * via {@code META-INF/services}. */ -@ExcludeTags({"fractional-v1"}) +@ExcludeTags({"fractional-v1", "evaluator-refs-whitespace", "non-existent-evaluator-ref"}) public class FlagdCoreEvaluatorTest extends AbstractEvaluatorTest { @Override diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index 6bdd45b29..046d8914f 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -4,6 +4,7 @@ import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.FLAG_KEY; import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.TARGET_KEY; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; @@ -19,6 +20,7 @@ import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.converter.ArgumentConversionException; import org.junit.jupiter.params.converter.ConvertWith; @@ -80,4 +82,72 @@ static class TestData { @JsonProperty("rule") List rule; } + + @Test + void missingBucketKeyReturnsNull() throws JsonLogicEvaluationException { + // no targeting key in data; bucket key var resolves to null + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + // no TARGET_KEY set + + List rule = List.of( + // bucket key is a null var result (simulated by being a non-string, non-list) + List.of("one", 50), List.of("two", 50)); + + // bucketing key is null, so fractional falls back to flagKey + targetingKey + // but targetingKey is null, so it should return null + assertNull(fractional.evaluate(rule, data, "path")); + } + + @Test + void singleEntryFractionalWithNonStringVariant() throws JsonLogicEvaluationException { + // simulates pre-evaluation flattening of [[100, 1]] -> [100, 1] + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + data.put(TARGET_KEY, "user"); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + + List rule = List.of(100, 1); + + assertEquals(100, fractional.evaluate(rule, data, "path")); + } + + @Test + void singleEntryFractionalWithStringVariant() throws JsonLogicEvaluationException { + // simulates pre-evaluation flattening of [["single", 1]] -> ["single", 1] + // "single" looks like a bucketing key but is actually the variant + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + data.put(TARGET_KEY, "user"); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + + List rule = List.of("single", 1); + + assertEquals("single", fractional.evaluate(rule, data, "path")); + } + + @Test + void zeroWeightsReturnsNull() throws JsonLogicEvaluationException { + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + data.put(TARGET_KEY, "user"); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + + List rule = List.of(List.of("one", 0), List.of("two", 0)); + + assertNull(fractional.evaluate(rule, data, "path")); + } } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java index 39692a53a..38fcd81d1 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.tools.flagd.core.targeting; import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -45,10 +46,22 @@ void testValidCases(List args) throws JsonLogicEvaluationException { static Stream invalidInputs() { return Stream.of( - Arguments.of(Arrays.asList("1.2.3", "=", 1.2)), - Arguments.of(Arrays.asList("1.2", "=", "1.2.3")), + // invalid operator Arguments.of(Arrays.asList("1.2.3", "*", "1.2.3")), - Arguments.of(Arrays.asList("1.2.3", "=", "1.2"))); + // wrong argument count (too few) + Arguments.of(Arrays.asList("1.0.0", "=")), + // wrong argument count (too many) + Arguments.of(Arrays.asList("1.0.0", "=", "1.0.0", "extra"))); + } + + static Stream coercedInputs() { + return Stream.of( + // numeric third arg coerced to semver + Arguments.of(Arrays.asList("1.2.3", "=", 1.2), false), + // partial version coerced + Arguments.of(Arrays.asList("1.2", "=", "1.2.3"), false), + Arguments.of(Arrays.asList("1.2.3", "=", "1.2"), false), + Arguments.of(Arrays.asList("1.2.0", "=", "1.2"), true)); } @ParameterizedTest @@ -60,4 +73,17 @@ void testInvalidCases(List args) throws JsonLogicEvaluationException { // then assertNull(semVer.evaluate(args, new Object(), "jsonPath")); } + + @ParameterizedTest + @MethodSource("coercedInputs") + void testCoercedCases(List args, boolean expected) throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(args, new Object(), "jsonPath"); + + // then + assertEquals(expected, result); + } } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java index 8deed2acb..6b15ca2b6 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java @@ -70,10 +70,35 @@ public void invalidNumberOfArgs() throws JsonLogicEvaluationException { // given final StringComp operator = new StringComp(StringComp.Type.STARTS_WITH); - // when + // when - too many args Object result = operator.evaluate(Arrays.asList("123", "12", "1"), new Object(), "jsonPath"); // then assertThat(result).isNull(); } + + @Test + public void tooFewArgs() throws JsonLogicEvaluationException { + // given + final StringComp startsWith = new StringComp(StringComp.Type.STARTS_WITH); + final StringComp endsWith = new StringComp(StringComp.Type.ENDS_WITH); + + // when/then - single arg returns null + assertThat(startsWith.evaluate(Arrays.asList("abc"), new Object(), "jsonPath")) + .isNull(); + assertThat(endsWith.evaluate(Arrays.asList("xyz"), new Object(), "jsonPath")) + .isNull(); + } + + @Test + public void endsWithNonStringInput() throws JsonLogicEvaluationException { + // given + final StringComp operator = new StringComp(StringComp.Type.ENDS_WITH); + + // when - non-string first arg + Object result = operator.evaluate(Arrays.asList(123, "abc"), new Object(), "jsonPath"); + + // then + assertThat(result).isNull(); + } } diff --git a/tools/flagd-core/version.txt b/tools/flagd-core/version.txt index 7dea76edb..26aaba0e8 100644 --- a/tools/flagd-core/version.txt +++ b/tools/flagd-core/version.txt @@ -1 +1 @@ -1.0.1 +1.2.0 From ae8f727b5041df0b9e9ae93379a1b02c77268f3c Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:08:08 +0100 Subject: [PATCH 14/22] Update all other references to secret manager to gcp Signed-off-by: Mahesh Patil --- .release-please-manifest.json | 2 +- pom.xml | 59 ++++++---- providers/gcp/README.md | 2 +- ...pSecretManagerProviderIntegrationTest.java | 2 +- release-please-config.json | 104 ++++-------------- samples/gcp/README.md | 6 +- samples/gcp/pom.xml | 2 +- samples/gcp/setup.sh | 2 +- 8 files changed, 67 insertions(+), 112 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1ccb3a92a..7d9aa912c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,7 +11,7 @@ "providers/statsig": "0.2.1", "providers/multiprovider": "0.0.3", "providers/ofrep": "0.0.1", - "providers/gcp-secret-manager": "0.0.1", + "providers/gcp": "0.0.1", "tools/junit-openfeature": "0.2.1", "tools/flagd-http-connector": "0.0.4", "tools/flagd-api": "1.0.0", diff --git a/pom.xml b/pom.xml index d4e76f544..59daa5e3f 100644 --- a/pom.xml +++ b/pom.xml @@ -1,6 +1,9 @@ - - + + 4.0.0 dev.openfeature.contrib @@ -45,20 +48,23 @@ providers/optimizely providers/multiprovider providers/ofrep - providers/gcp-secret-manager + providers/gcp tools/flagd-http-connector - scm:git:https://github.com/open-feature/java-sdk-contrib.git - scm:git:https://github.com/open-feature/java-sdk-contrib.git + scm:git:https://github.com/open-feature/java-sdk-contrib.git + scm:git:https://github.com/open-feature/java-sdk-contrib.git https://github.com/open-feature/java-sdk-contrib [21,) UTF-8 - UTF-8 + UTF-8 ${groupId}.${artifactId} true @@ -238,7 +244,8 @@ - ${module-name} + ${module-name} @@ -286,8 +293,10 @@ ${maven.deploy.skip} - https://repo1.maven.org/maven2 - https://central.sonatype.com/repository/maven-snapshots + https://repo1.maven.org/maven2 + https://central.sonatype.com/repository/maven-snapshots central @@ -311,7 +320,8 @@ maven-javadoc-plugin 3.12.0 - ${javadoc.failOnWarnings} + ${javadoc.failOnWarnings} dev.openfeature.flagd.grpc @@ -339,7 +349,8 @@ library 1.3 - true + true true true true @@ -385,7 +396,8 @@ central true - ${maven.deploy.skip} + ${maven.deploy.skip} @@ -413,7 +425,8 @@ true true false - true + true @@ -438,7 +451,8 @@ maven-pmd-plugin 3.28.0 - ${basedir}/target/generated-sources/ + ${basedir}/target/generated-sources/ @@ -457,7 +471,8 @@ spotbugs-maven-plugin 4.9.8.3 - spotbugs-exclusions.xml + spotbugs-exclusions.xml com.h3xstream.findsecbugs @@ -502,8 +517,8 @@ .gitignore - - + + true 4 @@ -512,16 +527,16 @@ - + true 4 - + - - + + diff --git a/providers/gcp/README.md b/providers/gcp/README.md index e5d69d69e..4703b0f65 100644 --- a/providers/gcp/README.md +++ b/providers/gcp/README.md @@ -117,5 +117,5 @@ Integration tests require real GCP credentials and pre-created test secrets. 3. Run: ```bash -GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-secret-manager -Dgroups=integration +GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp -Dgroups=integration ``` diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java index 7c3ad043f..e1f4453b1 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java @@ -35,7 +35,7 @@ * *

To run these tests: *

{@code
- * GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-secret-manager -Dgroups=integration
+ * GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp -Dgroups=integration
  * }
*/ @Tag("integration") diff --git a/release-please-config.json b/release-please-config.json index 1e9ab07d7..33a3bef25 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,10 +9,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/flagsmith": { "package-name": "dev.openfeature.contrib.providers.flagsmith", @@ -20,10 +17,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/go-feature-flag": { "package-name": "dev.openfeature.contrib.providers.go-feature-flag", @@ -31,10 +25,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/jsonlogic-eval-provider": { "package-name": "dev.openfeature.contrib.providers.jsonlogic", @@ -42,10 +33,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/env-var": { "package-name": "dev.openfeature.contrib.providers.env-var", @@ -53,10 +41,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/unleash": { "package-name": "dev.openfeature.contrib.providers.unleash", @@ -64,10 +49,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/flipt": { "package-name": "dev.openfeature.contrib.providers.flipt", @@ -75,10 +57,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/configcat": { "package-name": "dev.openfeature.contrib.providers.configcat", @@ -86,10 +65,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/statsig": { "package-name": "dev.openfeature.contrib.providers.statsig", @@ -97,10 +73,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/optimizely": { "package-name": "dev.openfeature.contrib.providers.optimizely", @@ -108,10 +81,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/multiprovider": { "package-name": "dev.openfeature.contrib.providers.multiprovider", @@ -119,10 +89,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "providers/ofrep": { "package-name": "dev.openfeature.contrib.providers.ofrep", @@ -130,21 +97,15 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, - "providers/gcp-secret-manager": { - "package-name": "dev.openfeature.contrib.providers.gcp-secret-manager", + "providers/gcp": { + "package-name": "dev.openfeature.contrib.providers.gcp", "release-type": "simple", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "hooks/open-telemetry": { "package-name": "dev.openfeature.contrib.hooks.otel", @@ -152,10 +113,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "tools/junit-openfeature": { "package-name": "dev.openfeature.contrib.tools.junitopenfeature", @@ -163,10 +121,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "tools/flagd-api": { "package-name": "dev.openfeature.contrib.tools.flagdapi", @@ -174,10 +129,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "tools/flagd-core": { "package-name": "dev.openfeature.contrib.tools.flagdcore", @@ -185,10 +137,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "tools/flagd-api-testkit": { "package-name": "dev.openfeature.contrib.tools.flagdapitestkit", @@ -196,10 +145,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, "tools/flagd-http-connector": { "package-name": "dev.openfeature.contrib.tools.flagdhttpconnector", @@ -207,10 +153,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default", - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] }, ".": { "package-name": "dev.openfeature.contrib.parent", @@ -227,10 +170,7 @@ "release", "tools" ], - "extra-files": [ - "pom.xml", - "README.md" - ] + "extra-files": ["pom.xml", "README.md"] } }, "changelog-sections": [ diff --git a/samples/gcp/README.md b/samples/gcp/README.md index 585816e3b..d27981e46 100644 --- a/samples/gcp/README.md +++ b/samples/gcp/README.md @@ -1,6 +1,6 @@ # GCP — OpenFeature Sample -A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp-secret-manager). +A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp). It evaluates five feature flags (covering every supported type) that are stored as secrets in Google Cloud Secret Manager. @@ -60,7 +60,7 @@ This installs the provider JAR to your local Maven repository (`~/.m2`). ## Step 4 — Create the feature-flag secrets ```bash -cd samples/gcp-secret-manager-sample +cd samples/gcp bash setup.sh ``` @@ -151,4 +151,4 @@ Re-run the sample to see the new value (cache expires after 30 seconds in this s | `PERMISSION_DENIED` | Missing IAM role | Grant `roles/secretmanager.secretAccessor` | | `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | | `secretmanager.googleapis.com is not enabled` | API disabled | Run Step 1 | -| `Could not find artifact ...gcp-secret-manager` | Provider not installed | Run Step 3 | +| `Could not find artifact ...gcp` | Provider not installed | Run Step 3 | diff --git a/samples/gcp/pom.xml b/samples/gcp/pom.xml index f781e470b..664ceef42 100644 --- a/samples/gcp/pom.xml +++ b/samples/gcp/pom.xml @@ -31,7 +31,7 @@ dev.openfeature.contrib.providers - gcp-secret-manager + gcp 0.0.1 diff --git a/samples/gcp/setup.sh b/samples/gcp/setup.sh index 777a1a25a..729d212bf 100644 --- a/samples/gcp/setup.sh +++ b/samples/gcp/setup.sh @@ -65,6 +65,6 @@ echo "" echo "Next steps:" echo " 1. Authenticate: gcloud auth application-default login" echo " 2. Run the sample:" -echo " cd gcp-secret-manager-sample" +echo " cd gcp-sample" echo " mvn exec:java # GCP_PROJECT_ID must still be set in your shell" echo " 3. To clean up: bash teardown.sh" From f0949f2b2a88ca4966082376e9a42b7540c81822 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:11:34 +0100 Subject: [PATCH 15/22] Fix spotless:checks Signed-off-by: Mahesh Patil --- .../contrib/providers/gcp/FlagCache.java | 12 +++--- .../gcp/GcpSecretManagerProvider.java | 24 ++++++------ .../contrib/providers/gcp/FlagCacheCTest.java | 28 ++++++-------- .../providers/gcp/FlagValueConverterTest.java | 21 ++++++----- ...pSecretManagerProviderIntegrationTest.java | 37 ++++++++++--------- 5 files changed, 60 insertions(+), 62 deletions(-) diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java index 6b27a9ef7..cf53d8684 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java @@ -28,14 +28,12 @@ class FlagCache { FlagCache(Duration ttl, int maxSize, Clock clock) { this.ttl = ttl; this.clock = clock; - this.store = Collections.synchronizedMap( - new LinkedHashMap(16, 0.75f, false) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxSize; - } + this.store = Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; } - ); + }); } /** diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java index 0f23244b0..e65cf5fcb 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java @@ -130,7 +130,10 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa private ProviderEvaluation evaluate(String key, Class targetType) { String rawValue = fetchWithCache(key); T value = FlagValueConverter.convert(rawValue, targetType); - return ProviderEvaluation.builder().value(value).reason(Reason.STATIC.toString()).build(); + return ProviderEvaluation.builder() + .value(value) + .reason(Reason.STATIC.toString()) + .build(); } private String fetchWithCache(String key) { @@ -140,13 +143,11 @@ private String fetchWithCache(String key) { return cached.get(); } synchronized (this) { - return cache - .get(secretName) - .orElseGet(() -> { - String value = fetchFromGcp(secretName); - cache.put(secretName, value); - return value; - }); + return cache.get(secretName).orElseGet(() -> { + String value = fetchFromGcp(secretName); + cache.put(secretName, value); + return value; + }); } } @@ -168,11 +169,8 @@ private String buildSecretName(String flagKey) { */ private String fetchFromGcp(String secretName) { try { - SecretVersionName versionName = SecretVersionName.of( - options.getProjectId(), - secretName, - options.getSecretVersion() - ); + SecretVersionName versionName = + SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion()); log.debug("Accessing secret '{}' from GCP", versionName); AccessSecretVersionResponse response = client.accessSecretVersion(versionName); return response.getPayload().getData().toStringUtf8(); diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java index ecb662836..7ba43dd9d 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java @@ -75,11 +75,10 @@ void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { now.set(T1); Runner.runParallel( - // Thread A: re-insert the same key with a fresh value - () -> cache.put("key", "new-value"), - // Thread B: get() detects the stale entry and attempts removal - () -> cache.get("key") - ); + // Thread A: re-insert the same key with a fresh value + () -> cache.put("key", "new-value"), + // Thread B: get() detects the stale entry and attempts removal + () -> cache.get("key")); // Thread A's put() has returned, so "new-value" must be in the cache. // Thread B's expiry-removal must not have silently evicted it. @@ -107,17 +106,14 @@ void getOnTimedOutEntryWhileConcurrentInsertNeverReturnsStaleValue() throws Exce try (var interleavings = new AllInterleavings("FlagCache: get on timed-out entry concurrent with insert")) { while (interleavings.hasNext()) { Runner.runParallel( - // Thread A: insert a fresh value for the same key - () -> cache.put("key", "new-value"), - // Thread B: get() on the timed-out entry must return nothing - // (expired entry removed) or "new-value" (Thread A won the race) — - // never the stale "stale-value" whose TTL has elapsed - () -> - assertThat(cache.get("key")).satisfiesAnyOf( - opt -> assertThat(opt).isEmpty(), - opt -> assertThat(opt).hasValue("new-value") - ) - ); + // Thread A: insert a fresh value for the same key + () -> cache.put("key", "new-value"), + // Thread B: get() on the timed-out entry must return nothing + // (expired entry removed) or "new-value" (Thread A won the race) — + // never the stale "stale-value" whose TTL has elapsed + () -> assertThat(cache.get("key")) + .satisfiesAnyOf(opt -> assertThat(opt).isEmpty(), opt -> assertThat(opt) + .hasValue("new-value"))); } } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java index 7cb292dda..ea5b049bf 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java @@ -20,14 +20,14 @@ class FlagValueConverterTest { class BooleanConversion { @ParameterizedTest - @ValueSource(strings = { "true", "True", "TRUE", "tRuE" }) + @ValueSource(strings = {"true", "True", "TRUE", "tRuE"}) @DisplayName("converts truthy strings to true") void trueVariants(String input) { assertThat(FlagValueConverter.convert(input, Boolean.class)).isTrue(); } @ParameterizedTest - @ValueSource(strings = { "false", "False", "FALSE", "fAlSe" }) + @ValueSource(strings = {"false", "False", "FALSE", "fAlSe"}) @DisplayName("converts falsy strings to false") void falseVariants(String input) { assertThat(FlagValueConverter.convert(input, Boolean.class)).isFalse(); @@ -36,7 +36,8 @@ void falseVariants(String input) { @Test @DisplayName("throws ParseError for non-boolean string") void nonBooleanThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)).isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)) + .isInstanceOf(ParseError.class); } } @@ -59,7 +60,8 @@ void negativeNumericString() { @Test @DisplayName("throws ParseError for non-numeric string") void nonNumericThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)).isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)) + .isInstanceOf(ParseError.class); } } @@ -88,9 +90,8 @@ void exponentialNotation() { @Test @DisplayName("throws ParseError for non-numeric string") void nonNumericThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)).isInstanceOf( - ParseError.class - ); + assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)) + .isInstanceOf(ParseError.class); } } @@ -138,12 +139,14 @@ void plainStringWrapped() { @Test @DisplayName("throws TypeMismatchError for unsupported type") void unsupportedTypeThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)).isInstanceOf(TypeMismatchError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)) + .isInstanceOf(TypeMismatchError.class); } @Test @DisplayName("throws ParseError when raw value is null") void nullRawThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)).isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)) + .isInstanceOf(ParseError.class); } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java index e1f4453b1..cd074dfe5 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java @@ -48,7 +48,8 @@ class GcpSecretManagerProviderIntegrationTest { @BeforeEach void setUp() throws Exception { String projectId = System.getenv("GCP_PROJECT_ID"); - GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + GcpSecretManagerProviderOptions opts = + GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); provider = new GcpSecretManagerProvider(opts); provider.initialize(new ImmutableContext()); } @@ -61,41 +62,43 @@ void tearDown() { @Test @DisplayName("evaluates boolean secret") void booleanFlag() { - assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()).getValue()).isTrue(); + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) + .getValue()) + .isTrue(); } @Test @DisplayName("evaluates string secret") void stringFlag() { - assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()).getValue()).isEqualTo( - "hello" - ); + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) + .getValue()) + .isEqualTo("hello"); } @Test @DisplayName("evaluates integer secret") void integerFlag() { - assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()).getValue()).isEqualTo(99); + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) + .getValue()) + .isEqualTo(99); } @Test @DisplayName("evaluates double secret") void doubleFlag() { - assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()).getValue()).isEqualTo( - 2.71 - ); + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) + .getValue()) + .isEqualTo(2.71); } @Test @DisplayName("evaluates object secret as Value/Structure") void objectFlag() { - assertThat( - provider - .getObjectEvaluation("it-object-flag", null, new ImmutableContext()) - .getValue() - .asStructure() - .getValue("key") - .asString() - ).isEqualTo("val"); + assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString()) + .isEqualTo("val"); } } From 7544afd12db4faa5a0235543c60d3e013abd6820 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:28:53 +0100 Subject: [PATCH 16/22] Fix compilation errors - passing mvn tests Signed-off-by: Mahesh Patil --- providers/gcp/README.md | 4 +- .../contrib/providers/gcp/FlagCacheTest.java | 4 +- .../gcp/GcpSecretManagerProviderTest.java | 110 +++++++++++------- .../samples/gcp/SecretManagerSampleApp.java | 19 ++- 4 files changed, 82 insertions(+), 55 deletions(-) diff --git a/providers/gcp/README.md b/providers/gcp/README.md index 4703b0f65..be3521650 100644 --- a/providers/gcp/README.md +++ b/providers/gcp/README.md @@ -17,8 +17,8 @@ An OpenFeature provider that reads feature flags from Google Cloud. Currently su ## Quick Start ```java -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions; import dev.openfeature.sdk.OpenFeatureAPI; GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java index 77db01838..0a12ae21a 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; @@ -92,7 +92,7 @@ void maxSizeEvictsOldest() { tinyCache.put("third", "3"); assertThat(tinyCache.get("third")).isPresent().hasValue("3"); int present = 0; - for (String key : new String[] {"first", "second", "third"}) { + for (String key : new String[] { "first", "second", "third" }) { if (tinyCache.get(key).isPresent()) { present++; } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java index 298e08bc0..5de917e0b 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -40,19 +40,15 @@ class GcpSecretManagerProviderTest { @BeforeEach void setUp() throws Exception { - options = GcpSecretManagerProviderOptions.builder() - .projectId("test-project") - .build(); + options = GcpSecretManagerProviderOptions.builder().projectId("test-project").build(); provider = new GcpSecretManagerProvider(options, mockClient); provider.initialize(new ImmutableContext()); } private void stubSecret(String value) { AccessSecretVersionResponse response = AccessSecretVersionResponse.newBuilder() - .setPayload(SecretPayload.newBuilder() - .setData(ByteString.copyFromUtf8(value)) - .build()) - .build(); + .setPayload(SecretPayload.newBuilder().setData(ByteString.copyFromUtf8(value)).build()) + .build(); when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenReturn(response); } @@ -67,6 +63,7 @@ private void stubSecretError(String message) { @Nested @DisplayName("Metadata") class MetadataTests { + @Test @DisplayName("returns the correct provider name") void providerName() { @@ -77,36 +74,41 @@ void providerName() { @Nested @DisplayName("Initialization") class InitializationTests { + @Test @DisplayName("throws IllegalArgumentException when projectId is blank") void blankProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = - GcpSecretManagerProviderOptions.builder().projectId("").build(); + GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().projectId("").build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); - assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( + IllegalArgumentException.class + ); } @Test @DisplayName("throws IllegalArgumentException when projectId is null") void nullProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = - GcpSecretManagerProviderOptions.builder().build(); + GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); - assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( + IllegalArgumentException.class + ); } } @Nested @DisplayName("Boolean evaluation") class BooleanEvaluation { + @Test @DisplayName("returns true for secret value 'true'") void trueValue() { stubSecret("true"); - ProviderEvaluation result = - provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()); + ProviderEvaluation result = provider.getBooleanEvaluation( + "bool-flag", + false, + new ImmutableContext() + ); assertThat(result.getValue()).isTrue(); assertThat(result.getReason()).isEqualTo(Reason.STATIC.toString()); } @@ -115,8 +117,11 @@ void trueValue() { @DisplayName("returns false for secret value 'false'") void falseValue() { stubSecret("false"); - ProviderEvaluation result = - provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + ProviderEvaluation result = provider.getBooleanEvaluation( + "bool-flag", + true, + new ImmutableContext() + ); assertThat(result.getValue()).isFalse(); } @@ -124,20 +129,25 @@ void falseValue() { @DisplayName("throws ParseError for malformed boolean value") void malformedBooleanThrows() { stubSecret("not-a-bool"); - assertThatThrownBy(() -> provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext())) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> + provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()) + ).isInstanceOf(ParseError.class); } } @Nested @DisplayName("String evaluation") class StringEvaluation { + @Test @DisplayName("returns string value as-is") void stringValue() { stubSecret("dark-mode"); - ProviderEvaluation result = - provider.getStringEvaluation("str-flag", "light-mode", new ImmutableContext()); + ProviderEvaluation result = provider.getStringEvaluation( + "str-flag", + "light-mode", + new ImmutableContext() + ); assertThat(result.getValue()).isEqualTo("dark-mode"); } } @@ -145,6 +155,7 @@ void stringValue() { @Nested @DisplayName("Integer evaluation") class IntegerEvaluation { + @Test @DisplayName("parses numeric string to Integer") void integerValue() { @@ -157,20 +168,25 @@ void integerValue() { @DisplayName("throws ParseError for non-numeric value") void nonNumericThrows() { stubSecret("abc"); - assertThatThrownBy(() -> provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext())) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext())).isInstanceOf( + ParseError.class + ); } } @Nested @DisplayName("Double evaluation") class DoubleEvaluation { + @Test @DisplayName("parses numeric string to Double") void doubleValue() { stubSecret("3.14"); - ProviderEvaluation result = - provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + ProviderEvaluation result = provider.getDoubleEvaluation( + "double-flag", + 0.0, + new ImmutableContext() + ); assertThat(result.getValue()).isEqualTo(3.14); } } @@ -178,41 +194,48 @@ void doubleValue() { @Nested @DisplayName("Object evaluation") class ObjectEvaluation { + @Test @DisplayName("parses JSON string to Value/Structure") void jsonValue() { stubSecret("{\"color\":\"blue\",\"count\":3}"); - ProviderEvaluation result = - provider.getObjectEvaluation("obj-flag", new Value(), new ImmutableContext()); + ProviderEvaluation result = provider.getObjectEvaluation( + "obj-flag", + new Value(), + new ImmutableContext() + ); assertThat(result.getValue().asStructure()).isNotNull(); - assertThat(result.getValue().asStructure().getValue("color").asString()) - .isEqualTo("blue"); + assertThat(result.getValue().asStructure().getValue("color").asString()).isEqualTo("blue"); } } @Nested @DisplayName("Error handling") class ErrorHandling { + @Test @DisplayName("throws FlagNotFoundError when secret does not exist") void flagNotFound() { stubSecretNotFound(); - assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) - .isInstanceOf(FlagNotFoundError.class); + assertThatThrownBy(() -> + provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext()) + ).isInstanceOf(FlagNotFoundError.class); } @Test @DisplayName("throws GeneralError on unexpected GCP API exception") void gcpApiError() { stubSecretError("Connection refused"); - assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())) - .isInstanceOf(GeneralError.class); + assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())).isInstanceOf( + GeneralError.class + ); } } @Nested @DisplayName("Caching") class CachingTests { + @Test @DisplayName("cache hit: GCP client called only once for two consecutive evaluations") void cacheHit() { @@ -226,13 +249,14 @@ void cacheHit() { @Nested @DisplayName("Secret name prefix") class PrefixTests { + @Test @DisplayName("prefix is prepended to the flag key when building secret name") void prefixApplied() { GcpSecretManagerProviderOptions prefixedOpts = GcpSecretManagerProviderOptions.builder() - .projectId("test-project") - .secretNamePrefix("ff-") - .build(); + .projectId("test-project") + .secretNamePrefix("ff-") + .build(); stubSecret("true"); GcpSecretManagerProvider prefixedProvider = new GcpSecretManagerProvider(prefixedOpts, mockClient); try { @@ -240,8 +264,11 @@ void prefixApplied() { } catch (Exception e) { throw new RuntimeException(e); } - ProviderEvaluation result = - prefixedProvider.getBooleanEvaluation("my-flag", false, new ImmutableContext()); + ProviderEvaluation result = prefixedProvider.getBooleanEvaluation( + "my-flag", + false, + new ImmutableContext() + ); assertThat(result.getValue()).isTrue(); } } @@ -249,6 +276,7 @@ void prefixApplied() { @Nested @DisplayName("Lifecycle") class LifecycleTests { + @Test @DisplayName("shutdown() closes the GCP client") void shutdownClosesClient() { diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java index 4e1d24276..a2f21d013 100644 --- a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java @@ -1,7 +1,7 @@ -package dev.openfeature.contrib.samples.gcpsecretmanager; +package dev.openfeature.contrib.samples.gcp; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; @@ -41,13 +41,12 @@ public static void main(String[] args) throws Exception { System.out.println(); // Build provider options - GcpSecretManagerProviderOptions options = - GcpSecretManagerProviderOptions.builder() - .projectId(projectId) - .secretNamePrefix(PREFIX) // secrets are named "of-sample-" - .secretVersion("latest") - .cacheExpiry(Duration.ofSeconds(30)) - .build(); + GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId(projectId) + .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .secretVersion("latest") + .cacheExpiry(Duration.ofSeconds(30)) + .build(); // Register the provider with OpenFeature GcpSecretManagerProvider provider = new GcpSecretManagerProvider(options); From 04f14cdcc0d327343822f9563636653b5034c372 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:42:04 +0100 Subject: [PATCH 17/22] Refactor GcpProviderOptions to reduce duplication Signed-off-by: Mahesh Patil --- providers/gcp/README.md | 4 +- .../gcp/GcpSecretManagerProvider.java | 36 ++++---- .../gcp/GcpSecretManagerProviderOptions.java | 86 ------------------- .../gcp/SecretManagerClientFactory.java | 4 +- ...pSecretManagerProviderIntegrationTest.java | 37 ++++---- .../gcp/GcpSecretManagerProviderTest.java | 12 +-- .../samples/gcp/SecretManagerSampleApp.java | 2 +- 7 files changed, 47 insertions(+), 134 deletions(-) delete mode 100644 providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java diff --git a/providers/gcp/README.md b/providers/gcp/README.md index be3521650..0dd7c390c 100644 --- a/providers/gcp/README.md +++ b/providers/gcp/README.md @@ -74,7 +74,7 @@ GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builde | `secretVersion` | `String` | `"latest"` | Secret version to access. Use `"latest"` for the current version or a numeric string (e.g. `"3"`) to pin to a specific version | | `cacheExpiry` | `Duration` | `5 minutes` | How long fetched secret values are cached before re-fetching from GCP | | `cacheMaxSize` | `int` | `500` | Maximum number of secret values held in the in-memory cache | -| `secretNamePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` | +| `namePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` | ## Advanced Usage @@ -92,7 +92,7 @@ GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builde ```java GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() .projectId("my-gcp-project") - .secretNamePrefix("feature-flags/") + .namePrefix("feature-flags/") .build(); ``` diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java index e65cf5fcb..ce054b7a9 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java @@ -20,7 +20,7 @@ * *

Each feature flag is stored as an individual secret in GCP Secret Manager. The flag key * maps directly to the secret name (with an optional prefix configured via - * {@link GcpSecretManagerProviderOptions#getSecretNamePrefix()}). + * {@link GcpProviderOptions#getNamePrefix()}). * *

Flag values are read as UTF-8 strings from the secret payload and parsed to the requested * type. Supported raw value formats: @@ -33,7 +33,7 @@ * * *

Results are cached in-process for the duration configured in - * {@link GcpSecretManagerProviderOptions#getCacheExpiry()}. + * {@link GcpProviderOptions#getCacheExpiry()}. * *

Example: *

{@code
@@ -48,7 +48,7 @@ public class GcpSecretManagerProvider implements FeatureProvider {
 
     static final String PROVIDER_NAME = "GCP Secret Manager Provider";
 
-    private final GcpSecretManagerProviderOptions options;
+    private final GcpProviderOptions options;
     private SecretManagerServiceClient client;
     private FlagCache cache;
 
@@ -58,14 +58,14 @@ public class GcpSecretManagerProvider implements FeatureProvider {
      *
      * @param options provider configuration; must not be null
      */
-    public GcpSecretManagerProvider(GcpSecretManagerProviderOptions options) {
+    public GcpSecretManagerProvider(GcpProviderOptions options) {
         this.options = options;
     }
 
     /**
      * Package-private constructor allowing injection of a pre-built client for testing.
      */
-    GcpSecretManagerProvider(GcpSecretManagerProviderOptions options, SecretManagerServiceClient client) {
+    GcpSecretManagerProvider(GcpProviderOptions options, SecretManagerServiceClient client) {
         this.options = options;
         this.client = client;
     }
@@ -130,10 +130,7 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa
     private  ProviderEvaluation evaluate(String key, Class targetType) {
         String rawValue = fetchWithCache(key);
         T value = FlagValueConverter.convert(rawValue, targetType);
-        return ProviderEvaluation.builder()
-                .value(value)
-                .reason(Reason.STATIC.toString())
-                .build();
+        return ProviderEvaluation.builder().value(value).reason(Reason.STATIC.toString()).build();
     }
 
     private String fetchWithCache(String key) {
@@ -143,11 +140,13 @@ private String fetchWithCache(String key) {
             return cached.get();
         }
         synchronized (this) {
-            return cache.get(secretName).orElseGet(() -> {
-                String value = fetchFromGcp(secretName);
-                cache.put(secretName, value);
-                return value;
-            });
+            return cache
+                .get(secretName)
+                .orElseGet(() -> {
+                    String value = fetchFromGcp(secretName);
+                    cache.put(secretName, value);
+                    return value;
+                });
         }
     }
 
@@ -155,7 +154,7 @@ private String fetchWithCache(String key) {
      * Applies the configured prefix (if any) and returns the GCP secret name for the flag.
      */
     private String buildSecretName(String flagKey) {
-        String prefix = options.getSecretNamePrefix();
+        String prefix = options.getNamePrefix();
         return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey;
     }
 
@@ -169,8 +168,11 @@ private String buildSecretName(String flagKey) {
      */
     private String fetchFromGcp(String secretName) {
         try {
-            SecretVersionName versionName =
-                    SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion());
+            SecretVersionName versionName = SecretVersionName.of(
+                options.getProjectId(),
+                secretName,
+                options.getVersion()
+            );
             log.debug("Accessing secret '{}' from GCP", versionName);
             AccessSecretVersionResponse response = client.accessSecretVersion(versionName);
             return response.getPayload().getData().toStringUtf8();
diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java
deleted file mode 100644
index 46c1bdf60..000000000
--- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package dev.openfeature.contrib.providers.gcp;
-
-import com.google.auth.oauth2.GoogleCredentials;
-import java.time.Duration;
-import lombok.Builder;
-import lombok.Getter;
-
-/**
- * Configuration options for {@link GcpSecretManagerProvider}.
- *
- * 

Example usage: - *

{@code
- * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
- *     .projectId("my-gcp-project")
- *     .secretVersion("latest")
- *     .cacheExpiry(Duration.ofMinutes(2))
- *     .build();
- * }
- */ -@Getter -@Builder -public class GcpSecretManagerProviderOptions { - - /** - * GCP project ID that owns the secrets. Required. - * Example: "my-gcp-project" or numeric project number "123456789". - */ - private final String projectId; - - /** - * Explicit Google credentials to use when creating the Secret Manager client. - * When {@code null} (default), Application Default Credentials (ADC) are used - * automatically by the GCP client library. - */ - private final GoogleCredentials credentials; - - /** - * The secret version to retrieve. Defaults to {@code "latest"}. - * Override with a specific version number (e.g. {@code "3"}) for pinned deployments - * where you want consistent behaviour regardless of secret rotation. - */ - @Builder.Default - private final String secretVersion = "latest"; - - /** - * How long a fetched secret value is retained in the in-memory cache before - * the next evaluation triggers a fresh GCP API call. - * - *

Secret Manager has API quotas (10,000 access operations per minute per project - * by default). Set this to at least {@code Duration.ofSeconds(30)} in - * high-throughput scenarios. - * - *

Default: 5 minutes. - */ - @Builder.Default - private final Duration cacheExpiry = Duration.ofMinutes(5); - - /** - * Maximum number of distinct secret names held in the cache at once. - * When the cache is full, the oldest entry is evicted before inserting a new one. - * Default: 500. - */ - @Builder.Default - private final int cacheMaxSize = 500; - - /** - * Optional prefix prepended to every flag key before constructing the GCP - * secret name. For example, setting {@code secretNamePrefix = "ff-"} maps - * flag key {@code "my-flag"} to secret name {@code "ff-my-flag"}. - */ - private final String secretNamePrefix; - - /** - * Validates that required options are present and well-formed. - * - * @throws IllegalArgumentException when {@code projectId} is null or blank - */ - public void validate() { - if (projectId == null || projectId.trim().isEmpty()) { - throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank"); - } - if (secretVersion == null || secretVersion.trim().isEmpty()) { - throw new IllegalArgumentException("GcpSecretManagerProviderOptions: secretVersion must not be blank"); - } - } -} diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java index a562f8469..398f64b18 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java @@ -16,7 +16,7 @@ private SecretManagerClientFactory() {} /** * Creates a new {@link SecretManagerServiceClient} using the provided options. * - *

When {@link GcpSecretManagerProviderOptions#getCredentials()} is non-null, those + *

When {@link GcpProviderOptions#getCredentials()} is non-null, those * credentials are used explicitly. Otherwise, the GCP client library falls back to * Application Default Credentials (ADC) automatically. * @@ -24,7 +24,7 @@ private SecretManagerClientFactory() {} * @return a configured {@link SecretManagerServiceClient} * @throws IOException if the client cannot be created */ - static SecretManagerServiceClient create(GcpSecretManagerProviderOptions options) throws IOException { + static SecretManagerServiceClient create(GcpProviderOptions options) throws IOException { SecretManagerServiceSettings.Builder settingsBuilder = SecretManagerServiceSettings.newBuilder(); if (options.getCredentials() != null) { settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(options.getCredentials())); diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java index cd074dfe5..caf2da8bc 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java @@ -48,8 +48,7 @@ class GcpSecretManagerProviderIntegrationTest { @BeforeEach void setUp() throws Exception { String projectId = System.getenv("GCP_PROJECT_ID"); - GcpSecretManagerProviderOptions opts = - GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + GcpProviderOptions opts = GcpProviderOptions.builder().projectId(projectId).build(); provider = new GcpSecretManagerProvider(opts); provider.initialize(new ImmutableContext()); } @@ -62,43 +61,41 @@ void tearDown() { @Test @DisplayName("evaluates boolean secret") void booleanFlag() { - assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) - .getValue()) - .isTrue(); + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()).getValue()).isTrue(); } @Test @DisplayName("evaluates string secret") void stringFlag() { - assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) - .getValue()) - .isEqualTo("hello"); + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()).getValue()).isEqualTo( + "hello" + ); } @Test @DisplayName("evaluates integer secret") void integerFlag() { - assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) - .getValue()) - .isEqualTo(99); + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()).getValue()).isEqualTo(99); } @Test @DisplayName("evaluates double secret") void doubleFlag() { - assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) - .getValue()) - .isEqualTo(2.71); + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()).getValue()).isEqualTo( + 2.71 + ); } @Test @DisplayName("evaluates object secret as Value/Structure") void objectFlag() { - assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) - .getValue() - .asStructure() - .getValue("key") - .asString()) - .isEqualTo("val"); + assertThat( + provider + .getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString() + ).isEqualTo("val"); } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java index 5de917e0b..a66d7439b 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java @@ -35,12 +35,12 @@ class GcpSecretManagerProviderTest { @Mock private SecretManagerServiceClient mockClient; - private GcpSecretManagerProviderOptions options; + private GcpProviderOptions options; private GcpSecretManagerProvider provider; @BeforeEach void setUp() throws Exception { - options = GcpSecretManagerProviderOptions.builder().projectId("test-project").build(); + options = GcpProviderOptions.builder().projectId("test-project").build(); provider = new GcpSecretManagerProvider(options, mockClient); provider.initialize(new ImmutableContext()); } @@ -78,7 +78,7 @@ class InitializationTests { @Test @DisplayName("throws IllegalArgumentException when projectId is blank") void blankProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().projectId("").build(); + GcpProviderOptions badOpts = GcpProviderOptions.builder().projectId("").build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( IllegalArgumentException.class @@ -88,7 +88,7 @@ void blankProjectIdThrows() { @Test @DisplayName("throws IllegalArgumentException when projectId is null") void nullProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().build(); + GcpProviderOptions badOpts = GcpProviderOptions.builder().build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( IllegalArgumentException.class @@ -253,9 +253,9 @@ class PrefixTests { @Test @DisplayName("prefix is prepended to the flag key when building secret name") void prefixApplied() { - GcpSecretManagerProviderOptions prefixedOpts = GcpSecretManagerProviderOptions.builder() + GcpProviderOptions prefixedOpts = GcpProviderOptions.builder() .projectId("test-project") - .secretNamePrefix("ff-") + .namePrefix("ff-") .build(); stubSecret("true"); GcpSecretManagerProvider prefixedProvider = new GcpSecretManagerProvider(prefixedOpts, mockClient); diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java index a2f21d013..838ece1d3 100644 --- a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java @@ -43,7 +43,7 @@ public static void main(String[] args) throws Exception { // Build provider options GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() .projectId(projectId) - .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .namePrefix(PREFIX) // secrets are named "of-sample-" .secretVersion("latest") .cacheExpiry(Duration.ofSeconds(30)) .build(); From 11a0ad3a8ddf7d9c3cdead01101418b538e7429b Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:42:23 +0100 Subject: [PATCH 18/22] Add GcpProviderOptions Signed-off-by: Mahesh Patil --- .../providers/gcp/GcpProviderOptions.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java new file mode 100644 index 000000000..b430725ac --- /dev/null +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java @@ -0,0 +1,86 @@ +package dev.openfeature.contrib.providers.gcp; + +import com.google.auth.oauth2.GoogleCredentials; +import java.time.Duration; +import lombok.Builder; +import lombok.Getter; + +/** + * Configuration options for {@link GcpSecretManagerProvider}. + * + *

Example usage: + *

{@code
+ * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .secretVersion("latest")
+ *     .cacheExpiry(Duration.ofMinutes(2))
+ *     .build();
+ * }
+ */ +@Getter +@Builder +public class GcpProviderOptions { + + /** + * GCP project ID that owns the config. Required. + * Example: "my-gcp-project" or numeric project number "123456789". + */ + private final String projectId; + + /** + * Explicit Google credentials to use when creating the GCP client. + * When {@code null} (default), Application Default Credentials (ADC) are used + * automatically by the GCP client library. + */ + private final GoogleCredentials credentials; + + /** + * The secret version to retrieve. Defaults to {@code "latest"}. + * Override with a specific version number (e.g. {@code "3"}) for pinned deployments + * where you want consistent behaviour regardless of secret rotation. + */ + @Builder.Default + private final String version = "latest"; + + /** + * How long a fetched secret value is retained in the in-memory cache before + * the next evaluation triggers a fresh GCP API call. + * + *

Secret Manager has API quotas (10,000 access operations per minute per project + * by default). Set this to at least {@code Duration.ofSeconds(30)} in + * high-throughput scenarios. + * + *

Default: 5 minutes. + */ + @Builder.Default + private final Duration cacheExpiry = Duration.ofMinutes(5); + + /** + * Maximum number of distinct secret names held in the cache at once. + * When the cache is full, the oldest entry is evicted before inserting a new one. + * Default: 500. + */ + @Builder.Default + private final int cacheMaxSize = 500; + + /** + * Optional prefix prepended to every flag key before constructing the GCP + * secret name. For example, setting {@code namePrefix = "ff-"} maps + * flag key {@code "my-flag"} to secret name {@code "ff-my-flag"}. + */ + private final String namePrefix; + + /** + * Validates that required options are present and well-formed. + * + * @throws IllegalArgumentException when {@code projectId} is null or blank + */ + public void validate() { + if (projectId == null || projectId.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank"); + } + if (version == null || version.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: secretVersion must not be blank"); + } + } +} From a9dd1eba5a51557266dfa89ef4af23ee85edc7f3 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:11:34 +0100 Subject: [PATCH 19/22] Fix spotless:checks Signed-off-by: Mahesh Patil --- .../contrib/providers/gcp/FlagCache.java | 12 +++--- .../gcp/GcpSecretManagerProvider.java | 24 ++++++------ .../contrib/providers/gcp/FlagCacheCTest.java | 28 ++++++-------- .../providers/gcp/FlagValueConverterTest.java | 21 ++++++----- ...pSecretManagerProviderIntegrationTest.java | 37 ++++++++++--------- 5 files changed, 60 insertions(+), 62 deletions(-) diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java index 6b27a9ef7..cf53d8684 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/FlagCache.java @@ -28,14 +28,12 @@ class FlagCache { FlagCache(Duration ttl, int maxSize, Clock clock) { this.ttl = ttl; this.clock = clock; - this.store = Collections.synchronizedMap( - new LinkedHashMap(16, 0.75f, false) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxSize; - } + this.store = Collections.synchronizedMap(new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; } - ); + }); } /** diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java index 0f23244b0..e65cf5fcb 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java @@ -130,7 +130,10 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa private ProviderEvaluation evaluate(String key, Class targetType) { String rawValue = fetchWithCache(key); T value = FlagValueConverter.convert(rawValue, targetType); - return ProviderEvaluation.builder().value(value).reason(Reason.STATIC.toString()).build(); + return ProviderEvaluation.builder() + .value(value) + .reason(Reason.STATIC.toString()) + .build(); } private String fetchWithCache(String key) { @@ -140,13 +143,11 @@ private String fetchWithCache(String key) { return cached.get(); } synchronized (this) { - return cache - .get(secretName) - .orElseGet(() -> { - String value = fetchFromGcp(secretName); - cache.put(secretName, value); - return value; - }); + return cache.get(secretName).orElseGet(() -> { + String value = fetchFromGcp(secretName); + cache.put(secretName, value); + return value; + }); } } @@ -168,11 +169,8 @@ private String buildSecretName(String flagKey) { */ private String fetchFromGcp(String secretName) { try { - SecretVersionName versionName = SecretVersionName.of( - options.getProjectId(), - secretName, - options.getSecretVersion() - ); + SecretVersionName versionName = + SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion()); log.debug("Accessing secret '{}' from GCP", versionName); AccessSecretVersionResponse response = client.accessSecretVersion(versionName); return response.getPayload().getData().toStringUtf8(); diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java index ecb662836..7ba43dd9d 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheCTest.java @@ -75,11 +75,10 @@ void concurrentExpiryAndInsertDoNotLoseNewEntry() throws Exception { now.set(T1); Runner.runParallel( - // Thread A: re-insert the same key with a fresh value - () -> cache.put("key", "new-value"), - // Thread B: get() detects the stale entry and attempts removal - () -> cache.get("key") - ); + // Thread A: re-insert the same key with a fresh value + () -> cache.put("key", "new-value"), + // Thread B: get() detects the stale entry and attempts removal + () -> cache.get("key")); // Thread A's put() has returned, so "new-value" must be in the cache. // Thread B's expiry-removal must not have silently evicted it. @@ -107,17 +106,14 @@ void getOnTimedOutEntryWhileConcurrentInsertNeverReturnsStaleValue() throws Exce try (var interleavings = new AllInterleavings("FlagCache: get on timed-out entry concurrent with insert")) { while (interleavings.hasNext()) { Runner.runParallel( - // Thread A: insert a fresh value for the same key - () -> cache.put("key", "new-value"), - // Thread B: get() on the timed-out entry must return nothing - // (expired entry removed) or "new-value" (Thread A won the race) — - // never the stale "stale-value" whose TTL has elapsed - () -> - assertThat(cache.get("key")).satisfiesAnyOf( - opt -> assertThat(opt).isEmpty(), - opt -> assertThat(opt).hasValue("new-value") - ) - ); + // Thread A: insert a fresh value for the same key + () -> cache.put("key", "new-value"), + // Thread B: get() on the timed-out entry must return nothing + // (expired entry removed) or "new-value" (Thread A won the race) — + // never the stale "stale-value" whose TTL has elapsed + () -> assertThat(cache.get("key")) + .satisfiesAnyOf(opt -> assertThat(opt).isEmpty(), opt -> assertThat(opt) + .hasValue("new-value"))); } } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java index 7cb292dda..ea5b049bf 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagValueConverterTest.java @@ -20,14 +20,14 @@ class FlagValueConverterTest { class BooleanConversion { @ParameterizedTest - @ValueSource(strings = { "true", "True", "TRUE", "tRuE" }) + @ValueSource(strings = {"true", "True", "TRUE", "tRuE"}) @DisplayName("converts truthy strings to true") void trueVariants(String input) { assertThat(FlagValueConverter.convert(input, Boolean.class)).isTrue(); } @ParameterizedTest - @ValueSource(strings = { "false", "False", "FALSE", "fAlSe" }) + @ValueSource(strings = {"false", "False", "FALSE", "fAlSe"}) @DisplayName("converts falsy strings to false") void falseVariants(String input) { assertThat(FlagValueConverter.convert(input, Boolean.class)).isFalse(); @@ -36,7 +36,8 @@ void falseVariants(String input) { @Test @DisplayName("throws ParseError for non-boolean string") void nonBooleanThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)).isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)) + .isInstanceOf(ParseError.class); } } @@ -59,7 +60,8 @@ void negativeNumericString() { @Test @DisplayName("throws ParseError for non-numeric string") void nonNumericThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)).isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)) + .isInstanceOf(ParseError.class); } } @@ -88,9 +90,8 @@ void exponentialNotation() { @Test @DisplayName("throws ParseError for non-numeric string") void nonNumericThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)).isInstanceOf( - ParseError.class - ); + assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)) + .isInstanceOf(ParseError.class); } } @@ -138,12 +139,14 @@ void plainStringWrapped() { @Test @DisplayName("throws TypeMismatchError for unsupported type") void unsupportedTypeThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)).isInstanceOf(TypeMismatchError.class); + assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)) + .isInstanceOf(TypeMismatchError.class); } @Test @DisplayName("throws ParseError when raw value is null") void nullRawThrows() { - assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)).isInstanceOf(ParseError.class); + assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)) + .isInstanceOf(ParseError.class); } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java index e1f4453b1..cd074dfe5 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java @@ -48,7 +48,8 @@ class GcpSecretManagerProviderIntegrationTest { @BeforeEach void setUp() throws Exception { String projectId = System.getenv("GCP_PROJECT_ID"); - GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + GcpSecretManagerProviderOptions opts = + GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); provider = new GcpSecretManagerProvider(opts); provider.initialize(new ImmutableContext()); } @@ -61,41 +62,43 @@ void tearDown() { @Test @DisplayName("evaluates boolean secret") void booleanFlag() { - assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()).getValue()).isTrue(); + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) + .getValue()) + .isTrue(); } @Test @DisplayName("evaluates string secret") void stringFlag() { - assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()).getValue()).isEqualTo( - "hello" - ); + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) + .getValue()) + .isEqualTo("hello"); } @Test @DisplayName("evaluates integer secret") void integerFlag() { - assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()).getValue()).isEqualTo(99); + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) + .getValue()) + .isEqualTo(99); } @Test @DisplayName("evaluates double secret") void doubleFlag() { - assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()).getValue()).isEqualTo( - 2.71 - ); + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) + .getValue()) + .isEqualTo(2.71); } @Test @DisplayName("evaluates object secret as Value/Structure") void objectFlag() { - assertThat( - provider - .getObjectEvaluation("it-object-flag", null, new ImmutableContext()) - .getValue() - .asStructure() - .getValue("key") - .asString() - ).isEqualTo("val"); + assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString()) + .isEqualTo("val"); } } From 68fbf35730d11bbaa78a50eb142ef2619b4a9b05 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:28:53 +0100 Subject: [PATCH 20/22] Fix compilation errors - passing mvn tests Signed-off-by: Mahesh Patil --- providers/gcp/README.md | 4 +- .../contrib/providers/gcp/FlagCacheTest.java | 4 +- .../gcp/GcpSecretManagerProviderTest.java | 110 +++++++++++------- .../samples/gcp/SecretManagerSampleApp.java | 19 ++- 4 files changed, 82 insertions(+), 55 deletions(-) diff --git a/providers/gcp/README.md b/providers/gcp/README.md index 4703b0f65..be3521650 100644 --- a/providers/gcp/README.md +++ b/providers/gcp/README.md @@ -17,8 +17,8 @@ An OpenFeature provider that reads feature flags from Google Cloud. Currently su ## Quick Start ```java -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions; import dev.openfeature.sdk.OpenFeatureAPI; GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java index 77db01838..0a12ae21a 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/FlagCacheTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; @@ -92,7 +92,7 @@ void maxSizeEvictsOldest() { tinyCache.put("third", "3"); assertThat(tinyCache.get("third")).isPresent().hasValue("3"); int present = 0; - for (String key : new String[] {"first", "second", "third"}) { + for (String key : new String[] { "first", "second", "third" }) { if (tinyCache.get(key).isPresent()) { present++; } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java index 298e08bc0..5de917e0b 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.gcpsecretmanager; +package dev.openfeature.contrib.providers.gcp; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -40,19 +40,15 @@ class GcpSecretManagerProviderTest { @BeforeEach void setUp() throws Exception { - options = GcpSecretManagerProviderOptions.builder() - .projectId("test-project") - .build(); + options = GcpSecretManagerProviderOptions.builder().projectId("test-project").build(); provider = new GcpSecretManagerProvider(options, mockClient); provider.initialize(new ImmutableContext()); } private void stubSecret(String value) { AccessSecretVersionResponse response = AccessSecretVersionResponse.newBuilder() - .setPayload(SecretPayload.newBuilder() - .setData(ByteString.copyFromUtf8(value)) - .build()) - .build(); + .setPayload(SecretPayload.newBuilder().setData(ByteString.copyFromUtf8(value)).build()) + .build(); when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenReturn(response); } @@ -67,6 +63,7 @@ private void stubSecretError(String message) { @Nested @DisplayName("Metadata") class MetadataTests { + @Test @DisplayName("returns the correct provider name") void providerName() { @@ -77,36 +74,41 @@ void providerName() { @Nested @DisplayName("Initialization") class InitializationTests { + @Test @DisplayName("throws IllegalArgumentException when projectId is blank") void blankProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = - GcpSecretManagerProviderOptions.builder().projectId("").build(); + GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().projectId("").build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); - assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( + IllegalArgumentException.class + ); } @Test @DisplayName("throws IllegalArgumentException when projectId is null") void nullProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = - GcpSecretManagerProviderOptions.builder().build(); + GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); - assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( + IllegalArgumentException.class + ); } } @Nested @DisplayName("Boolean evaluation") class BooleanEvaluation { + @Test @DisplayName("returns true for secret value 'true'") void trueValue() { stubSecret("true"); - ProviderEvaluation result = - provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()); + ProviderEvaluation result = provider.getBooleanEvaluation( + "bool-flag", + false, + new ImmutableContext() + ); assertThat(result.getValue()).isTrue(); assertThat(result.getReason()).isEqualTo(Reason.STATIC.toString()); } @@ -115,8 +117,11 @@ void trueValue() { @DisplayName("returns false for secret value 'false'") void falseValue() { stubSecret("false"); - ProviderEvaluation result = - provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + ProviderEvaluation result = provider.getBooleanEvaluation( + "bool-flag", + true, + new ImmutableContext() + ); assertThat(result.getValue()).isFalse(); } @@ -124,20 +129,25 @@ void falseValue() { @DisplayName("throws ParseError for malformed boolean value") void malformedBooleanThrows() { stubSecret("not-a-bool"); - assertThatThrownBy(() -> provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext())) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> + provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()) + ).isInstanceOf(ParseError.class); } } @Nested @DisplayName("String evaluation") class StringEvaluation { + @Test @DisplayName("returns string value as-is") void stringValue() { stubSecret("dark-mode"); - ProviderEvaluation result = - provider.getStringEvaluation("str-flag", "light-mode", new ImmutableContext()); + ProviderEvaluation result = provider.getStringEvaluation( + "str-flag", + "light-mode", + new ImmutableContext() + ); assertThat(result.getValue()).isEqualTo("dark-mode"); } } @@ -145,6 +155,7 @@ void stringValue() { @Nested @DisplayName("Integer evaluation") class IntegerEvaluation { + @Test @DisplayName("parses numeric string to Integer") void integerValue() { @@ -157,20 +168,25 @@ void integerValue() { @DisplayName("throws ParseError for non-numeric value") void nonNumericThrows() { stubSecret("abc"); - assertThatThrownBy(() -> provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext())) - .isInstanceOf(ParseError.class); + assertThatThrownBy(() -> provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext())).isInstanceOf( + ParseError.class + ); } } @Nested @DisplayName("Double evaluation") class DoubleEvaluation { + @Test @DisplayName("parses numeric string to Double") void doubleValue() { stubSecret("3.14"); - ProviderEvaluation result = - provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + ProviderEvaluation result = provider.getDoubleEvaluation( + "double-flag", + 0.0, + new ImmutableContext() + ); assertThat(result.getValue()).isEqualTo(3.14); } } @@ -178,41 +194,48 @@ void doubleValue() { @Nested @DisplayName("Object evaluation") class ObjectEvaluation { + @Test @DisplayName("parses JSON string to Value/Structure") void jsonValue() { stubSecret("{\"color\":\"blue\",\"count\":3}"); - ProviderEvaluation result = - provider.getObjectEvaluation("obj-flag", new Value(), new ImmutableContext()); + ProviderEvaluation result = provider.getObjectEvaluation( + "obj-flag", + new Value(), + new ImmutableContext() + ); assertThat(result.getValue().asStructure()).isNotNull(); - assertThat(result.getValue().asStructure().getValue("color").asString()) - .isEqualTo("blue"); + assertThat(result.getValue().asStructure().getValue("color").asString()).isEqualTo("blue"); } } @Nested @DisplayName("Error handling") class ErrorHandling { + @Test @DisplayName("throws FlagNotFoundError when secret does not exist") void flagNotFound() { stubSecretNotFound(); - assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) - .isInstanceOf(FlagNotFoundError.class); + assertThatThrownBy(() -> + provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext()) + ).isInstanceOf(FlagNotFoundError.class); } @Test @DisplayName("throws GeneralError on unexpected GCP API exception") void gcpApiError() { stubSecretError("Connection refused"); - assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())) - .isInstanceOf(GeneralError.class); + assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())).isInstanceOf( + GeneralError.class + ); } } @Nested @DisplayName("Caching") class CachingTests { + @Test @DisplayName("cache hit: GCP client called only once for two consecutive evaluations") void cacheHit() { @@ -226,13 +249,14 @@ void cacheHit() { @Nested @DisplayName("Secret name prefix") class PrefixTests { + @Test @DisplayName("prefix is prepended to the flag key when building secret name") void prefixApplied() { GcpSecretManagerProviderOptions prefixedOpts = GcpSecretManagerProviderOptions.builder() - .projectId("test-project") - .secretNamePrefix("ff-") - .build(); + .projectId("test-project") + .secretNamePrefix("ff-") + .build(); stubSecret("true"); GcpSecretManagerProvider prefixedProvider = new GcpSecretManagerProvider(prefixedOpts, mockClient); try { @@ -240,8 +264,11 @@ void prefixApplied() { } catch (Exception e) { throw new RuntimeException(e); } - ProviderEvaluation result = - prefixedProvider.getBooleanEvaluation("my-flag", false, new ImmutableContext()); + ProviderEvaluation result = prefixedProvider.getBooleanEvaluation( + "my-flag", + false, + new ImmutableContext() + ); assertThat(result.getValue()).isTrue(); } } @@ -249,6 +276,7 @@ void prefixApplied() { @Nested @DisplayName("Lifecycle") class LifecycleTests { + @Test @DisplayName("shutdown() closes the GCP client") void shutdownClosesClient() { diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java index 4e1d24276..a2f21d013 100644 --- a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java @@ -1,7 +1,7 @@ -package dev.openfeature.contrib.samples.gcpsecretmanager; +package dev.openfeature.contrib.samples.gcp; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; -import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; @@ -41,13 +41,12 @@ public static void main(String[] args) throws Exception { System.out.println(); // Build provider options - GcpSecretManagerProviderOptions options = - GcpSecretManagerProviderOptions.builder() - .projectId(projectId) - .secretNamePrefix(PREFIX) // secrets are named "of-sample-" - .secretVersion("latest") - .cacheExpiry(Duration.ofSeconds(30)) - .build(); + GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId(projectId) + .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .secretVersion("latest") + .cacheExpiry(Duration.ofSeconds(30)) + .build(); // Register the provider with OpenFeature GcpSecretManagerProvider provider = new GcpSecretManagerProvider(options); From fe78e94a65aea92d85195dfb2f63bbd8da94bb47 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:42:04 +0100 Subject: [PATCH 21/22] Refactor GcpProviderOptions to reduce duplication Signed-off-by: Mahesh Patil --- providers/gcp/README.md | 4 +- .../gcp/GcpSecretManagerProvider.java | 36 ++++---- .../gcp/GcpSecretManagerProviderOptions.java | 86 ------------------- .../gcp/SecretManagerClientFactory.java | 4 +- ...pSecretManagerProviderIntegrationTest.java | 37 ++++---- .../gcp/GcpSecretManagerProviderTest.java | 12 +-- .../samples/gcp/SecretManagerSampleApp.java | 2 +- 7 files changed, 47 insertions(+), 134 deletions(-) delete mode 100644 providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java diff --git a/providers/gcp/README.md b/providers/gcp/README.md index be3521650..0dd7c390c 100644 --- a/providers/gcp/README.md +++ b/providers/gcp/README.md @@ -74,7 +74,7 @@ GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builde | `secretVersion` | `String` | `"latest"` | Secret version to access. Use `"latest"` for the current version or a numeric string (e.g. `"3"`) to pin to a specific version | | `cacheExpiry` | `Duration` | `5 minutes` | How long fetched secret values are cached before re-fetching from GCP | | `cacheMaxSize` | `int` | `500` | Maximum number of secret values held in the in-memory cache | -| `secretNamePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` | +| `namePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` | ## Advanced Usage @@ -92,7 +92,7 @@ GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builde ```java GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() .projectId("my-gcp-project") - .secretNamePrefix("feature-flags/") + .namePrefix("feature-flags/") .build(); ``` diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java index e65cf5fcb..ce054b7a9 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java @@ -20,7 +20,7 @@ * *

Each feature flag is stored as an individual secret in GCP Secret Manager. The flag key * maps directly to the secret name (with an optional prefix configured via - * {@link GcpSecretManagerProviderOptions#getSecretNamePrefix()}). + * {@link GcpProviderOptions#getNamePrefix()}). * *

Flag values are read as UTF-8 strings from the secret payload and parsed to the requested * type. Supported raw value formats: @@ -33,7 +33,7 @@ * * *

Results are cached in-process for the duration configured in - * {@link GcpSecretManagerProviderOptions#getCacheExpiry()}. + * {@link GcpProviderOptions#getCacheExpiry()}. * *

Example: *

{@code
@@ -48,7 +48,7 @@ public class GcpSecretManagerProvider implements FeatureProvider {
 
     static final String PROVIDER_NAME = "GCP Secret Manager Provider";
 
-    private final GcpSecretManagerProviderOptions options;
+    private final GcpProviderOptions options;
     private SecretManagerServiceClient client;
     private FlagCache cache;
 
@@ -58,14 +58,14 @@ public class GcpSecretManagerProvider implements FeatureProvider {
      *
      * @param options provider configuration; must not be null
      */
-    public GcpSecretManagerProvider(GcpSecretManagerProviderOptions options) {
+    public GcpSecretManagerProvider(GcpProviderOptions options) {
         this.options = options;
     }
 
     /**
      * Package-private constructor allowing injection of a pre-built client for testing.
      */
-    GcpSecretManagerProvider(GcpSecretManagerProviderOptions options, SecretManagerServiceClient client) {
+    GcpSecretManagerProvider(GcpProviderOptions options, SecretManagerServiceClient client) {
         this.options = options;
         this.client = client;
     }
@@ -130,10 +130,7 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa
     private  ProviderEvaluation evaluate(String key, Class targetType) {
         String rawValue = fetchWithCache(key);
         T value = FlagValueConverter.convert(rawValue, targetType);
-        return ProviderEvaluation.builder()
-                .value(value)
-                .reason(Reason.STATIC.toString())
-                .build();
+        return ProviderEvaluation.builder().value(value).reason(Reason.STATIC.toString()).build();
     }
 
     private String fetchWithCache(String key) {
@@ -143,11 +140,13 @@ private String fetchWithCache(String key) {
             return cached.get();
         }
         synchronized (this) {
-            return cache.get(secretName).orElseGet(() -> {
-                String value = fetchFromGcp(secretName);
-                cache.put(secretName, value);
-                return value;
-            });
+            return cache
+                .get(secretName)
+                .orElseGet(() -> {
+                    String value = fetchFromGcp(secretName);
+                    cache.put(secretName, value);
+                    return value;
+                });
         }
     }
 
@@ -155,7 +154,7 @@ private String fetchWithCache(String key) {
      * Applies the configured prefix (if any) and returns the GCP secret name for the flag.
      */
     private String buildSecretName(String flagKey) {
-        String prefix = options.getSecretNamePrefix();
+        String prefix = options.getNamePrefix();
         return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey;
     }
 
@@ -169,8 +168,11 @@ private String buildSecretName(String flagKey) {
      */
     private String fetchFromGcp(String secretName) {
         try {
-            SecretVersionName versionName =
-                    SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion());
+            SecretVersionName versionName = SecretVersionName.of(
+                options.getProjectId(),
+                secretName,
+                options.getVersion()
+            );
             log.debug("Accessing secret '{}' from GCP", versionName);
             AccessSecretVersionResponse response = client.accessSecretVersion(versionName);
             return response.getPayload().getData().toStringUtf8();
diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java
deleted file mode 100644
index 46c1bdf60..000000000
--- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderOptions.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package dev.openfeature.contrib.providers.gcp;
-
-import com.google.auth.oauth2.GoogleCredentials;
-import java.time.Duration;
-import lombok.Builder;
-import lombok.Getter;
-
-/**
- * Configuration options for {@link GcpSecretManagerProvider}.
- *
- * 

Example usage: - *

{@code
- * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
- *     .projectId("my-gcp-project")
- *     .secretVersion("latest")
- *     .cacheExpiry(Duration.ofMinutes(2))
- *     .build();
- * }
- */ -@Getter -@Builder -public class GcpSecretManagerProviderOptions { - - /** - * GCP project ID that owns the secrets. Required. - * Example: "my-gcp-project" or numeric project number "123456789". - */ - private final String projectId; - - /** - * Explicit Google credentials to use when creating the Secret Manager client. - * When {@code null} (default), Application Default Credentials (ADC) are used - * automatically by the GCP client library. - */ - private final GoogleCredentials credentials; - - /** - * The secret version to retrieve. Defaults to {@code "latest"}. - * Override with a specific version number (e.g. {@code "3"}) for pinned deployments - * where you want consistent behaviour regardless of secret rotation. - */ - @Builder.Default - private final String secretVersion = "latest"; - - /** - * How long a fetched secret value is retained in the in-memory cache before - * the next evaluation triggers a fresh GCP API call. - * - *

Secret Manager has API quotas (10,000 access operations per minute per project - * by default). Set this to at least {@code Duration.ofSeconds(30)} in - * high-throughput scenarios. - * - *

Default: 5 minutes. - */ - @Builder.Default - private final Duration cacheExpiry = Duration.ofMinutes(5); - - /** - * Maximum number of distinct secret names held in the cache at once. - * When the cache is full, the oldest entry is evicted before inserting a new one. - * Default: 500. - */ - @Builder.Default - private final int cacheMaxSize = 500; - - /** - * Optional prefix prepended to every flag key before constructing the GCP - * secret name. For example, setting {@code secretNamePrefix = "ff-"} maps - * flag key {@code "my-flag"} to secret name {@code "ff-my-flag"}. - */ - private final String secretNamePrefix; - - /** - * Validates that required options are present and well-formed. - * - * @throws IllegalArgumentException when {@code projectId} is null or blank - */ - public void validate() { - if (projectId == null || projectId.trim().isEmpty()) { - throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank"); - } - if (secretVersion == null || secretVersion.trim().isEmpty()) { - throw new IllegalArgumentException("GcpSecretManagerProviderOptions: secretVersion must not be blank"); - } - } -} diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java index a562f8469..398f64b18 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/SecretManagerClientFactory.java @@ -16,7 +16,7 @@ private SecretManagerClientFactory() {} /** * Creates a new {@link SecretManagerServiceClient} using the provided options. * - *

When {@link GcpSecretManagerProviderOptions#getCredentials()} is non-null, those + *

When {@link GcpProviderOptions#getCredentials()} is non-null, those * credentials are used explicitly. Otherwise, the GCP client library falls back to * Application Default Credentials (ADC) automatically. * @@ -24,7 +24,7 @@ private SecretManagerClientFactory() {} * @return a configured {@link SecretManagerServiceClient} * @throws IOException if the client cannot be created */ - static SecretManagerServiceClient create(GcpSecretManagerProviderOptions options) throws IOException { + static SecretManagerServiceClient create(GcpProviderOptions options) throws IOException { SecretManagerServiceSettings.Builder settingsBuilder = SecretManagerServiceSettings.newBuilder(); if (options.getCredentials() != null) { settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(options.getCredentials())); diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java index cd074dfe5..caf2da8bc 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderIntegrationTest.java @@ -48,8 +48,7 @@ class GcpSecretManagerProviderIntegrationTest { @BeforeEach void setUp() throws Exception { String projectId = System.getenv("GCP_PROJECT_ID"); - GcpSecretManagerProviderOptions opts = - GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + GcpProviderOptions opts = GcpProviderOptions.builder().projectId(projectId).build(); provider = new GcpSecretManagerProvider(opts); provider.initialize(new ImmutableContext()); } @@ -62,43 +61,41 @@ void tearDown() { @Test @DisplayName("evaluates boolean secret") void booleanFlag() { - assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) - .getValue()) - .isTrue(); + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()).getValue()).isTrue(); } @Test @DisplayName("evaluates string secret") void stringFlag() { - assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) - .getValue()) - .isEqualTo("hello"); + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()).getValue()).isEqualTo( + "hello" + ); } @Test @DisplayName("evaluates integer secret") void integerFlag() { - assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) - .getValue()) - .isEqualTo(99); + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()).getValue()).isEqualTo(99); } @Test @DisplayName("evaluates double secret") void doubleFlag() { - assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) - .getValue()) - .isEqualTo(2.71); + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()).getValue()).isEqualTo( + 2.71 + ); } @Test @DisplayName("evaluates object secret as Value/Structure") void objectFlag() { - assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) - .getValue() - .asStructure() - .getValue("key") - .asString()) - .isEqualTo("val"); + assertThat( + provider + .getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString() + ).isEqualTo("val"); } } diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java index 5de917e0b..a66d7439b 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java @@ -35,12 +35,12 @@ class GcpSecretManagerProviderTest { @Mock private SecretManagerServiceClient mockClient; - private GcpSecretManagerProviderOptions options; + private GcpProviderOptions options; private GcpSecretManagerProvider provider; @BeforeEach void setUp() throws Exception { - options = GcpSecretManagerProviderOptions.builder().projectId("test-project").build(); + options = GcpProviderOptions.builder().projectId("test-project").build(); provider = new GcpSecretManagerProvider(options, mockClient); provider.initialize(new ImmutableContext()); } @@ -78,7 +78,7 @@ class InitializationTests { @Test @DisplayName("throws IllegalArgumentException when projectId is blank") void blankProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().projectId("").build(); + GcpProviderOptions badOpts = GcpProviderOptions.builder().projectId("").build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( IllegalArgumentException.class @@ -88,7 +88,7 @@ void blankProjectIdThrows() { @Test @DisplayName("throws IllegalArgumentException when projectId is null") void nullProjectIdThrows() { - GcpSecretManagerProviderOptions badOpts = GcpSecretManagerProviderOptions.builder().build(); + GcpProviderOptions badOpts = GcpProviderOptions.builder().build(); GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())).isInstanceOf( IllegalArgumentException.class @@ -253,9 +253,9 @@ class PrefixTests { @Test @DisplayName("prefix is prepended to the flag key when building secret name") void prefixApplied() { - GcpSecretManagerProviderOptions prefixedOpts = GcpSecretManagerProviderOptions.builder() + GcpProviderOptions prefixedOpts = GcpProviderOptions.builder() .projectId("test-project") - .secretNamePrefix("ff-") + .namePrefix("ff-") .build(); stubSecret("true"); GcpSecretManagerProvider prefixedProvider = new GcpSecretManagerProvider(prefixedOpts, mockClient); diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java index a2f21d013..838ece1d3 100644 --- a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java @@ -43,7 +43,7 @@ public static void main(String[] args) throws Exception { // Build provider options GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() .projectId(projectId) - .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .namePrefix(PREFIX) // secrets are named "of-sample-" .secretVersion("latest") .cacheExpiry(Duration.ofSeconds(30)) .build(); From 13dc2778cb79e57f47c4dee5640fd70b68ca4662 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Tue, 12 May 2026 18:42:23 +0100 Subject: [PATCH 22/22] Add GcpProviderOptions Signed-off-by: Mahesh Patil --- .../providers/gcp/GcpProviderOptions.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java new file mode 100644 index 000000000..b430725ac --- /dev/null +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java @@ -0,0 +1,86 @@ +package dev.openfeature.contrib.providers.gcp; + +import com.google.auth.oauth2.GoogleCredentials; +import java.time.Duration; +import lombok.Builder; +import lombok.Getter; + +/** + * Configuration options for {@link GcpSecretManagerProvider}. + * + *

Example usage: + *

{@code
+ * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .secretVersion("latest")
+ *     .cacheExpiry(Duration.ofMinutes(2))
+ *     .build();
+ * }
+ */ +@Getter +@Builder +public class GcpProviderOptions { + + /** + * GCP project ID that owns the config. Required. + * Example: "my-gcp-project" or numeric project number "123456789". + */ + private final String projectId; + + /** + * Explicit Google credentials to use when creating the GCP client. + * When {@code null} (default), Application Default Credentials (ADC) are used + * automatically by the GCP client library. + */ + private final GoogleCredentials credentials; + + /** + * The secret version to retrieve. Defaults to {@code "latest"}. + * Override with a specific version number (e.g. {@code "3"}) for pinned deployments + * where you want consistent behaviour regardless of secret rotation. + */ + @Builder.Default + private final String version = "latest"; + + /** + * How long a fetched secret value is retained in the in-memory cache before + * the next evaluation triggers a fresh GCP API call. + * + *

Secret Manager has API quotas (10,000 access operations per minute per project + * by default). Set this to at least {@code Duration.ofSeconds(30)} in + * high-throughput scenarios. + * + *

Default: 5 minutes. + */ + @Builder.Default + private final Duration cacheExpiry = Duration.ofMinutes(5); + + /** + * Maximum number of distinct secret names held in the cache at once. + * When the cache is full, the oldest entry is evicted before inserting a new one. + * Default: 500. + */ + @Builder.Default + private final int cacheMaxSize = 500; + + /** + * Optional prefix prepended to every flag key before constructing the GCP + * secret name. For example, setting {@code namePrefix = "ff-"} maps + * flag key {@code "my-flag"} to secret name {@code "ff-my-flag"}. + */ + private final String namePrefix; + + /** + * Validates that required options are present and well-formed. + * + * @throws IllegalArgumentException when {@code projectId} is null or blank + */ + public void validate() { + if (projectId == null || projectId.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank"); + } + if (version == null || version.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: secretVersion must not be blank"); + } + } +}