From b298a452c53116e56c4c023a149bff51d4f8fdf3 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Thu, 9 Apr 2026 06:17:11 +0100 Subject: [PATCH] feat: add GCP Parameter Manager OpenFeature provider Adds a new OpenFeature provider for GCP Parameter Manager that enables reading feature flags from GCP Parameter Manager secrets. Includes the provider implementation with flag caching, value conversion, unit tests, integration tests, and a sample application demonstrating usage. --- .release-please-manifest.json | 1 + pom.xml | 1 + providers/gcp-parameter-manager/CHANGELOG.md | 1 + providers/gcp-parameter-manager/README.md | 121 +++++++++ providers/gcp-parameter-manager/pom.xml | 77 ++++++ .../spotbugs-exclusions.xml | 39 +++ .../gcpparametermanager/FlagCache.java | 89 ++++++ .../FlagValueConverter.java | 164 +++++++++++ .../GcpParameterManagerProvider.java | 176 ++++++++++++ .../GcpParameterManagerProviderOptions.java | 90 ++++++ .../ParameterManagerClientFactory.java | 34 +++ .../gcpparametermanager/FlagCacheTest.java | 83 ++++++ .../FlagValueConverterTest.java | 134 +++++++++ ...rameterManagerProviderIntegrationTest.java | 106 ++++++++ .../GcpParameterManagerProviderTest.java | 257 ++++++++++++++++++ .../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-parameter-manager/version.txt | 1 + release-please-config.json | 11 + .../gcp-parameter-manager-sample/README.md | 173 ++++++++++++ samples/gcp-parameter-manager-sample/pom.xml | 69 +++++ samples/gcp-parameter-manager-sample/setup.sh | 82 ++++++ .../ParameterManagerSampleApp.java | 131 +++++++++ .../gcp-parameter-manager-sample/teardown.sh | 36 +++ 28 files changed, 1894 insertions(+) create mode 100644 providers/gcp-parameter-manager/CHANGELOG.md create mode 100644 providers/gcp-parameter-manager/README.md create mode 100644 providers/gcp-parameter-manager/pom.xml create mode 100644 providers/gcp-parameter-manager/spotbugs-exclusions.xml create mode 100644 providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java create mode 100644 providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java create mode 100644 providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java create mode 100644 providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java create mode 100644 providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java create mode 100644 providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java create mode 100644 providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java create mode 100644 providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java create mode 100644 providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java create mode 100644 providers/gcp-parameter-manager/src/test/resources/fixtures/bool-flag.txt create mode 100644 providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt create mode 100644 providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt create mode 100644 providers/gcp-parameter-manager/src/test/resources/fixtures/object-flag.json create mode 100644 providers/gcp-parameter-manager/src/test/resources/fixtures/string-flag.txt create mode 100644 providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml create mode 100644 providers/gcp-parameter-manager/version.txt create mode 100644 samples/gcp-parameter-manager-sample/README.md create mode 100644 samples/gcp-parameter-manager-sample/pom.xml create mode 100644 samples/gcp-parameter-manager-sample/setup.sh create mode 100644 samples/gcp-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java create mode 100755 samples/gcp-parameter-manager-sample/teardown.sh diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b96f60247..1fab46e59 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-parameter-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 f497d1a1e..30abe4f84 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ providers/optimizely providers/multiprovider providers/ofrep + providers/gcp-parameter-manager tools/flagd-http-connector diff --git a/providers/gcp-parameter-manager/CHANGELOG.md b/providers/gcp-parameter-manager/CHANGELOG.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/providers/gcp-parameter-manager/CHANGELOG.md @@ -0,0 +1 @@ + diff --git a/providers/gcp-parameter-manager/README.md b/providers/gcp-parameter-manager/README.md new file mode 100644 index 000000000..63ddc65f4 --- /dev/null +++ b/providers/gcp-parameter-manager/README.md @@ -0,0 +1,121 @@ +# GCP Parameter Manager Provider + +An OpenFeature provider that reads feature flags from [Google Cloud Parameter Manager](https://cloud.google.com/secret-manager/parameter-manager/docs/overview), the GCP-native equivalent of AWS SSM Parameter Store. + +## Installation + + +```xml + + dev.openfeature.contrib.providers + gcp-parameter-manager + 0.0.1 + +``` + + +## Quick Start + +```java +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProvider; +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProviderOptions; +import dev.openfeature.sdk.OpenFeatureAPI; + +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .build(); + +OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(options)); + +// Evaluate a boolean flag stored in GCP as parameter "enable-dark-mode" +boolean darkMode = OpenFeatureAPI.getInstance().getClient() + .getBooleanValue("enable-dark-mode", false); +``` + +## How It Works + +Each feature flag is stored as an individual **parameter** in GCP Parameter Manager. The flag key maps directly to the parameter name (with an optional prefix). + +Supported raw value formats: + +| Flag type | Parameter 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")); + +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .credentials(credentials) + .build(); +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `projectId` | `String` | *(required)* | GCP project ID that owns the parameters | +| `locationId` | `String` | `"global"` | GCP location for the Parameter Manager endpoint (`"global"` or a region such as `"us-central1"`) | +| `credentials` | `GoogleCredentials` | `null` (ADC) | Explicit credentials; falls back to Application Default Credentials when null | +| `cacheExpiry` | `Duration` | `5 minutes` | How long fetched values are cached before re-fetching from GCP | +| `cacheMaxSize` | `int` | `500` | Maximum number of flag values held in the in-memory cache | +| `parameterNamePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to parameter `"ff-my-flag"` | + +## Advanced Usage + +### Regional endpoint + +```java +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .locationId("us-central1") + .build(); +``` + +### Parameter name prefix + +```java +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .parameterNamePrefix("feature-flags/") + .build(); +``` + +### Tuning cache for high-throughput scenarios + +GCP Parameter Manager has API quotas. Use a longer `cacheExpiry` to reduce quota consumption. + +```java +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.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 parameters. + +1. Configure ADC: `gcloud auth application-default login` +2. Create test parameters in your project (see `GcpParameterManagerProviderIntegrationTest` for the required parameter names) +3. Run: + +```bash +GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-parameter-manager -Dgroups=integration +``` diff --git a/providers/gcp-parameter-manager/pom.xml b/providers/gcp-parameter-manager/pom.xml new file mode 100644 index 000000000..bc3a92343 --- /dev/null +++ b/providers/gcp-parameter-manager/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + + dev.openfeature.contrib.providers + gcp-parameter-manager + 0.0.1 + + + + ${groupId}.gcpparametermanager + + + gcp-parameter-manager + GCP Parameter Manager provider for OpenFeature Java SDK + https://openfeature.dev + + + + openfeaturebot + OpenFeature Bot + OpenFeature + https://openfeature.dev/ + + + + + + + com.google.cloud + google-cloud-parametermanager + 0.31.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 + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + integration + + + + + diff --git a/providers/gcp-parameter-manager/spotbugs-exclusions.xml b/providers/gcp-parameter-manager/spotbugs-exclusions.xml new file mode 100644 index 000000000..7f1d7ae12 --- /dev/null +++ b/providers/gcp-parameter-manager/spotbugs-exclusions.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java new file mode 100644 index 000000000..05fd9e615 --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java @@ -0,0 +1,89 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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 Parameter 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; + + FlagCache(Duration ttl, int maxSize) { + this.ttl = ttl; + 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) { + 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)); + } + + /** + * 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; + + CacheEntry(String value, Duration ttl) { + this.value = value; + this.expiresAt = Instant.now().plus(ttl); + } + + boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + } +} diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java new file mode 100644 index 000000000..78916e8ec --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java @@ -0,0 +1,164 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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 parameter values (fetched from GCP Parameter 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: + *

