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:
+ *
+ * - {@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 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:
+ *
+ * - Set the {@code GCP_PROJECT_ID} environment variable to your GCP project ID.
+ * - Ensure Application Default Credentials are configured
+ * ({@code gcloud auth application-default login}).
+ * - 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"}}
+ *
+ *
+ *
+ *
+ * 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."