+ * + * @param raw the raw string value from GCP Parameter 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)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(trimmed)) { + return Boolean.FALSE; + } + 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 the string looks like JSON, parse it into an OpenFeature Value/Structure + 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}. + * Follows the same pattern as {@code FlagsmithProvider.objectToValue()}. + */ + 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-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java new file mode 100644 index 000000000..379c6343e --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java @@ -0,0 +1,176 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.parametermanager.v1.ParameterManagerClient; +import com.google.cloud.parametermanager.v1.ParameterVersionName; +import com.google.cloud.parametermanager.v1.RenderParameterVersionResponse; +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 lombok.extern.slf4j.Slf4j; + +/** + * OpenFeature {@link FeatureProvider} backed by Google Cloud Parameter Manager. + * + *

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

Flag values are read as strings 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 GcpParameterManagerProviderOptions#getCacheExpiry()}. + * + *

Example: + *

{@code
+ * GcpParameterManagerProviderOptions opts = GcpParameterManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .build();
+ * OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(opts));
+ * }
+ */ +@Slf4j +public class GcpParameterManagerProvider implements FeatureProvider { + + static final String PROVIDER_NAME = "GCP Parameter Manager Provider"; + + private final GcpParameterManagerProviderOptions options; + private ParameterManagerClient 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 GcpParameterManagerProvider(GcpParameterManagerProviderOptions options) { + this.options = options; + } + + /** + * Package-private constructor allowing injection of a pre-built client for testing. + */ + GcpParameterManagerProvider(GcpParameterManagerProviderOptions options, ParameterManagerClient 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 = ParameterManagerClientFactory.create(options); + } + cache = new FlagCache(options.getCacheExpiry(), options.getCacheMaxSize()); + log.info("GcpParameterManagerProvider initialized for project '{}'", options.getProjectId()); + } + + @Override + public void shutdown() { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + log.warn("Error closing ParameterManagerClient", e); + } + client = null; + } + log.info("GcpParameterManagerProvider 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 paramName = buildParameterName(key); + return cache.get(paramName).orElseGet(() -> { + String value = fetchFromGcp(paramName); + cache.put(paramName, value); + return value; + }); + } + + /** + * Applies the configured prefix (if any) and returns the GCP parameter name for the flag. + */ + private String buildParameterName(String flagKey) { + String prefix = options.getParameterNamePrefix(); + return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey; + } + + /** + * Fetches the latest version of the named parameter from GCP Parameter Manager. + * + * @param parameterName the GCP parameter name (without project/location path) + * @return the rendered string value of the parameter + * @throws FlagNotFoundError when the parameter does not exist + * @throws GeneralError for any other GCP API error + */ + private String fetchFromGcp(String parameterName) { + try { + ParameterVersionName versionName = + ParameterVersionName.of(options.getProjectId(), options.getLocationId(), parameterName, options.getParameterVersion()); + log.debug("Fetching parameter '{}' from GCP", versionName); + RenderParameterVersionResponse response = client.renderParameterVersion(versionName); + return response.getRenderedPayload().toStringUtf8(); + } catch (NotFoundException e) { + throw new FlagNotFoundError("Parameter not found: " + parameterName); + } catch (Exception e) { + throw new GeneralError("Error fetching parameter '" + parameterName + "': " + e.getMessage()); + } + } +} diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java new file mode 100644 index 000000000..333faf356 --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java @@ -0,0 +1,90 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import com.google.auth.oauth2.GoogleCredentials; +import java.time.Duration; +import lombok.Builder; +import lombok.Getter; + +/** + * Configuration options for {@link GcpParameterManagerProvider}. + * + *

Example usage: + *

{@code
+ * GcpParameterManagerProviderOptions opts = GcpParameterManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .locationId("us-central1")
+ *     .cacheExpiry(Duration.ofMinutes(2))
+ *     .build();
+ * }
+ */ +@Getter +@Builder +public class GcpParameterManagerProviderOptions { + + /** + * GCP project ID that owns the parameters. Required. + * Example: "my-gcp-project" or numeric project number "123456789". + */ + private final String projectId; + + /** + * GCP location for the Parameter Manager endpoint. Optional. + * Use "global" (default) for the global endpoint, or a region such as "us-central1" + * when parameters are stored regionally. + */ + @Builder.Default + private final String locationId = "global"; + + /** + * Explicit Google credentials to use when creating the Parameter Manager client. + * When {@code null} (default), Application Default Credentials (ADC) are used + * automatically by the GCP client library. + */ + private final GoogleCredentials credentials; + + /** + * How long a fetched parameter value is retained in the in-memory cache before + * the next evaluation triggers a fresh GCP API call. + * + *

GCP Parameter Manager has API quotas. 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 parameter 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; + + /** + * The parameter 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 parameter updates. + */ + @Builder.Default + private final String parameterVersion = "latest"; + + /** + * Optional prefix prepended to every flag key before constructing the GCP + * parameter name. For example, setting {@code parameterNamePrefix = "ff-"} maps + * flag key {@code "my-flag"} to parameter name {@code "ff-my-flag"}. + */ + private final String parameterNamePrefix; + + /** + * 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("GcpParameterManagerProviderOptions: projectId must not be blank"); + } + } +} diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java new file mode 100644 index 000000000..3ab8eee57 --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java @@ -0,0 +1,34 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.cloud.parametermanager.v1.ParameterManagerClient; +import com.google.cloud.parametermanager.v1.ParameterManagerSettings; +import java.io.IOException; + +/** + * Factory for creating a {@link ParameterManagerClient}, separated to allow injection + * of mock clients in unit tests. + */ +final class ParameterManagerClientFactory { + + private ParameterManagerClientFactory() {} + + /** + * Creates a new {@link ParameterManagerClient} using the provided options. + * + *

When {@link GcpParameterManagerProviderOptions#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 ParameterManagerClient} + * @throws IOException if the client cannot be created + */ + static ParameterManagerClient create(GcpParameterManagerProviderOptions options) throws IOException { + ParameterManagerSettings.Builder settingsBuilder = ParameterManagerSettings.newBuilder(); + if (options.getCredentials() != null) { + settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(options.getCredentials())); + } + return ParameterManagerClient.create(settingsBuilder.build()); + } +} diff --git a/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java new file mode 100644 index 000000000..25141cc0a --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java @@ -0,0 +1,83 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Optional; +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() throws InterruptedException { + FlagCache shortTtlCache = new FlagCache(Duration.ofMillis(50), 100); + shortTtlCache.put("flag", "value"); + Thread.sleep(100); + assertThat(shortTtlCache.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() throws InterruptedException { + FlagCache tinyCache = new FlagCache(Duration.ofMinutes(5), 2); + tinyCache.put("first", "1"); + Thread.sleep(5); // ensure different insertion time + tinyCache.put("second", "2"); + Thread.sleep(5); + // inserting a third entry should evict the oldest + tinyCache.put("third", "3"); + // at least the newest entry must be present + assertThat(tinyCache.get("third")).isPresent().hasValue("3"); + // total size should be at most maxSize + 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-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java new file mode 100644 index 000000000..f75a55f2b --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java @@ -0,0 +1,134 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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("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("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-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java new file mode 100644 index 000000000..eafcf5f6c --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java @@ -0,0 +1,106 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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 GcpParameterManagerProvider}. + * + *

These tests require real GCP credentials and a pre-configured project with + * test parameters. 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 parameters in GCP Parameter Manager under the project: + *
      + *
    • {@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-parameter-manager -Dgroups=integration
+ * }
+ */ +@Tag("integration") +@DisabledIfEnvironmentVariable(named = "GCP_PROJECT_ID", matches = "") +@DisplayName("GcpParameterManagerProvider integration tests") +class GcpParameterManagerProviderIntegrationTest { + + private GcpParameterManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + String projectId = System.getenv("GCP_PROJECT_ID"); + GcpParameterManagerProviderOptions opts = GcpParameterManagerProviderOptions.builder() + .projectId(projectId) + .build(); + provider = new GcpParameterManagerProvider(opts); + provider.initialize(new ImmutableContext()); + } + + @AfterEach + void tearDown() { + if (provider != null) { + provider.shutdown(); + } + } + + @Test + @DisplayName("evaluates boolean parameter") + void booleanFlag() { + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) + .getValue()) + .isTrue(); + } + + @Test + @DisplayName("evaluates string parameter") + void stringFlag() { + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) + .getValue()) + .isEqualTo("hello"); + } + + @Test + @DisplayName("evaluates integer parameter") + void integerFlag() { + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) + .getValue()) + .isEqualTo(99); + } + + @Test + @DisplayName("evaluates double parameter") + void doubleFlag() { + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) + .getValue()) + .isEqualTo(2.71); + } + + @Test + @DisplayName("evaluates object parameter 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-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java new file mode 100644 index 000000000..3328f297c --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java @@ -0,0 +1,257 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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.parametermanager.v1.ParameterManagerClient; +import com.google.cloud.parametermanager.v1.ParameterVersionName; +import com.google.cloud.parametermanager.v1.RenderParameterVersionResponse; +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("GcpParameterManagerProvider") +@ExtendWith(MockitoExtension.class) +class GcpParameterManagerProviderTest { + + @Mock + private ParameterManagerClient mockClient; + + private GcpParameterManagerProviderOptions options; + private GcpParameterManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + options = GcpParameterManagerProviderOptions.builder() + .projectId("test-project") + .build(); + provider = new GcpParameterManagerProvider(options, mockClient); + provider.initialize(new ImmutableContext()); + } + + private void stubParameter(String key, String value) { + RenderParameterVersionResponse response = RenderParameterVersionResponse.newBuilder() + .setRenderedPayload(ByteString.copyFromUtf8(value)) + .build(); + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))).thenReturn(response); + } + + private void stubParameterNotFound() { + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))).thenThrow(NotFoundException.class); + } + + private void stubParameterError(String message) { + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))) + .thenThrow(new RuntimeException(message)); + } + + @Nested + @DisplayName("Metadata") + class MetadataTests { + @Test + @DisplayName("returns the correct provider name") + void providerName() { + assertThat(provider.getMetadata().getName()).isEqualTo(GcpParameterManagerProvider.PROVIDER_NAME); + } + } + + @Nested + @DisplayName("Initialization") + class InitializationTests { + @Test + @DisplayName("throws IllegalArgumentException when projectId is blank") + void blankProjectIdThrows() { + GcpParameterManagerProviderOptions badOpts = + GcpParameterManagerProviderOptions.builder().projectId("").build(); + GcpParameterManagerProvider badProvider = new GcpParameterManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws IllegalArgumentException when projectId is null") + void nullProjectIdThrows() { + GcpParameterManagerProviderOptions badOpts = + GcpParameterManagerProviderOptions.builder().build(); + GcpParameterManagerProvider badProvider = new GcpParameterManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("Boolean evaluation") + class BooleanEvaluation { + @Test + @DisplayName("returns true for parameter value 'true'") + void trueValue() { + stubParameter("bool-flag", "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 parameter value 'false'") + void falseValue() { + stubParameter("bool-flag", "false"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + assertThat(result.getValue()).isFalse(); + } + + @Test + @DisplayName("throws ParseError for malformed boolean value") + void malformedBooleanThrows() { + stubParameter("bool-flag", "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() { + stubParameter("str-flag", "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() { + stubParameter("int-flag", "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() { + stubParameter("int-flag", "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() { + stubParameter("double-flag", "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() { + stubParameter("obj-flag", "{\"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 parameter does not exist") + void flagNotFound() { + stubParameterNotFound(); + assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) + .isInstanceOf(FlagNotFoundError.class); + } + + @Test + @DisplayName("throws GeneralError on unexpected GCP API exception") + void gcpApiError() { + stubParameterError("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() { + stubParameter("cached-flag", "true"); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + verify(mockClient, times(1)).renderParameterVersion(any(ParameterVersionName.class)); + } + } + + @Nested + @DisplayName("Parameter name prefix") + class PrefixTests { + @Test + @DisplayName("prefix is prepended to the flag key when building parameter name") + void prefixApplied() { + GcpParameterManagerProviderOptions prefixedOpts = GcpParameterManagerProviderOptions.builder() + .projectId("test-project") + .parameterNamePrefix("ff-") + .build(); + stubParameter("ff-my-flag", "true"); + GcpParameterManagerProvider prefixedProvider = new GcpParameterManagerProvider(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-parameter-manager/src/test/resources/fixtures/bool-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/bool-flag.txt new file mode 100644 index 000000000..f32a5804e --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/bool-flag.txt @@ -0,0 +1 @@ +true \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt new file mode 100644 index 000000000..3767b4b17 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt @@ -0,0 +1 @@ +3.14 \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt new file mode 100644 index 000000000..f70d7bba4 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/fixtures/object-flag.json b/providers/gcp-parameter-manager/src/test/resources/fixtures/object-flag.json new file mode 100644 index 000000000..5b3335c01 --- /dev/null +++ b/providers/gcp-parameter-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-parameter-manager/src/test/resources/fixtures/string-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/string-flag.txt new file mode 100644 index 000000000..3643aef41 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/string-flag.txt @@ -0,0 +1 @@ +dark-mode \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml b/providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..c52aaeaf2 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/providers/gcp-parameter-manager/version.txt b/providers/gcp-parameter-manager/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/gcp-parameter-manager/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/release-please-config.json b/release-please-config.json index 33ad09f17..3968cadfa 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -135,6 +135,17 @@ "README.md" ] }, + "providers/gcp-parameter-manager": { + "package-name": "dev.openfeature.contrib.providers.gcp-parameter-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-parameter-manager-sample/README.md b/samples/gcp-parameter-manager-sample/README.md new file mode 100644 index 000000000..2eadcb591 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/README.md @@ -0,0 +1,173 @@ +# GCP Parameter Manager — OpenFeature Sample + +A runnable Java application demonstrating the [GCP Parameter Manager OpenFeature provider](../../providers/gcp-parameter-manager). + +It evaluates five feature flags (covering every supported type) that are stored as +parameters in Google Cloud Parameter Manager. + +## Feature Flags Used + +| Parameter 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 parameters 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 **Parameter Manager Parameter Version Accessor** role +(`roles/parametermanager.parameterVersionAccessor`) on the project. + +--- + +## Step 1 — Enable the API + +```bash +export GCP_PROJECT_ID=my-gcp-project # replace with your project ID + +gcloud services enable parametermanager.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 parameters + +```bash +cd samples/gcp-parameter-manager-sample +bash setup.sh +``` + +You should see output like: + +``` +Creating sample feature-flag parameters in project: my-gcp-project (location: global) + [CREATED] of-sample-dark-mode + [VERSION] of-sample-dark-mode → true + [CREATED] of-sample-banner-text + ... +✓ All parameters 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 Parameter Manager — OpenFeature Sample +======================================================= +Project : my-gcp-project +Location : global +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 parameter version: + +```bash +gcloud parametermanager parameters versions create "v2" \ + --parameter="of-sample-dark-mode" \ + --project="$GCP_PROJECT_ID" \ + --location=global \ + --payload-data="false" +``` + +The provider fetches the `latest` version by default (the highest version number). +Re-run the sample after the 30-second cache expires to see the updated value. + +--- + +## Regional parameters + +If your parameters are stored in a specific region instead of `global`, update the +`locationId` in the app or pass a different location in `setup.sh`: + +```java +GcpParameterManagerProviderOptions.builder() + .projectId(projectId) + .locationId("us-central1") // regional endpoint + ... +``` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GCP_PROJECT_ID is not set` | Env var missing | `export GCP_PROJECT_ID=my-project` | +| `FlagNotFoundError` | Parameter doesn't exist | Run `setup.sh` first | +| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/parametermanager.parameterVersionAccessor` | +| `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | +| `parametermanager.googleapis.com is not enabled` | API disabled | Run Step 1 | +| `Could not find artifact ...gcp-parameter-manager` | Provider not installed | Run Step 3 | diff --git a/samples/gcp-parameter-manager-sample/pom.xml b/samples/gcp-parameter-manager-sample/pom.xml new file mode 100644 index 000000000..a910c5890 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + dev.openfeature.contrib.samples + gcp-parameter-manager-sample + 1.0-SNAPSHOT + jar + + GCP Parameter Manager OpenFeature Sample + + Runnable sample demonstrating the GCP Parameter Manager OpenFeature provider. + Evaluates five feature flags (bool, string, int, double, object) stored as + GCP parameters against a real GCP project. + + + + 17 + ${java.version} + ${java.version} + UTF-8 + + ${env.GCP_PROJECT_ID} + + + + + + dev.openfeature.contrib.providers + gcp-parameter-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.gcpparametermanager.ParameterManagerSampleApp + + + GCP_PROJECT_ID + ${GCP_PROJECT_ID} + + + + + + + diff --git a/samples/gcp-parameter-manager-sample/setup.sh b/samples/gcp-parameter-manager-sample/setup.sh new file mode 100644 index 000000000..a2d341f62 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/setup.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# setup.sh — Creates the five sample feature-flag parameters in GCP Parameter Manager. +# +# Prerequisites: +# - gcloud CLI installed and authenticated (gcloud auth application-default login) +# - Parameter Manager API enabled: gcloud services enable parametermanager.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)}" +LOCATION="global" + +echo "Creating sample feature-flag parameters in project: ${PROJECT} (location: ${LOCATION})" +echo "All parameters are prefixed with 'of-sample-' to match the sample app." +echo "" + +# ──────────────────────────────────────────────────────────────────────────────── +create_parameter() { + local name="$1" + local value="$2" + local full_name="of-sample-${name}" + + # Create the parameter resource (idempotent — ignores "already exists") + if gcloud parametermanager parameters describe "${full_name}" \ + --project="${PROJECT}" --location="${LOCATION}" &>/dev/null; then + echo " [EXISTS] ${full_name} — adding new version" + else + gcloud parametermanager parameters create "${full_name}" \ + --project="${PROJECT}" \ + --location="${LOCATION}" \ + --parameter-format=UNFORMATTED \ + --quiet + echo " [CREATED] ${full_name}" + fi + + # Add a parameter version with the flag value + # NOTE: versions in Parameter Manager are named; we use "v1" for the initial version + gcloud parametermanager parameters versions create "v1" \ + --parameter="${full_name}" \ + --project="${PROJECT}" \ + --location="${LOCATION}" \ + --payload-data="${value}" \ + --quiet 2>/dev/null || \ + gcloud parametermanager parameters versions create "v$(date +%s)" \ + --parameter="${full_name}" \ + --project="${PROJECT}" \ + --location="${LOCATION}" \ + --payload-data="${value}" \ + --quiet + echo " [VERSION] ${full_name} → ${value}" +} +# ──────────────────────────────────────────────────────────────────────────────── + +# Boolean flag: dark UI theme toggle +create_parameter "dark-mode" "true" + +# String flag: hero banner text +create_parameter "banner-text" "Welcome! 10% off today only" + +# Integer flag: maximum items in cart +create_parameter "max-cart-items" "25" + +# Double flag: discount multiplier (10%) +create_parameter "discount-rate" "0.10" + +# Object flag: structured checkout configuration (JSON) +create_parameter "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' + +echo "" +echo "✓ All parameters created successfully." +echo "" +echo "Next steps:" +echo " 1. Authenticate: gcloud auth application-default login" +echo " 2. Run the sample:" +echo " cd gcp-parameter-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-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java b/samples/gcp-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java new file mode 100644 index 000000000..1d6df703d --- /dev/null +++ b/samples/gcp-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java @@ -0,0 +1,131 @@ +package dev.openfeature.contrib.samples.gcpparametermanager; + +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProvider; +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProviderOptions; +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 Parameter Manager OpenFeature provider. + * + *

This app evaluates five feature flags backed by GCP Parameter Manager parameters: + *

    + *
  • {@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 parameters in your GCP project, then: + *

+ *   export GCP_PROJECT_ID=my-gcp-project
+ *   mvn exec:java
+ * 
+ */ +public class ParameterManagerSampleApp { + + 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 Parameter Manager — OpenFeature Sample"); + System.out.println("======================================================="); + System.out.println("Project : " + projectId); + System.out.println("Location : global"); + System.out.println("Prefix : " + PREFIX); + System.out.println(); + + // Build provider options + GcpParameterManagerProviderOptions options = + GcpParameterManagerProviderOptions.builder() + .projectId(projectId) + .locationId("global") // use "us-central1" etc. for regional parameters + .parameterNamePrefix(PREFIX) // parameters named "of-sample-" + .cacheExpiry(Duration.ofSeconds(30)) + .build(); + + // Register the provider with OpenFeature + GcpParameterManagerProvider provider = new GcpParameterManagerProvider(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-parameter-manager-sample/teardown.sh b/samples/gcp-parameter-manager-sample/teardown.sh new file mode 100755 index 000000000..c1db0db17 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/teardown.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# teardown.sh — Deletes the sample feature-flag parameters from GCP Parameter 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)}" +LOCATION="global" + +echo "Deleting sample parameters from project: ${PROJECT} (location: ${LOCATION})" +echo "" + +for name in dark-mode banner-text max-cart-items discount-rate checkout-config; do + full_name="of-sample-${name}" + if gcloud parametermanager parameters describe "${full_name}" \ + --project="${PROJECT}" --location="${LOCATION}" &>/dev/null; then + for version in $(gcloud parametermanager parameters versions list \ + --parameter="${full_name}" --project="${PROJECT}" --location="${LOCATION}" \ + --format="value(name.basename())" 2>/dev/null); do + gcloud parametermanager parameters versions delete "${version}" \ + --parameter="${full_name}" --project="${PROJECT}" --location="${LOCATION}" --quiet + echo " [DELETED] ${full_name}/versions/${version}" + done + gcloud parametermanager parameters delete "${full_name}" \ + --project="${PROJECT}" --location="${LOCATION}" --quiet + echo " [DELETED] ${full_name}" + else + echo " [SKIP] ${full_name} (not found)" + fi +done + +echo "" +echo "✓ Cleanup complete."