diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java index c270138c1..3d2c71303 100644 --- a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java @@ -47,7 +47,7 @@ void start() { if (t != null) t.cancel(false); return; } - var o = RefreshJob.runRefreshWithTimeout(self.action, self.config.timeout); + var o = RefreshJob.runRefreshWithTimeout(self.action, self.config.getTimeoutMilliseconds()); if (o != null) { boolean changed = !o.equals(self.latestOutput); self.latestOutput = o; @@ -66,7 +66,7 @@ void start() { } }, 0, - config.interval, + config.getIntervalMilliseconds(), TimeUnit.MILLISECONDS ); @@ -83,7 +83,7 @@ public Optional getOutput() { if (poll == null) { log.warn("Polling hasn't started but the output is being used."); } else if (!firstOutput.isDone()) { - return Optional.ofNullable(firstOutput.get(config.timeout, TimeUnit.MILLISECONDS)); + return Optional.ofNullable(firstOutput.get(config.getTimeoutMilliseconds(), TimeUnit.MILLISECONDS)); } } catch (Exception e) { log.warn("Attempted to await for poll output but an exception occurred: {}", e.toString()); @@ -118,9 +118,9 @@ final class OnDemand implements RefreshJob { @Override public Optional getOutput() { if (!stopped) { - if (lastUpdated - System.currentTimeMillis() < config.ttl) { + if (lastUpdated - System.currentTimeMillis() < config.getTtlMilliseconds()) { log.debug("Running refresh as current output is stale."); - var o = RefreshJob.runRefreshWithTimeout(action, config.timeout); + var o = RefreshJob.runRefreshWithTimeout(action, config.getTimeoutMilliseconds()); if (o != null) { boolean changed = !o.equals(output); output = o; diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionConfig.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionConfig.java index 0e5b64c97..9306d5552 100644 --- a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionConfig.java +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionConfig.java @@ -1,6 +1,5 @@ package io.juspay.superposition.openfeature; -import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; import lombok.Builder; import lombok.Data; diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java index 9618e2e3d..0227afe25 100644 --- a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -33,7 +34,7 @@ * // Configure experimentation options * SuperpositionProviderOptions.ExperimentationOptions expOptions = * SuperpositionProviderOptions.ExperimentationOptions.builder() - * .refreshStrategy(RefreshStrategy.Polling.of(5000, 2000)) // 5s timeout, 2s interval + * .refreshStrategy(new RefreshStrategy.Polling(5000, 2000)) // 5s timeout, 2s interval * .build(); * * // Configure provider options @@ -43,7 +44,7 @@ * .workspaceId("your-workspace-id") * .endpoint("https://api.superposition.dev") * .token("your-api-token") - * .refreshStrategy(RefreshStrategy.Polling.of(10000, 5000)) // 10s timeout, 5s interval + * .refreshStrategy(new RefreshStrategy.Polling(10000, 5000)) // 10s timeout, 5s interval * .experimentationOptions(expOptions) * .build(); * @@ -95,7 +96,7 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op } this.sdk = builder.build(); this.cache = new ProviderCache(); - this.configTimeout = options.refreshStrategy.getTimeout(); + this.configTimeout = options.refreshStrategy.getTimeoutMilliseconds(); var getConfigInput = GetConfigInput.builder() .context(Map.of()) @@ -110,7 +111,7 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op if (options.experimentationOptions != null) { this.experimentationTimeout = - options.experimentationOptions.refreshStrategy.getTimeout(); + options.experimentationOptions.refreshStrategy.getTimeoutMilliseconds(); var listExpInput = ListExperimentInput.builder() .orgId(options.orgId) .workspaceId(options.workspaceId) @@ -328,7 +329,7 @@ private Map evaluateConfigInternal(EvaluationContext ctx) throws throw new Exception("Experiments cache not initialized within timeout (" + experimentationTimeout + "ms)."); } } - var ctx_ = defaultCtx.isPresent() ? ctx.merge(defaultCtx.get()) : ctx; + var ctx_ = defaultCtx.isPresent() ? defaultCtx.get().merge(ctx) : ctx; var queryData = EvaluationArgs.Companion.buildQueryData(ctx_); String targetingKey = ctx_.getTargetingKey(); return cache.evalConfig(queryData, MergeStrategy.MERGE, null, targetingKey); @@ -336,8 +337,8 @@ private Map evaluateConfigInternal(EvaluationContext ctx) throws private List getApplicableVariantsInternal(EvaluationContext ctx) throws Exception { EvaluationArgs args = getEvaluationArgs(ctx); - var ctx_ = defaultCtx.isPresent() ? ctx.merge(defaultCtx.get()) : ctx; - return args.getApplicableVariants(ctx_, getExperimentationArgs(ctx_)); + var ctx_ = defaultCtx.isPresent() ? defaultCtx.get().merge(ctx) : ctx; + return args.getApplicableVariants(ctx_, Objects.requireNonNull(getExperimentationArgs(ctx_))); } private ExperimentationArgs getExperimentationArgs(EvaluationContext ctx) { diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionProviderOptions.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionProviderOptions.java index a6c1f8aed..1caed5061 100644 --- a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionProviderOptions.java +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionProviderOptions.java @@ -1,6 +1,5 @@ package io.juspay.superposition.openfeature; -import io.juspay.superposition.openfeature.options.EvaluationCacheOptions; import io.juspay.superposition.openfeature.options.RefreshStrategy; import lombok.Builder; import lombok.Data; @@ -52,8 +51,5 @@ public static class ExperimentationOptions { /** Refresh strategy for experimentation data. */ @NonNull RefreshStrategy refreshStrategy; - /** Evaluation cache options for experimentation data (optional). */ - @Nullable - EvaluationCacheOptions evaluationCacheOptions; } } diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/FileDataSource.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/FileDataSource.java new file mode 100644 index 000000000..6d2e6f5c1 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/FileDataSource.java @@ -0,0 +1,252 @@ +package io.juspay.superposition.openfeature.data_source; + +import io.juspay.superposition.openfeature.FfiUtils; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uniffi.superposition_client.OperationException; +import uniffi.superposition_types.Config; + +/** + * File-based implementation of SuperpositionDataSource. + * + * Reads configuration from a local JSON or TOML file, picking the parser from the file + * extension. Always returns fresh data — {@code ifModifiedSince} is accepted but ignored. + * Context and prefix filtering are applied by the core via + * {@link FfiUtils#parseConfigFileWithFilters}, so they prune contexts and overrides exactly + * the way the server would. + * + * Supports file watching via {@link java.nio.file.WatchService}. Each call to {@link #watch()} + * returns its own {@link WatchStream}; a single daemon thread fans every change to the watched + * file out to all of them. + * + * Does NOT support experiments. + */ +public class FileDataSource implements SuperpositionDataSource { + + private static final Logger log = LoggerFactory.getLogger(FileDataSource.class); + + private static final String JSON = "json"; + private static final String TOML = "toml"; + + private final Path filePath; + private final String fileFormat; + + private final Object watcherLock = new Object(); + private final CopyOnWriteArrayList subscribers = new CopyOnWriteArrayList<>(); + private WatchService watchService = null; + + /** + * Create a new file-based data source. + * + * @param filePath path to the configuration file; the extension must be {@code .json} or {@code .toml} + * @throws SuperpositionError if the extension is missing or unsupported + */ + public FileDataSource(Path filePath) throws SuperpositionError { + this.filePath = filePath; + this.fileFormat = formatOf(filePath); + log.debug("FileDataSource initialized for {} file: {}", fileFormat, filePath); + } + + private static String formatOf(Path filePath) throws SuperpositionError { + String name = filePath.getFileName().toString(); + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + throw SuperpositionError.dataSourceError( + "File path must have an extension to determine format: " + filePath); + } + String extension = name.substring(dot + 1).toLowerCase(Locale.ROOT); + return switch (extension) { + case JSON -> JSON; + case TOML -> TOML; + default -> throw SuperpositionError.dataSourceError( + "Unsupported file extension '" + extension + "'. Supported formats are 'json' and 'toml'."); + }; + } + + @Override + public FetchResponse fetchFilteredConfig( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError { + if (ifModifiedSince.isPresent()) { + log.debug("FileDataSource: ignoring ifModifiedSince, always reading fresh from file"); + } + Instant now = Instant.now(); + + String content; + try { + content = Files.readString(filePath); + } catch (IOException e) { + throw SuperpositionError.dataSourceError( + "Failed to read config file " + filePath + ": " + e.getMessage(), e); + } + + Config config; + try { + config = FfiUtils.parseConfigFileWithFilters( + content, fileFormat, context.orElse(null), prefixFilter.orElse(null)); + } catch (OperationException e) { + throw SuperpositionError.dataSourceError( + "Failed to parse " + fileFormat.toUpperCase(Locale.ROOT) + " config from " + + filePath + ": " + e.getMessage(), e); + } + + return FetchResponse.data(new ConfigData(config, now)); + } + + @Override + public FetchResponse fetchActiveExperiments( + Optional ifModifiedSince) throws SuperpositionError { + throw SuperpositionError.dataSourceError("Experiments not supported by FileDataSource"); + } + + @Override + public FetchResponse fetchCandidateActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + throw SuperpositionError.dataSourceError("Experiments not supported by FileDataSource"); + } + + @Override + public FetchResponse fetchMatchingActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + throw SuperpositionError.dataSourceError("Experiments not supported by FileDataSource"); + } + + @Override + public boolean supportsExperiments() { + return false; + } + + /** + * Set up file watching. Each caller gets its own {@link WatchStream}; one daemon thread + * fans OS events for the target file out to every live stream. + */ + @Override + public Optional watch() throws SuperpositionError { + synchronized (watcherLock) { + WatchStream stream = new WatchStream(); + subscribers.add(stream); + + if (watchService != null) { + log.debug("FileDataSource: added subscriber to existing watcher for {}", filePath); + return Optional.of(stream); + } + + Path dir = filePath.toAbsolutePath().getParent(); + if (dir == null) { + subscribers.remove(stream); + throw SuperpositionError.dataSourceError( + "Cannot watch file with no parent directory: " + filePath); + } + + WatchService service = null; + try { + service = dir.getFileSystem().newWatchService(); + dir.register( + service, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE); + } catch (Exception e) { + closeQuietly(service); + subscribers.remove(stream); + throw SuperpositionError.dataSourceError( + "Failed to set up file watcher for " + filePath + ": " + e.getMessage(), e); + } + + // Only reachable when registration succeeded — the catch above always throws. The + // field must not be published before that: a half-registered watcher would leave + // watchService non-null with no thread running (it starts below), so the next watch() + // would take the "existing watcher" branch and hand back a stream that never fires. + watchService = service; + + Thread watchThread = getWatchThread(); + watchThread.setDaemon(true); + watchThread.start(); + + return Optional.of(stream); + } + } + + private @NotNull Thread getWatchThread() { + Path watchedFileName = filePath.getFileName(); + WatchService ws = watchService; + + return new Thread(() -> { + log.debug("FileDataSource: watch thread started for {}", filePath); + while (!Thread.currentThread().isInterrupted()) { + WatchKey key; + try { + key = ws.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (java.nio.file.ClosedWatchServiceException e) { + log.debug("FileDataSource: watch service closed for {}", filePath); + break; + } + + for (WatchEvent event : key.pollEvents()) { + // Editors and config deploys often save via atomic rename, which surfaces + // as CREATE (or OVERFLOW) rather than MODIFY — all of them mean "re-read". + Object ctx = event.context(); + if (!(ctx instanceof Path) || watchedFileName.equals(ctx)) { + log.debug("FileDataSource: change detected in {}", filePath); + subscribers.forEach(WatchStream::notifyChange); + } + } + + if (!key.reset()) { + log.warn("FileDataSource: watch key no longer valid for {}", filePath); + break; + } + } + log.debug("FileDataSource: watch thread exiting for {}", filePath); + }, "superposition-file-watcher-" + filePath.getFileName()); + } + + @Override + public void close() { + synchronized (watcherLock) { + // Closing unblocks the watch thread with ClosedWatchServiceException. + closeQuietly(watchService); + watchService = null; + subscribers.forEach(WatchStream::close); + subscribers.clear(); + } + log.debug("FileDataSource closed for {}", filePath); + } + + /** Releases the watcher's file descriptor. Failing to close it is not worth failing over. */ + private static void closeQuietly(WatchService service) { + if (service == null) { + return; + } + try { + service.close(); + } catch (IOException e) { + log.warn("FileDataSource: error closing watch service: {}", e.getMessage()); + } + } +} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/HttpDataSource.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/HttpDataSource.java new file mode 100644 index 000000000..66dbfeb15 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/HttpDataSource.java @@ -0,0 +1,261 @@ +package io.juspay.superposition.openfeature.data_source; + +import io.juspay.superposition.client.SuperpositionAsyncClient; +import io.juspay.superposition.client.auth.BearerTokenIdentityResolver; +import io.juspay.superposition.model.DimensionMatchStrategy; +import io.juspay.superposition.model.GetConfigInput; +import io.juspay.superposition.model.GetConfigOutput; +import io.juspay.superposition.model.GetExperimentConfigInput; +import io.juspay.superposition.model.GetExperimentConfigOutput; +import io.juspay.superposition.openfeature.FfiUtils; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import io.juspay.superposition.openfeature.options.SuperpositionOptions; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import software.amazon.smithy.java.client.core.interceptors.ClientInterceptor; +import software.amazon.smithy.java.client.core.interceptors.RequestHook; +import software.amazon.smithy.java.client.core.interceptors.ResponseHook; +import software.amazon.smithy.java.core.error.CallException; +import software.amazon.smithy.java.core.error.ErrorFault; +import software.amazon.smithy.java.http.api.HttpRequest; +import software.amazon.smithy.java.http.api.HttpResponse; +import software.amazon.smithy.java.retries.api.RetrySafety; + +/** + * HTTP-based implementation of SuperpositionDataSource. + * + * Fetches configuration and experiment data from the Superposition HTTP API + * using the Smithy-generated SDK client. + * + * Supports: + * - Conditional fetching via if-modified-since timestamps (HTTP 304) + * - Context and prefix filtering + * - Exact and subset dimension matching strategies for experiments + */ +public class HttpDataSource implements SuperpositionDataSource { + + private static final Logger log = LoggerFactory.getLogger(HttpDataSource.class); + + private final SuperpositionOptions options; + private final SuperpositionAsyncClient sdk; + + /** + * Create a new HTTP data source. + * + * @param options connection options (endpoint, token, org_id, workspace_id) + */ + public HttpDataSource(SuperpositionOptions options) { + options.validate(); + this.options = options; + + this.sdk = SuperpositionAsyncClient.builder() + .endpointResolver(EndpointResolver.staticEndpoint(options.getEndpoint())) + .addIdentityResolver(new BearerTokenIdentityResolver(options.getToken())) + .addInterceptor(new NotModifiedInterceptor()) + .addInterceptor(new PrefixQueryInterceptor()) + .build(); + + log.debug("HttpDataSource initialized for endpoint: {}", options.getEndpoint()); + } + + @Override + public FetchResponse fetchFilteredConfig( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError { + log.debug("Fetching config from Superposition service using SDK"); + + GetConfigInput.Builder inputBuilder = GetConfigInput.builder() + .orgId(options.getOrgId()) + .workspaceId(options.getWorkspaceId()); + + context.filter(c -> !c.isEmpty()) + .ifPresent(c -> inputBuilder.context(FfiUtils.contextToDocuments(c))); + prefixFilter.filter(p -> !p.isEmpty()).ifPresent(inputBuilder::prefix); + ifModifiedSince.ifPresent(inputBuilder::ifModifiedSince); + + try { + // get(), not join(): join() ignores interrupts, so a refresh past its timeout could + // not be cancelled and would wedge the single refresh worker behind a dead request. + GetConfigOutput output = sdk.getConfig(inputBuilder.build()).get(); + ConfigData configData = + new ConfigData(FfiUtils.fromGetConfigOutput(output), output.lastModified()); + log.debug("Successfully fetched {}", configData); + return FetchResponse.data(configData); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw SuperpositionError.networkError("Interrupted while fetching config", e); + } catch (Exception e) { + if (isNotModified(e)) { + return FetchResponse.notModified(); + } + throw SuperpositionError.networkError("Failed to fetch config: " + e.getMessage(), e); + } + } + + @Override + public FetchResponse fetchActiveExperiments(Optional ifModifiedSince) + throws SuperpositionError { + return fetchExperimentsWithFilters( + Optional.empty(), Optional.empty(), ifModifiedSince, Optional.empty()); + } + + @Override + public FetchResponse fetchCandidateActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError { + return fetchExperimentsWithFilters( + context, prefixFilter, ifModifiedSince, Optional.of(DimensionMatchStrategy.EXACT)); + } + + @Override + public FetchResponse fetchMatchingActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError { + return fetchExperimentsWithFilters( + context, prefixFilter, ifModifiedSince, Optional.of(DimensionMatchStrategy.SUBSET)); + } + + @Override + public boolean supportsExperiments() { + return true; + } + + @Override + public void close() { + // SDK client doesn't require explicit close in the async variant + log.debug("HttpDataSource closed"); + } + + // ============= Private helpers ============= + + private FetchResponse fetchExperimentsWithFilters( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince, + Optional matchStrategy) + throws SuperpositionError { + log.debug("Fetching experiments from Superposition service using SDK"); + + GetExperimentConfigInput.Builder inputBuilder = GetExperimentConfigInput.builder() + .orgId(options.getOrgId()) + .workspaceId(options.getWorkspaceId()); + + context.filter(c -> !c.isEmpty()) + .ifPresent(c -> inputBuilder.context(FfiUtils.contextToDocuments(c))); + prefixFilter.filter(p -> !p.isEmpty()).ifPresent(inputBuilder::prefix); + ifModifiedSince.ifPresent(inputBuilder::ifModifiedSince); + matchStrategy.ifPresent(inputBuilder::dimensionMatchStrategy); + + try { + // get(), not join() — see fetchFilteredConfig. + GetExperimentConfigOutput output = sdk.getExperimentConfig(inputBuilder.build()).get(); + ExperimentData experimentData = new ExperimentData( + FfiUtils.fromGetExperimentConfigOutput(output), output.lastModified()); + log.debug("Successfully fetched {}", experimentData); + return FetchResponse.data(experimentData); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw SuperpositionError.networkError("Interrupted while fetching experiments", e); + } catch (Exception e) { + if (isNotModified(e)) { + return FetchResponse.notModified(); + } + throw SuperpositionError.networkError( + "Failed to list experiments: " + e.getMessage(), e); + } + } + + /** + * 304 surfaces as {@link NotModifiedException} thrown by {@link NotModifiedInterceptor}; + * {@code CompletableFuture} wraps it in an {@code ExecutionException}, so walk the cause chain. + */ + private static boolean isNotModified(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof NotModifiedException) { + return true; + } + if (t.getCause() == t) { + break; + } + } + return false; + } + + /** + * Signals an HTTP 304. Extends {@link CallException} so the client pipeline treats it as a + * terminal client-side outcome rather than something to retry. + */ + private static final class NotModifiedException extends CallException { + NotModifiedException() { + super("Not modified", ErrorFault.CLIENT); + isRetrySafe(RetrySafety.NO); + } + } + + /** + * The Smithy Java SDK has no modeled shape for 304, so the response would otherwise surface as + * a generic error. Inspecting the raw status is the only way to tell a real failure from + * "your cached copy is still current". + */ + private static final class NotModifiedInterceptor implements ClientInterceptor { + @Override + public void readAfterTransmit(ResponseHook hook) { + if (hook.response() instanceof HttpResponse response && response.statusCode() == 304) { + throw new NotModifiedException(); + } + } + } + + /** + * The generated SDK never serializes list-typed {@code @httpQuery} members, so a {@code prefix} + * set on the input silently never reaches the server and the response comes back unfiltered. + * Until that is fixed in the SDK, put it on the wire here — comma-joined, which is how the + * service parses it. + */ + private static final class PrefixQueryInterceptor implements ClientInterceptor { + @Override + public RequestT modifyBeforeTransmit(RequestHook hook) { + List prefix = prefixOf(hook.input()); + if (prefix.isEmpty()) { + return hook.request(); + } + return hook.mapRequest(HttpRequest.class, h -> { + URI uri = h.request().uri(); + String param = "prefix=" + + URLEncoder.encode(String.join(",", prefix), StandardCharsets.UTF_8); + String query = uri.getRawQuery() == null ? param : uri.getRawQuery() + "&" + param; + return h.request().toBuilder().uri(withQuery(uri, query)).build(); + }); + } + + private static List prefixOf(Object input) { + if (input instanceof GetConfigInput config) { + return config.prefix(); + } + if (input instanceof GetExperimentConfigInput experiments) { + return experiments.prefix(); + } + return List.of(); + } + + private static URI withQuery(URI uri, String rawQuery) { + String rebuilt = uri.getScheme() + "://" + uri.getRawAuthority() + + uri.getRawPath() + '?' + rawQuery; + return URI.create(rebuilt); + } + } +} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/SuperpositionDataSource.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/SuperpositionDataSource.java new file mode 100644 index 000000000..7ee0c41e2 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/data_source/SuperpositionDataSource.java @@ -0,0 +1,126 @@ +package io.juspay.superposition.openfeature.data_source; + +import io.juspay.superposition.openfeature.error.SuperpositionError; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Interface for abstracting data sources that provide configuration and experiment data. + * Different implementations can fetch from various backends: + * - HTTP API (HttpDataSource) + * - Local JSON/TOML files (FileDataSource) + * - Redis (future) + * - Database (future) + * All methods support conditional fetching via {@code ifModifiedSince} timestamps, + * enabling efficient caching using HTTP 304 Not Modified responses. + * + *

Context maps carry JSON-encoded values (e.g. {@code "42"}, {@code "\"android\""}), + * matching the encoding used across the FFI boundary, so numbers and booleans survive the + * round trip instead of degrading to strings. + */ +public interface SuperpositionDataSource { + + /** + * Fetch the full resolved configuration from the data source. + * + * @param ifModifiedSince if provided, only return data if modified since this timestamp + * @return FetchResponse containing ConfigData or NotModified + * @throws SuperpositionError if the fetch fails + */ + default FetchResponse fetchConfig(Optional ifModifiedSince) + throws SuperpositionError { + return fetchFilteredConfig(Optional.empty(), Optional.empty(), ifModifiedSince); + } + + /** + * Fetch configuration filtered by context and key prefixes. + * + * @param context optional dimension values (JSON-encoded) to filter contexts by + * @param prefixFilter optional list of key prefixes (only keys starting with these are returned) + * @param ifModifiedSince optional timestamp for conditional fetching + * @return FetchResponse containing filtered ConfigData or NotModified + * @throws SuperpositionError if the fetch fails + */ + FetchResponse fetchFilteredConfig( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError; + + /** + * Fetch all active experiments. + * + * @param ifModifiedSince optional timestamp for conditional fetching + * @return FetchResponse containing ExperimentData or NotModified + * @throws SuperpositionError if the fetch fails + */ + FetchResponse fetchActiveExperiments(Optional ifModifiedSince) + throws SuperpositionError; + + /** + * Fetch active experiments whose conditions are candidate matches for the given context. + * + * Uses exact dimension matching strategy: experiment condition must exactly match + * dimension values in the context. + * + * @param context optional dimension values (JSON-encoded) to match against + * @param prefixFilter optional list of key prefixes to filter experiments + * @param ifModifiedSince optional timestamp for conditional fetching + * @return FetchResponse containing filtered ExperimentData or NotModified + * @throws SuperpositionError if the fetch fails + */ + FetchResponse fetchCandidateActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError; + + /** + * Fetch active experiments that match the given context. + * + * Uses subset dimension matching strategy: context dimensions must be a subset of + * experiment condition dimensions. + * + * @param context optional dimension values (JSON-encoded) to match against + * @param prefixFilter optional list of key prefixes to filter experiments + * @param ifModifiedSince optional timestamp for conditional fetching + * @return FetchResponse containing filtered ExperimentData or NotModified + * @throws SuperpositionError if the fetch fails + */ + FetchResponse fetchMatchingActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) + throws SuperpositionError; + + /** + * Check if this data source supports experiments. + * + * @return true if experiments are supported, false otherwise + */ + boolean supportsExperiments(); + + /** + * Set up file watching for changes and return a stream of notifications. + * + * Only data sources that support watching (e.g., FileDataSource) will return + * a non-empty Optional. The default implementation returns empty (no watching). + * + * @return Optional containing a WatchStream if watching is supported, empty otherwise + * @throws SuperpositionError if watching setup fails + */ + default Optional watch() throws SuperpositionError { + return Optional.empty(); + } + + /** + * Clean up resources held by this data source. + * + * Called when the provider is shutting down or switching data sources. + * + * @throws SuperpositionError if cleanup fails + */ + void close() throws SuperpositionError; +} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/options/EvaluationCacheOptions.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/options/EvaluationCacheOptions.java deleted file mode 100644 index 8e5953490..000000000 --- a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/options/EvaluationCacheOptions.java +++ /dev/null @@ -1,6 +0,0 @@ -package io.juspay.superposition.openfeature.options; - -public class EvaluationCacheOptions { - int ttl; - int size; -} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/options/RefreshStrategy.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/options/RefreshStrategy.java deleted file mode 100644 index 9b8e9f983..000000000 --- a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/options/RefreshStrategy.java +++ /dev/null @@ -1,84 +0,0 @@ -package io.juspay.superposition.openfeature.options; - -/** - * Strategy interface for controlling how feature flag data is refreshed from the backend. - * Implementations define the refresh mechanism and timeout behavior. - */ -public interface RefreshStrategy { - /** - * Returns the timeout for the refresh operation, in milliseconds. - */ - int getTimeout(); - - /** - * Refresh strategy that polls the backend at a fixed interval. - * Useful for keeping data up-to-date in near real-time. - */ - final class Polling implements RefreshStrategy { - /** Timeout for polling, in milliseconds. */ - public int timeout; - /** Polling interval, in milliseconds. */ - public int interval; - - /** - * Creates a polling refresh strategy. - * @param timeout Timeout in milliseconds - * @param interval Polling interval in milliseconds - */ - Polling(int timeout, int interval) { - this.timeout = timeout; - this.interval = interval; - } - - /** - * Factory method for creating a polling refresh strategy. - * @param timeout Timeout in milliseconds - * @param interval Polling interval in milliseconds - * @return a new Polling RefreshStrategy - */ - public static RefreshStrategy of(int timeout, int interval) { - return new Polling(timeout, interval); - } - - @Override - public int getTimeout() { - return timeout; - } - } - - /** - * Refresh strategy that fetches data on demand and caches it for a TTL (time-to-live). - * Useful for reducing backend calls and controlling staleness. - */ - final class OnDemand implements RefreshStrategy { - /** Timeout for on-demand fetch, in milliseconds. */ - public int timeout; - /** Time-to-live (TTL) for cached data, in milliseconds. */ - public int ttl; - - /** - * Creates an on-demand refresh strategy. - * @param timeout Timeout in milliseconds - * @param ttl Time-to-live for cached data in milliseconds - */ - OnDemand(int timeout, int ttl) { - this.timeout = timeout; - this.ttl = ttl; - } - - @Override - public int getTimeout() { - return timeout; - } - - /** - * Factory method for creating an on-demand refresh strategy. - * @param timeout Timeout in milliseconds - * @param ttl Time-to-live for cached data in milliseconds - * @return a new OnDemand RefreshStrategy - */ - public static RefreshStrategy of(int timeout, int ttl) { - return new OnDemand(timeout, ttl); - } - } -} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/provider/LocalResolutionProvider.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/provider/LocalResolutionProvider.java new file mode 100644 index 000000000..48eb0d92e --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/provider/LocalResolutionProvider.java @@ -0,0 +1,790 @@ +package io.juspay.superposition.openfeature.provider; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import io.juspay.superposition.openfeature.EvaluationArgs; +import io.juspay.superposition.openfeature.data_source.ConfigData; +import io.juspay.superposition.openfeature.data_source.ExperimentData; +import io.juspay.superposition.openfeature.data_source.FetchResponse; +import io.juspay.superposition.openfeature.data_source.SuperpositionDataSource; +import io.juspay.superposition.openfeature.data_source.WatchStream; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import io.juspay.superposition.openfeature.options.RefreshStrategy; +import io.juspay.superposition.openfeature.traits.AllFeatureProvider; +import io.juspay.superposition.openfeature.traits.FeatureExperimentMeta; +import java.lang.ref.WeakReference; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uniffi.superposition_client.OperationException; +import uniffi.superposition_client.ProviderCache; +import uniffi.superposition_types.Config; +import uniffi.superposition_types.MergeStrategy; + +/** + * Local resolution provider: resolves configurations in-process using cached data. + * + * Supports: + * - Multiple data sources (primary + fallback) + * - Multiple refresh strategies (Polling, OnDemand, Watch, Manual) + * - Configuration caching with last-known-good on refresh failure + * - Experiment variant tracking + * + * Cached config and experiments are held in a {@link ProviderCache}, so evaluation, filtering and + * variant resolution all run through the same core logic the service itself uses. + * + * Also implements {@link SuperpositionDataSource}, so other consumers can fetch filtered data + * from this provider's cache. + */ +public class LocalResolutionProvider extends EventProvider + implements AllFeatureProvider, FeatureExperimentMeta, SuperpositionDataSource { + + private static final Logger log = LoggerFactory.getLogger(LocalResolutionProvider.class); + + /** How often the watch thread wakes on its own to check that the provider is still reachable. */ + private static final long LIVENESS_CHECK_MS = 5_000; + + private final SuperpositionDataSource primaryDataSource; + private final Optional fallbackDataSource; + private final RefreshStrategy refreshStrategy; + + /** + * Native cache holding the resolved config and experiments. + * + *

Created once and reused: {@code initConfig}/{@code initExperiments} overwrite its contents + * wholesale.

+ */ + private final ProviderCache cache = new ProviderCache(); + + // Caches — updated atomically; null until first successful fetch + private final AtomicReference cachedConfigData = new AtomicReference<>(null); + private final AtomicReference cachedExperimentData = new AtomicReference<>(null); + + /** + * When each cache was last successfully checked against its source, by the local clock. + * + *

Deliberately not {@code ConfigData.getFetchedAt()}: for an HTTP source that is the + * server's last-modified — when the config last changed, not when we last + * looked. Driving the OnDemand TTL off it meant a config that had been stable for + * longer than the TTL was permanently "stale", so every evaluation fired a fetch; the 304 that + * came back left the timestamp untouched, so the next evaluation fired another. A perfectly + * stable config produced maximum load, which is the exact opposite of the point of OnDemand. + * + *

Advanced on every successful check, including a 304. + */ + private final AtomicReference configCheckedAt = new AtomicReference<>(null); + private final AtomicReference experimentsCheckedAt = new AtomicReference<>(null); + + private final AtomicReference state = new AtomicReference<>(ProviderState.NOT_READY); + private volatile EvaluationContext globalContext = new ImmutableContext(); + + // Background refresh + private final ScheduledExecutorService refreshExecutor = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "superposition-refresh"); + t.setDaemon(true); + return t; + }); + /** Runs the refresh itself, so a hung data source blocks a worker rather than the caller. */ + private final ExecutorService refreshWorker = + Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "superposition-refresh-worker"); + t.setDaemon(true); + return t; + }); + private volatile ScheduledFuture pollingTask = null; + private volatile Thread watchThread = null; + + /** + * Create a new local resolution provider. + * + * @param primaryDataSource primary data source for fetching config/experiments + * @param fallbackDataSource optional fallback if primary fails on init + * @param refreshStrategy strategy for updating data + */ + public LocalResolutionProvider( + SuperpositionDataSource primaryDataSource, + Optional fallbackDataSource, + RefreshStrategy refreshStrategy) { + this.primaryDataSource = primaryDataSource; + this.fallbackDataSource = fallbackDataSource; + this.refreshStrategy = refreshStrategy; + log.debug("LocalResolutionProvider created with strategy: {}", + refreshStrategy.getClass().getSimpleName()); + } + + // ========== FeatureProvider ========== + + @Override + public Metadata getMetadata() { + return () -> "LocalResolutionProvider"; + } + + @Override + public ProviderState getState() { + return state.get(); + } + + @Override + public void initialize(EvaluationContext context) throws Exception { + // Single-shot: a provider is initialized once and then served. A second initialize() on a + // live provider would strand the first strategy's still-running task and leak its native + // cache, so it is refused. A prior *failed* attempt (ERROR) may be retried — nothing is + // running after one, and this loop below starts clean. + ProviderState current = state.get(); + if (current == ProviderState.READY || current == ProviderState.STALE) { + log.warn("LocalResolutionProvider.initialize() called on an already-initialized " + + "provider; ignoring. Providers are single-shot — build a new instance."); + return; + } + + log.debug("Initializing LocalResolutionProvider..."); + state.set(ProviderState.NOT_READY); + + try { + loadInitialConfig(); + loadInitialExperiments(); + startRefreshStrategy(); + this.globalContext = context != null ? context : new ImmutableContext(); + state.set(ProviderState.READY); + } catch (Exception e) { + state.set(ProviderState.ERROR); + throw e; + } + + log.info("LocalResolutionProvider initialized successfully"); + } + + @Override + public void shutdown() { + log.debug("LocalResolutionProvider shutting down..."); + if (pollingTask != null) { + pollingTask.cancel(false); + pollingTask = null; + } + if (watchThread != null) { + watchThread.interrupt(); + watchThread = null; + } + refreshExecutor.shutdownNow(); + refreshWorker.shutdownNow(); + // shutdownNow() only interrupts. A refresh already inside fetchConfig() or cache.initConfig() + // keeps using primaryDataSource and cache, so give it a bounded chance to stop before those + // handles are closed below. Cleanup proceeds regardless if it overruns. + awaitQuietly(refreshWorker); + awaitQuietly(refreshExecutor); + + closeQuietly(primaryDataSource, "primary"); + fallbackDataSource.ifPresent(source -> closeQuietly(source, "fallback")); + + cachedConfigData.set(null); + cachedExperimentData.set(null); + configCheckedAt.set(null); + experimentsCheckedAt.set(null); + globalContext = new ImmutableContext(); + cache.close(); + state.set(ProviderState.NOT_READY); + // Detaches the SDK's event listener; without it a shut-down provider keeps a reference. + super.shutdown(); + log.info("LocalResolutionProvider shut down"); + } + + // ========== Typed evaluations ========== + // Delegated to AllFeatureProvider, which parses the JSON-encoded values and reports + // FLAG_NOT_FOUND / TYPE_MISMATCH the way the OpenFeature spec expects. + + @Override + public ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext context) { + return resolveBool(key, context); + } + + @Override + public ProviderEvaluation getStringEvaluation( + String key, String defaultValue, EvaluationContext context) { + return resolveString(key, context); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext context) { + return resolveInt(key, context); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + String key, Double defaultValue, EvaluationContext context) { + return resolveFloat(key, context); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + String key, Value defaultValue, EvaluationContext context) { + return resolveStruct(key, context); + } + + // ========== AllFeatureProvider ========== + + @Override + public Map resolveAllFeaturesWithFilter( + EvaluationContext context, + Optional> prefixFilter) throws SuperpositionError { + ensureFreshData(); + + if (cachedConfigData.get() == null) { + // Evaluating before a successful init, or after one that failed. Not a config error: + // the config is not malformed, the provider is unusable. + throw SuperpositionError.providerError( + "Provider not initialized: no cached config available"); + } + + EvaluationContext merged = mergeWithGlobal(context); + try { + return cache.evalConfig( + EvaluationArgs.buildQueryData(merged), + MergeStrategy.MERGE, + prefixFilter.orElse(null), + merged.getTargetingKey()); + } catch (OperationException e) { + throw SuperpositionError.configError("Failed to evaluate config: " + e.getMessage(), e); + } + } + + // ========== FeatureExperimentMeta ========== + + @Override + public List getApplicableVariants( + EvaluationContext context, + Optional> prefixFilter) throws SuperpositionError { + ensureFreshData(); + + if (cachedExperimentData.get() == null) { + return Collections.emptyList(); + } + + EvaluationContext merged = mergeWithGlobal(context); + String targetingKey = merged.getTargetingKey() != null ? merged.getTargetingKey() : ""; + try { + return cache.getApplicableVariants( + EvaluationArgs.buildQueryData(merged), prefixFilter.orElse(null), targetingKey); + } catch (OperationException e) { + throw SuperpositionError.configError( + "Failed to resolve applicable variants: " + e.getMessage(), e); + } + } + + // ========== SuperpositionDataSource (serve the cache to nested consumers) ========== + + @Override + public FetchResponse fetchFilteredConfig( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + if (ifModifiedSince.isPresent()) { + log.debug("LocalResolutionProvider: ignoring ifModifiedSince for config, always returning cached data"); + } + + ConfigData cached = cachedConfigData.get(); + if (cached == null) { + throw SuperpositionError.dataSourceError("No cached config available"); + } + + try { + Config filtered = cache.filterConfig(context.orElse(null), prefixFilter.orElse(null)); + return FetchResponse.data(new ConfigData(filtered, cached.getFetchedAt())); + } catch (OperationException e) { + throw SuperpositionError.dataSourceError("Failed to filter config: " + e.getMessage(), e); + } + } + + @Override + public FetchResponse fetchActiveExperiments(Optional ifModifiedSince) + throws SuperpositionError { + requireExperimentSupport(); + if (ifModifiedSince.isPresent()) { + log.debug("LocalResolutionProvider: ignoring ifModifiedSince for experiments, always returning cached data"); + } + + ExperimentData cached = cachedExperimentData.get(); + if (cached == null) { + throw SuperpositionError.dataSourceError("No cached experiments available"); + } + return FetchResponse.data(cached); + } + + @Override + public FetchResponse fetchCandidateActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + return filterCachedExperiments(context, prefixFilter, ifModifiedSince, false); + } + + @Override + public FetchResponse fetchMatchingActiveExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + return filterCachedExperiments(context, prefixFilter, ifModifiedSince, true); + } + + @Override + public boolean supportsExperiments() { + return primaryDataSource.supportsExperiments(); + } + + @Override + public Optional watch() { + return Optional.empty(); + } + + @Override + public void close() { + shutdown(); + } + + // ========== Refresh ========== + + /** + * Refresh config and experiments from the primary source, keeping the last known good data + * on failure. Drives the Manual strategy; Polling, Watch and OnDemand call it internally. + * + *

The refresh runs on a worker thread and is bounded by the refresh strategy's timeout, so a + * data source that hangs cannot stall the caller — an evaluation thread under OnDemand, or the + * poller — indefinitely. + * + * @throws SuperpositionError if the refresh timed out, the config refresh failed, or only the + * experiment refresh did + */ + public void refresh() throws SuperpositionError { + int timeoutMs = refreshStrategy.getTimeoutMilliseconds(); + Future pending = refreshWorker.submit(this::refreshOnce); + boolean succeeded = false; + + try { + SuperpositionError error = + timeoutMs > 0 ? pending.get(timeoutMs, TimeUnit.MILLISECONDS) : pending.get(); + if (error != null) { + throw error; + } + succeeded = true; + } catch (TimeoutException e) { + pending.cancel(true); + throw SuperpositionError.refreshError("Refresh timed out after " + timeoutMs + "ms"); + } catch (InterruptedException e) { + pending.cancel(true); + Thread.currentThread().interrupt(); + throw SuperpositionError.refreshError("Refresh interrupted", e); + } catch (ExecutionException e) { + // A task that dies without a cause still has to produce a usable error. + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw SuperpositionError.refreshError("Refresh failed: " + cause.getMessage(), cause); + } finally { + // Every refresh path — polling, watch, on-demand and manual — funnels through here. + recordRefreshOutcome(succeeded); + } + } + + /** + * A refresh that fails while the cache is still served leaves the provider STALE: the flags are + * frozen at their last known good values, and this is the only signal a consumer has that they + * stopped tracking the source of truth. The next successful refresh clears it. + * + *

Only meaningful from READY. A failure during init is an ERROR — there is no good data to + * be stale — and a provider that has been shut down stays NOT_READY. + * + *

The event matters as much as the field. {@code FeatureProviderStateManager} keeps the + * SDK's own copy of the provider state and never calls {@link #getState()}, so without emitting + * here, nothing reached through an OpenFeature client — {@code client.getProviderState()}, an + * {@code onProviderStale} handler — would ever see this. Evaluation is unaffected either way: + * the client only short-circuits on NOT_READY and FATAL. + */ + private void recordRefreshOutcome(boolean succeeded) { + if (succeeded) { + if (state.compareAndSet(ProviderState.STALE, ProviderState.READY)) { + log.info("LocalResolutionProvider: refresh recovered, no longer stale"); + emitProviderReady(ProviderEventDetails.builder() + .message("Refresh recovered; flags are current again") + .build()); + } + } else if (state.compareAndSet(ProviderState.READY, ProviderState.STALE)) { + log.warn("LocalResolutionProvider: refresh failed, serving stale data"); + emitProviderStale(ProviderEventDetails.builder() + .message("Refresh failed; serving the last known good config") + .build()); + } + } + + /** @return the error that made the refresh fail, or null if config and experiments both refreshed */ + private SuperpositionError refreshOnce() { + SuperpositionError configError = refreshConfig(); + SuperpositionError experimentError = refreshExperiments(); + + if (configError != null) { + return configError; + } + return experimentError; + } + + // ========== Private: init helpers ========== + + private void loadInitialConfig() throws SuperpositionError { + ConfigData configData; + try { + configData = fetchConfigFrom(primaryDataSource); + log.info("LocalResolutionProvider: fetched config from primary source"); + } catch (SuperpositionError primaryError) { + log.warn("LocalResolutionProvider: primary config fetch failed: {}", primaryError.getMessage()); + SuperpositionDataSource fallback = fallbackDataSource.orElseThrow(() -> + SuperpositionError.configError( + "Primary config fetch failed and no fallback configured: " + + primaryError.getMessage(), primaryError)); + try { + configData = fetchConfigFrom(fallback); + log.info("LocalResolutionProvider: fetched config from fallback source"); + } catch (SuperpositionError fallbackError) { + throw SuperpositionError.configError( + "Both primary and fallback config fetch failed. Primary: " + + primaryError.getMessage() + ". Fallback: " + fallbackError.getMessage(), + fallbackError); + } + } + + cacheConfig(configData); + } + + private static ConfigData fetchConfigFrom(SuperpositionDataSource source) throws SuperpositionError { + return source.fetchConfig(Optional.empty()) + .getData() + .orElseThrow(() -> SuperpositionError.configError( + "Data source returned no config on initial fetch")); + } + + /** + * Mirrors the Rust provider: experiments come from the primary, falling back to the fallback + * source. A source that does not support experiments simply yields none. + */ + private void loadInitialExperiments() throws SuperpositionError { + if (!primaryDataSource.supportsExperiments()) { + return; + } + + ExperimentData experimentData; + try { + experimentData = primaryDataSource.fetchActiveExperiments(Optional.empty()) + .getData() + .orElse(null); + } catch (SuperpositionError primaryError) { + log.warn("LocalResolutionProvider: primary experiment fetch failed: {}", + primaryError.getMessage()); + SuperpositionDataSource fallback = fallbackDataSource.orElseThrow(() -> + SuperpositionError.configError( + "Primary experiment fetch failed and no fallback configured: " + + primaryError.getMessage(), primaryError)); + if (!fallback.supportsExperiments()) { + log.warn("LocalResolutionProvider: fallback does not support experiments"); + return; + } + try { + experimentData = fallback.fetchActiveExperiments(Optional.empty()) + .getData() + .orElse(null); + } catch (SuperpositionError fallbackError) { + throw SuperpositionError.configError( + "Both primary and fallback experiment fetch failed. Primary: " + + primaryError.getMessage() + ". Fallback: " + fallbackError.getMessage(), + fallbackError); + } + } + + if (experimentData != null) { + cacheExperiments(experimentData); + } + } + + private void cacheConfig(ConfigData configData) throws SuperpositionError { + Config config = configData.getData(); + try { + cache.initConfig( + config.getDefaultConfigs(), + config.getContexts(), + config.getOverrides(), + config.getDimensions()); + } catch (OperationException e) { + throw SuperpositionError.configError( + "Failed to initialize config cache: " + e.getMessage(), e); + } + cachedConfigData.set(configData); + configCheckedAt.set(Instant.now()); + log.debug("Config cached: {}", configData); + } + + private void cacheExperiments(ExperimentData experimentData) throws SuperpositionError { + try { + cache.initExperiments( + experimentData.getData().getExperiments(), + experimentData.getData().getExperimentGroups()); + } catch (OperationException e) { + throw SuperpositionError.configError( + "Failed to initialize experiments cache: " + e.getMessage(), e); + } + cachedExperimentData.set(experimentData); + experimentsCheckedAt.set(Instant.now()); + log.debug("Experiments cached: {}", experimentData); + } + + // ========== Private: refresh strategies ========== + + private void startRefreshStrategy() throws SuperpositionError { + if (refreshStrategy instanceof RefreshStrategy.Polling polling) { + startPolling(polling); + } else if (refreshStrategy instanceof RefreshStrategy.Watch watch) { + startWatching(watch); + } else if (refreshStrategy instanceof RefreshStrategy.OnDemand onDemand) { + log.info("LocalResolutionProvider: using OnDemand strategy with ttl={}ms", onDemand.getTtlMilliseconds()); + } else { + log.info("LocalResolutionProvider: using Manual refresh strategy"); + } + } + + /** + * The refresh loops must not hold the provider alive. A caller that drops the provider without + * calling {@link #shutdown()} would otherwise leak it — and its native cache — for the life of + * the process, since the executor's queued task and the watch thread are both GC roots. They + * take a weak reference instead and stop once the provider is gone. + */ + private void startPolling(RefreshStrategy.Polling polling) { + log.info("LocalResolutionProvider: starting polling with interval={}ms", polling.getIntervalMilliseconds()); + + WeakReference self = new WeakReference<>(this); + ScheduledExecutorService scheduler = refreshExecutor; + ExecutorService worker = refreshWorker; + + pollingTask = scheduler.scheduleWithFixedDelay( + () -> { + LocalResolutionProvider provider = self.get(); + if (provider == null) { + log.info("LocalResolutionProvider was garbage collected, stopping polling"); + scheduler.shutdown(); + worker.shutdown(); + return; + } + provider.refreshQuietly(); + }, + polling.getIntervalMilliseconds(), polling.getIntervalMilliseconds(), TimeUnit.MILLISECONDS); + } + + private void startWatching(RefreshStrategy.Watch watch) throws SuperpositionError { + WatchStream stream = primaryDataSource.watch().orElseThrow(() -> + SuperpositionError.configError( + "Watch strategy selected but data source does not support watching")); + + log.info("LocalResolutionProvider: starting watch with debounce={}ms", watch.getDebounceMs()); + + WeakReference self = new WeakReference<>(this); + int debounceMs = watch.getDebounceMs(); + + watchThread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + // Wait for a change, but wake periodically even when nothing happens, so a + // dropped provider can be noticed instead of being pinned by this thread. + if (!stream.tryGetNextEvent(LIVENESS_CHECK_MS)) { + if (stream.isClosed()) { + log.debug("LocalResolutionProvider: watch stream closed"); + break; + } + if (self.get() == null) { + log.info("LocalResolutionProvider was garbage collected, stopping watch"); + break; + } + continue; + } + + // Debounce: a burst of changes (an editor's write-then-rename, a bulk deploy) + // must cost one refresh, so keep extending the window while events keep landing. + while (stream.tryGetNextEvent(debounceMs)) { + log.debug("LocalResolutionProvider: change during debounce, extending window"); + } + + LocalResolutionProvider provider = self.get(); + if (provider == null) { + log.info("LocalResolutionProvider was garbage collected, stopping watch"); + break; + } + provider.refreshQuietly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }, "superposition-watcher"); + watchThread.setDaemon(true); + watchThread.start(); + } + + /** Background refresh: a failure keeps the last known good data, so it is only logged. */ + private void refreshQuietly() { + try { + refresh(); + } catch (SuperpositionError e) { + log.warn("LocalResolutionProvider: background refresh failed: {}", e.getMessage()); + } + } + + /** @return the error if the refresh failed and the cached config was kept, else null */ + private SuperpositionError refreshConfig() { + ConfigData current = cachedConfigData.get(); + Optional since = Optional.ofNullable(current).map(ConfigData::getFetchedAt); + + try { + FetchResponse response = primaryDataSource.fetchConfig(since); + if (response.isNotModified()) { + // A 304 is a successful check: the cache is confirmed current, so the TTL clock + // restarts. Without this the next evaluation would ask again, immediately. + configCheckedAt.set(Instant.now()); + log.debug("LocalResolutionProvider: config not modified"); + return null; + } + cacheConfig(response.getData().orElseThrow()); + log.debug("LocalResolutionProvider: config refreshed from primary"); + return null; + } catch (SuperpositionError e) { + log.warn("LocalResolutionProvider: config refresh failed, keeping last known good: {}", + e.getMessage()); + return e; + } + } + + /** @return the error if the refresh failed and the cached experiments were kept, else null */ + private SuperpositionError refreshExperiments() { + if (!primaryDataSource.supportsExperiments()) { + return null; + } + + ExperimentData current = cachedExperimentData.get(); + Optional since = Optional.ofNullable(current).map(ExperimentData::getFetchedAt); + + try { + FetchResponse response = primaryDataSource.fetchActiveExperiments(since); + if (response.isNotModified()) { + // See refreshConfig: a 304 restarts the TTL clock. + experimentsCheckedAt.set(Instant.now()); + log.debug("LocalResolutionProvider: experiments not modified"); + return null; + } + cacheExperiments(response.getData().orElseThrow()); + log.debug("LocalResolutionProvider: experiments refreshed from primary"); + return null; + } catch (SuperpositionError e) { + log.warn("LocalResolutionProvider: experiment refresh failed, keeping last known good: {}", + e.getMessage()); + return e; + } + } + + /** For OnDemand: refresh synchronously once the cached data is older than the TTL. */ + private void ensureFreshData() throws SuperpositionError { + if (!(refreshStrategy instanceof RefreshStrategy.OnDemand onDemand)) { + return; + } + + Instant staleBefore = Instant.now().minusMillis(onDemand.getTtlMilliseconds()); + boolean configStale = isStale(configCheckedAt.get(), staleBefore); + boolean experimentsStale = supportsExperiments() + && isStale(experimentsCheckedAt.get(), staleBefore); + + if (!configStale && !experimentsStale) { + return; + } + + log.debug("LocalResolutionProvider: TTL expired, refreshing on-demand"); + try { + refresh(); + } catch (SuperpositionError e) { + if (!onDemand.getUseStaleOnError()) { + throw e; + } + log.warn("LocalResolutionProvider: on-demand refresh failed, using stale data: {}", + e.getMessage()); + } + } + + /** Never checked, or last checked before the TTL window opened. */ + private static boolean isStale(Instant checkedAt, Instant staleBefore) { + return checkedAt == null || checkedAt.isBefore(staleBefore); + } + + // ========== Private: helpers ========== + + /** Global context supplies defaults; the evaluation context wins on conflict. */ + private EvaluationContext mergeWithGlobal(EvaluationContext context) { + return context == null ? globalContext : globalContext.merge(context); + } + + private FetchResponse filterCachedExperiments( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince, + boolean partialApply) throws SuperpositionError { + ExperimentData cached = fetchActiveExperiments(ifModifiedSince).getData().orElseThrow(); + + try { + return FetchResponse.data(new ExperimentData( + cache.filterExperiment( + context.orElse(null), prefixFilter.orElse(null), partialApply), + cached.getFetchedAt())); + } catch (OperationException e) { + throw SuperpositionError.dataSourceError( + "Failed to filter experiments: " + e.getMessage(), e); + } + } + + private void requireExperimentSupport() throws SuperpositionError { + if (!supportsExperiments()) { + throw SuperpositionError.dataSourceError("Experiments not supported by this provider"); + } + } + + /** + * Wait a bounded time for an already-shutdown executor to finish its current task. Re-asserts + * the interrupt and returns if interrupted, so shutdown() always proceeds to release resources. + */ + private static void awaitQuietly(ExecutorService executor) { + try { + if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { + log.warn("LocalResolutionProvider: refresh executor did not stop within 2s; " + + "closing resources anyway"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void closeQuietly(SuperpositionDataSource source, String label) { + try { + source.close(); + } catch (Exception e) { + log.warn("LocalResolutionProvider: error closing {} source: {}", label, e.getMessage()); + } + } +} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/provider/SuperpositionAPIProvider.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/provider/SuperpositionAPIProvider.java new file mode 100644 index 000000000..143fe8321 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/provider/SuperpositionAPIProvider.java @@ -0,0 +1,199 @@ +package io.juspay.superposition.openfeature.provider; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import io.juspay.superposition.client.SuperpositionClient; +import io.juspay.superposition.client.auth.BearerTokenIdentityResolver; +import io.juspay.superposition.model.ApplicableVariantsInput; +import io.juspay.superposition.model.ApplicableVariantsOutput; +import io.juspay.superposition.model.GetResolvedConfigWithIdentifierInput; +import io.juspay.superposition.model.GetResolvedConfigWithIdentifierOutput; +import io.juspay.superposition.model.Variant; +import io.juspay.superposition.openfeature.EvaluationArgs; +import io.juspay.superposition.openfeature.FfiUtils; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import io.juspay.superposition.openfeature.options.SuperpositionOptions; +import io.juspay.superposition.openfeature.traits.AllFeatureProvider; +import io.juspay.superposition.openfeature.traits.FeatureExperimentMeta; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import software.amazon.smithy.java.core.serde.document.Document; + +/** + * Remote resolution provider: delegates all resolution to the Superposition API. + * + * Every evaluation is a server-side resolve — the service applies contexts, overrides and + * experiment variants and returns the finished config. Use this when: + * - Near real-time config updates are required (zero cache staleness) + * - Local storage is not desirable + * - The application can tolerate the HTTP round-trip latency + * + * Uses the Smithy-generated synchronous {@link SuperpositionClient}. + */ +public class SuperpositionAPIProvider implements FeatureProvider, AllFeatureProvider, FeatureExperimentMeta { + + private static final Logger log = LoggerFactory.getLogger(SuperpositionAPIProvider.class); + + private final SuperpositionOptions options; + private final SuperpositionClient sdk; + private final AtomicReference state = new AtomicReference<>(ProviderState.NOT_READY); + private volatile EvaluationContext globalContext = new ImmutableContext(); + + /** + * Create a new remote resolution provider. + * + * @param options connection options (endpoint, token, org_id, workspace_id) + */ + public SuperpositionAPIProvider(SuperpositionOptions options) { + options.validate(); + this.options = options; + this.sdk = SuperpositionClient.builder() + .endpointResolver(EndpointResolver.staticEndpoint(options.getEndpoint())) + .addIdentityResolver(new BearerTokenIdentityResolver(options.getToken())) + .build(); + log.debug("SuperpositionAPIProvider created for endpoint: {}", options.getEndpoint()); + } + + // ========== FeatureProvider ========== + + @Override + public Metadata getMetadata() { + return () -> "SuperpositionAPIProvider"; + } + + @Override + public ProviderState getState() { + return state.get(); + } + + @Override + public void initialize(EvaluationContext context) { + this.globalContext = context != null ? context : new ImmutableContext(); + state.set(ProviderState.READY); + log.info("SuperpositionAPIProvider initialized successfully"); + } + + @Override + public void shutdown() { + state.set(ProviderState.NOT_READY); + log.info("SuperpositionAPIProvider shut down"); + } + + // ========== Typed evaluations ========== + // Delegated to AllFeatureProvider, which parses the JSON-encoded values and reports + // FLAG_NOT_FOUND / TYPE_MISMATCH the way the OpenFeature spec expects. + + @Override + public ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext context) { + return resolveBool(key, context); + } + + @Override + public ProviderEvaluation getStringEvaluation( + String key, String defaultValue, EvaluationContext context) { + return resolveString(key, context); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext context) { + return resolveInt(key, context); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + String key, Double defaultValue, EvaluationContext context) { + return resolveFloat(key, context); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + String key, Value defaultValue, EvaluationContext context) { + return resolveStruct(key, context); + } + + // ========== AllFeatureProvider ========== + + @Override + public Map resolveAllFeaturesWithFilter( + EvaluationContext context, + Optional> prefixFilter) throws SuperpositionError { + EvaluationContext merged = mergeWithGlobal(context); + + GetResolvedConfigWithIdentifierInput.Builder inputBuilder = + GetResolvedConfigWithIdentifierInput.builder() + .workspaceId(options.getWorkspaceId()) + .orgId(options.getOrgId()) + .context(contextOf(merged)); + + Optional.ofNullable(merged.getTargetingKey()) + .filter(key -> !key.isEmpty()) + .ifPresent(inputBuilder::identifier); + prefixFilter.filter(prefixes -> !prefixes.isEmpty()).ifPresent(inputBuilder::prefix); + + try { + GetResolvedConfigWithIdentifierOutput output = + sdk.getResolvedConfigWithIdentifier(inputBuilder.build()); + return FfiUtils.resolvedConfigToJsonMap(output.config()); + } catch (Exception e) { + throw SuperpositionError.networkError( + "Failed to get resolved config: " + e.getMessage(), e); + } + } + + // ========== FeatureExperimentMeta ========== + + @Override + public List getApplicableVariants( + EvaluationContext context, + Optional> prefixFilter) throws SuperpositionError { + EvaluationContext merged = mergeWithGlobal(context); + + // An absent targeting key is not an error: the service buckets an empty identifier the + // same way local resolution does, and simply matches no experiments. + String targetingKey = Optional.ofNullable(merged.getTargetingKey()).orElse(""); + + ApplicableVariantsInput.Builder inputBuilder = ApplicableVariantsInput.builder() + .workspaceId(options.getWorkspaceId()) + .orgId(options.getOrgId()) + .context(contextOf(merged)) + .identifier(targetingKey); + + prefixFilter.filter(prefixes -> !prefixes.isEmpty()).ifPresent(inputBuilder::prefix); + + try { + ApplicableVariantsOutput output = sdk.applicableVariants(inputBuilder.build()); + return output.data().stream().map(Variant::id).collect(Collectors.toList()); + } catch (Exception e) { + throw SuperpositionError.networkError( + "Failed to get applicable variants: " + e.getMessage(), e); + } + } + + // ========== Private helpers ========== + + /** Global context supplies defaults; the evaluation context wins on conflict. */ + private EvaluationContext mergeWithGlobal(EvaluationContext context) { + return context == null ? globalContext : globalContext.merge(context); + } + + /** + * Build the request context. Values go through the same JSON encoding the FFI uses, so + * numbers and booleans reach the service as numbers and booleans rather than strings. + */ + private static Map contextOf(EvaluationContext context) { + return FfiUtils.contextToDocuments(EvaluationArgs.buildQueryData(context)); + } +} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/traits/AllFeatureProvider.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/traits/AllFeatureProvider.java new file mode 100644 index 000000000..f720e97ce --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/traits/AllFeatureProvider.java @@ -0,0 +1,212 @@ +package io.juspay.superposition.openfeature.traits; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Interface for bulk configuration resolution. + * + * This interface provides methods to resolve all feature flags at once, + * which is more efficient than resolving them individually. + * + * Implementors should provide: + * 1. resolveAllFeatures() - resolve all flags + * 2. resolveAllFeaturesWithFilter() - resolve filtered flags + * + * Default implementations of typed helpers (resolveBool, resolveString, etc.) are provided, + * so implementors only need to implement the two core methods. + */ +public interface AllFeatureProvider { + + /** + * Resolve all features for the given evaluation context. + * + * @param context the evaluation context containing dimensions and targeting key + * @return map of all feature keys to their resolved values (as JSON strings) + * @throws SuperpositionError if resolution fails + */ + default Map resolveAllFeatures(EvaluationContext context) throws SuperpositionError { + return resolveAllFeaturesWithFilter(context, Optional.empty()); + } + /** + * Resolve all features for the given context, optionally filtered by key prefixes. + * + * If prefix_filter is empty, behaves like resolveAllFeatures. + * If prefix_filter is present, only returns features whose keys match any of the prefixes. + * + * @param context the evaluation context + * @param prefixFilter optional list of key prefixes (empty = no filtering) + * @return map of filtered feature keys to their resolved values + * @throws SuperpositionError if resolution fails + */ + Map resolveAllFeaturesWithFilter( + EvaluationContext context, + Optional> prefixFilter) throws SuperpositionError; + + /** + * Default implementation of typed resolution with custom extractor. + * + *

TODO: successful resolutions leave {@code reason} unset. Reporting it accurately + * (STATIC for a default-config value, TARGETING_MATCH for a context override, SPLIT for an + * experiment variant) needs {@code eval_config} in {@code superposition_core} to say, per key, + * where the value came from. Until it does, guessing would be worse than saying nothing — + * a flag no experiment touched would still get labelled SPLIT. Error reasons are set below. + * The same TODO applies to the Rust and Python clients. + * + * @param flagKey the flag key to resolve + * @param context the evaluation context + * @param typeName name of the type (for error messages) + * @param extractor function to extract the value and convert to type T + * @param the type of the resolved value + * @return ProviderEvaluation containing the resolved value + */ + default ProviderEvaluation resolveTyped( + String flagKey, + EvaluationContext context, + String typeName, + java.util.function.Function> extractor) { + try { + Map config = resolveAllFeatures(context); + String valueStr = config.get(flagKey); + + if (valueStr == null) { + return ProviderEvaluation.builder() + .variant("default") + .reason(Reason.ERROR.name()) + .errorCode(dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag '" + flagKey + "' not found") + .build(); + } + + Object value = JsonValues.parse(valueStr); + Optional extracted = extractor.apply(value); + + if (extracted.isPresent()) { + return ProviderEvaluation.builder() + .value(extracted.get()) + .variant("evaluated") + .build(); + } else { + return ProviderEvaluation.builder() + .variant("default") + .reason(Reason.ERROR.name()) + .errorCode(dev.openfeature.sdk.ErrorCode.TYPE_MISMATCH) + .errorMessage("Flag '" + flagKey + "' is not a " + typeName) + .build(); + } + } catch (SuperpositionError e) { + return ProviderEvaluation.builder() + .variant("error") + .reason(Reason.ERROR.name()) + .errorCode(dev.openfeature.sdk.ErrorCode.GENERAL) + .errorMessage("Error evaluating flag '" + flagKey + "': " + e.getMessage()) + .build(); + } + } + + /** + * Resolve a boolean flag. + * + * @param flagKey the flag key + * @param context the evaluation context + * @return ProviderEvaluation with the boolean value + */ + default ProviderEvaluation resolveBool( + String flagKey, + EvaluationContext context) { + return resolveTyped(flagKey, context, "boolean", + value -> { + if (value instanceof Boolean) return Optional.of((Boolean) value); + return Optional.empty(); + }); + } + + /** + * Resolve a string flag. + * + * @param flagKey the flag key + * @param context the evaluation context + * @return ProviderEvaluation with the string value + */ + default ProviderEvaluation resolveString( + String flagKey, + EvaluationContext context) { + return resolveTyped(flagKey, context, "string", + value -> { + if (value instanceof String) return Optional.of((String) value); + return Optional.empty(); + }); + } + + /** + * Resolve an integer flag. + * + * @param flagKey the flag key + * @param context the evaluation context + * @return ProviderEvaluation with the integer value + */ + default ProviderEvaluation resolveInt( + String flagKey, + EvaluationContext context) { + return resolveTyped(flagKey, context, "integer", + value -> { + // Only a JSON integer literal that fits an int. A Double here means the flag + // holds 1.9, 10.0, or a value too large for an int — truncating any of those + // would return a value the flag does not hold. + if (value instanceof Integer number) { + return Optional.of(number); + } + return Optional.empty(); + }); + } + + /** + * Resolve a floating-point flag. + * + * @param flagKey the flag key + * @param context the evaluation context + * @return ProviderEvaluation with the double value + */ + default ProviderEvaluation resolveFloat( + String flagKey, + EvaluationContext context) { + return resolveTyped(flagKey, context, "float", + value -> { + // Integers widen to float: every integer is a real number, so this is + // lossless. The reverse (float -> int) is not, and is rejected above. + if (value instanceof Integer || value instanceof Double) { + return Optional.of(((Number) value).doubleValue()); + } + return Optional.empty(); + }); + } + + /** + * Resolve a structured (object) flag. + * + * @param flagKey the flag key + * @param context the evaluation context + * @return ProviderEvaluation with the object value + */ + default ProviderEvaluation resolveStruct( + String flagKey, + EvaluationContext context) { + return resolveTyped(flagKey, context, "object", + value -> { + // Objects and arrays only. The guard is the point: objectToValue also accepts + // a String, a Boolean and a Number, so calling it unguarded meant a string + // flag read via getObjectValue succeeded instead of reporting TYPE_MISMATCH. + if (value instanceof Map || value instanceof List) { + return Optional.of(Value.objectToValue(value)); + } + return Optional.empty(); + }); + } + +} diff --git a/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/traits/FeatureExperimentMeta.java b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/traits/FeatureExperimentMeta.java new file mode 100644 index 000000000..7a95cb12a --- /dev/null +++ b/clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/traits/FeatureExperimentMeta.java @@ -0,0 +1,49 @@ +package io.juspay.superposition.openfeature.traits; + +import dev.openfeature.sdk.EvaluationContext; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import java.util.List; +import java.util.Optional; + +/** + * Interface for experiment variant resolution. + * + * Implementors provide the ability to determine which experiment variants + * are applicable for a given evaluation context. + * + * This is particularly useful for understanding which variants have been assigned + * to a targeting key, enabling analytics, monitoring, and A/B testing workflows. + */ +public interface FeatureExperimentMeta { + + /** + * Get the list of applicable experiment variant IDs for the given context. + * + * Returns the variant IDs that should be applied based on the experiments + * that match the given evaluation context. + * + * An optional prefix_filter can be provided to narrow the scope to only + * variants in experiments whose IDs start with the given prefixes. + * + * @param context the evaluation context containing dimensions and targeting key + * @param prefixFilter optional list of experiment ID prefixes + * @return list of applicable variant IDs (unordered, may contain duplicates) + * @throws SuperpositionError if resolution fails + */ + List getApplicableVariants( + EvaluationContext context, + Optional> prefixFilter) throws SuperpositionError; + + /** + * Get the list of applicable experiment variant IDs for the given context. + * + * Convenience method equivalent to calling with empty prefix filter. + * + * @param context the evaluation context + * @return list of applicable variant IDs + * @throws SuperpositionError if resolution fails + */ + default List getApplicableVariants(EvaluationContext context) throws SuperpositionError { + return getApplicableVariants(context, Optional.empty()); + } +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/EvaluationArgs.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/EvaluationArgs.kt index 2f151147e..55b71b669 100644 --- a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/EvaluationArgs.kt +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/EvaluationArgs.kt @@ -10,7 +10,7 @@ import io.juspay.superposition.model.GetConfigOutput import software.amazon.smithy.java.core.serde.document.Document import uniffi.superposition_client.* import uniffi.superposition_types.Bucket -import uniffi.superposition_types.Buckets +import uniffi.superposition_types.Config import uniffi.superposition_types.Context import uniffi.superposition_types.DimensionInfo import uniffi.superposition_types.DimensionType @@ -65,6 +65,13 @@ internal class EvaluationArgs { dimensions = config.dimensions } + constructor(config: Config) { + defaultConfig = config.defaultConfigs + contexts = config.contexts + overrides = config.overrides + dimensions = config.dimensions + } + internal object Helpers { private fun toFfiVariant(v: io.juspay.superposition.model.Variant): Variant { var vtype = VariantType.CONTROL @@ -154,10 +161,7 @@ internal class EvaluationArgs { } private fun toQueryData(eContext: EvaluationContext): Map { - val m = eContext.asObjectMap() - if (m == null) { - return HashMap() - } + val m = eContext.asObjectMap() ?: return HashMap() return m.mapValues { valueToJsonString(it.value) } } @@ -198,8 +202,8 @@ internal class EvaluationArgs { private fun toFfiDimensionType(dimType: io.juspay.superposition.model.DimensionType): DimensionType { return when (dimType.type()) { - io.juspay.superposition.model.DimensionType.Type.localCohort -> DimensionType.LocalCohort(dimType.getValue()) - io.juspay.superposition.model.DimensionType.Type.remoteCohort -> DimensionType.RemoteCohort(dimType.getValue()) + io.juspay.superposition.model.DimensionType.Type.localCohort -> DimensionType.LocalCohort(dimType.getValue()) + io.juspay.superposition.model.DimensionType.Type.remoteCohort -> DimensionType.RemoteCohort(dimType.getValue()) else -> DimensionType.Regular } } diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/FfiUtils.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/FfiUtils.kt new file mode 100644 index 000000000..ff17629b6 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/FfiUtils.kt @@ -0,0 +1,180 @@ +package io.juspay.superposition.openfeature + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException +import io.juspay.superposition.model.ContextPartial +import io.juspay.superposition.model.DimensionType as SdkDimensionType +import io.juspay.superposition.model.GetConfigOutput +import io.juspay.superposition.model.GetExperimentConfigOutput +import software.amazon.smithy.java.core.serde.document.Document +import uniffi.superposition_client.ExperimentConfig +import uniffi.superposition_client.OperationException +import uniffi.superposition_client.ffiParseConfigFileWithFilters +import uniffi.superposition_types.Config +import uniffi.superposition_types.Context +import uniffi.superposition_types.DimensionInfo +import uniffi.superposition_types.DimensionType + +/** + * Java-callable wrappers around UniFFI top-level functions and conversion helpers. + * + * Kotlin top-level functions in the generated bindings are not easily callable from Java + * without referencing the auto-generated `*Kt` class. This object exposes them as + * `@JvmStatic` methods to keep Java call sites clean. + * + * `internal` on purpose. This is the quarantine layer between the Smithy SDK's types and the + * UniFFI-generated ones — every signature here traffics in `Config`, `ExperimentConfig` and + * `Document`, none of which belong in a contract consumers compile against. It exists for + * `FileDataSource`, `HttpDataSource` and `SuperpositionAPIProvider` inside this module and + * nothing else; [EvaluationArgs], which does the same job, is already internal. + * + * Note this is not a blanket rule against exposing UniFFI types: [ConfigData] and [ExperimentData] + * deliberately carry `Config` and `ExperimentConfig`, because that *is* the domain model — one + * Rust definition shared by the Rust, Python and Java clients alike. Callers read those. What must + * not leak is the machinery for *building* them. + */ +internal object FfiUtils { + + private val gson = Gson() + + /** + * Parses a config file and applies dimension and prefix filters in a single FFI call. + * + * @param format either `json` or `toml` + * @param dimensionData dimension values as JSON-encoded strings; null or empty skips dimension filtering + * @param prefix config key prefixes; null or empty skips prefix filtering + */ + @JvmStatic + @Throws(OperationException::class) + fun parseConfigFileWithFilters( + fileContent: String, + format: String, + dimensionData: Map?, + prefix: List? + ): Config = ffiParseConfigFileWithFilters( + fileContent, + format, + dimensionData?.takeIf { it.isNotEmpty() }, + prefix?.takeIf { it.isNotEmpty() } + ) + + /** + * Converts a Smithy-generated GetConfigOutput to a UniFFI Config. + * Used by HttpDataSource and other SDK operations to transform API responses. + */ + @JvmStatic + fun fromGetConfigOutput(output: GetConfigOutput): Config { + return Config( + contexts = output.contexts().map { toFfiContext(it) }, + overrides = output.overrides().mapValues { (_, docs) -> serializeDocumentValues(docs) }, + defaultConfigs = serializeDocumentValues(output.defaultConfigs()), + dimensions = output.dimensions().mapValues { (_, dimInfo) -> toFfiDimensionInfo(dimInfo) } + ) + } + + /** + * Converts a Smithy-generated GetExperimentConfigOutput to a UniFFI ExperimentConfig, + * so cached experiments can be handed to the evaluation FFI as-is. + */ + @JvmStatic + fun fromGetExperimentConfigOutput(output: GetExperimentConfigOutput): ExperimentConfig { + return ExperimentConfig( + experiments = output.experiments().map { EvaluationArgs.Helpers.toFfiExperiment(it) }, + experimentGroups = output.experimentGroups().map { EvaluationArgs.Helpers.toFfiExperimentGroup(it) } + ) + } + + /** + * Converts a server-resolved config Document into the JSON-encoded value map the providers + * hand back. A response that is not an object is wrapped under `_value`, mirroring the Rust + * provider. + */ + @JvmStatic + fun resolvedConfigToJsonMap(config: Document): Map { + val resolved = config.asObject() + if (resolved !is Map<*, *>) { + return mapOf("_value" to gson.toJson(resolved)) + } + return resolved.entries.associate { (key, value) -> + key.toString() to gson.toJson(if (value is Document) value.asObject() else value) + } + } + + /** + * Converts a context map of JSON-encoded values into the Documents the SDK expects, + * preserving numbers, booleans and nested structures. A value that is not valid JSON + * is passed through as a plain string. + */ + @JvmStatic + fun contextToDocuments(context: Map): Map { + return context.mapValues { (_, json) -> toDocument(json) } + } + + // ============= Private conversion helpers ============= + + private fun toDocument(json: String): Document { + val element = try { + JsonParser.parseString(json) + } catch (_: JsonSyntaxException) { + return Document.of(json) + } + return toDocument(element) + } + + private fun toDocument(element: JsonElement): Document = when { + element.isJsonObject -> + Document.of(element.asJsonObject.entrySet().associate { (k, v) -> k to toDocument(v) }) + element.isJsonArray -> Document.of(element.asJsonArray.map { toDocument(it) }) + element.isJsonPrimitive -> { + val primitive = element.asJsonPrimitive + when { + primitive.isBoolean -> Document.of(primitive.asBoolean) + primitive.isNumber -> Document.ofNumber(primitive.asNumber) + else -> Document.of(primitive.asString) + } + } + // JSON null has no Document representation; the empty string keeps the key present. + else -> Document.of("") + } + + private fun serializeDocument(d: Document): String { + val obj = d.asObject() + return gson.toJson(obj) + } + + private fun serializeDocumentValues(m: MutableMap): Map { + return m.mapValues { (_, doc) -> serializeDocument(doc) } + } + + private fun toFfiContext(cp: ContextPartial): Context { + val cond = serializeDocumentValues(cp.condition()) + return Context( + id = cp.id(), + condition = cond, + priority = cp.priority(), + weight = cp.weight(), + overrideWithKeys = cp.overrideWithKeys() + ) + } + + private fun toFfiDimensionInfo(dim: io.juspay.superposition.model.DimensionInfo): DimensionInfo { + return DimensionInfo( + schema = serializeDocumentValues(dim.schemaMember()), + position = dim.position(), + dimensionType = toFfiDimensionType(dim.dimensionType()), + dependencyGraph = dim.dependencyGraph(), + valueComputeFunctionName = dim.valueComputeFunctionName(), + description = "" + ) + } + + private fun toFfiDimensionType(dimType: SdkDimensionType): DimensionType { + return when (dimType.type()) { + SdkDimensionType.Type.localCohort -> DimensionType.LocalCohort(dimType.getValue()) + SdkDimensionType.Type.remoteCohort -> DimensionType.RemoteCohort(dimType.getValue()) + else -> DimensionType.Regular + } + } +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/ConfigData.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/ConfigData.kt new file mode 100644 index 000000000..9a754f7b7 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/ConfigData.kt @@ -0,0 +1,31 @@ +package io.juspay.superposition.openfeature.data_source + +import uniffi.superposition_types.Config +import java.time.Instant + +/** + * Holds resolved configuration along with the timestamp it was fetched. + * + * Wraps [Config] (the UniFFI-generated Rust type) with a [fetchedAt] timestamp used to support + * conditional fetching (HTTP 304 / If-Modified-Since). For HTTP sources this is the server's + * `last-modified` time, not the local clock, so it can be sent straight back as + * `if-modified-since`. + * + * [Config] contains: + * - `contexts` — list of contexts matchable against an evaluation context + * - `overrides` — per-context key/value overrides (`Map`) + * - `defaultConfigs` — default key/value pairs (`ExtendedMap = Map`) + * - `dimensions` — dimension definitions (`Map`) + * + * @property data The resolved configuration, directly from the UniFFI Rust bindings. + * @property fetchedAt The timestamp when this config was last modified at its source. + */ +data class ConfigData(val data: Config, val fetchedAt: Instant) { + + override fun toString(): String = + "ConfigData(fetched_at: $fetchedAt" + + ", data.contexts: ${data.contexts.size}" + + ", data.overrides: ${data.overrides.size}" + + ", data.defaultConfigs: ${data.defaultConfigs.size}" + + ", data.dimensions: ${data.dimensions.size})" +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/ExperimentData.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/ExperimentData.kt new file mode 100644 index 000000000..5eb8842c9 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/ExperimentData.kt @@ -0,0 +1,18 @@ +package io.juspay.superposition.openfeature.data_source + +import uniffi.superposition_client.ExperimentConfig +import java.time.Instant + +/** + * Holds active experiments and experiment groups along with the timestamp they were fetched. + * + * @property data The experiment configuration data. + * @property fetchedAt The timestamp when this data was last modified at its source. + */ +data class ExperimentData(val data: ExperimentConfig, val fetchedAt: Instant) { + + override fun toString(): String = + "ExperimentData(data: ${data.experiments.size} experiments, " + + "${data.experimentGroups.size} experiment groups" + + ", fetched_at: $fetchedAt)" +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/FetchResponse.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/FetchResponse.kt new file mode 100644 index 000000000..dac0ffdf8 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/FetchResponse.kt @@ -0,0 +1,61 @@ +package io.juspay.superposition.openfeature.data_source + +import java.util.Optional +import java.util.function.Function + +/** + * The result of a fetch: either fresh data, or confirmation that nothing changed. + * + * A data source that supports conditional fetching (HTTP 304 / If-Modified-Since) answers + * [NotModified] when the caller's cached copy is still current, which is not an error and not an + * empty result — it means "keep what you have". + * + * @param T the type of data returned by the fetch operation + */ +sealed class FetchResponse { + + /** + * The data has not been modified since the last request. Corresponds to HTTP 304. + * + * A single object serves every `T`: it carries no value, so `FetchResponse` is a + * subtype of `FetchResponse` for any `T`. The Java version had to share one instance + * behind an unchecked cast to achieve the same thing. + */ + object NotModified : FetchResponse() { + override fun toString(): String = "FetchResponse.NotModified" + } + + /** Fresh data from the source. */ + data class Data(val value: T) : FetchResponse() { + override fun toString(): String = "FetchResponse.Data($value)" + } + + /** Whether this response means "nothing changed". */ + fun isNotModified(): Boolean = this is NotModified + + /** The data, or empty when nothing changed. */ + fun getData(): Optional<@UnsafeVariance T> = when (this) { + is NotModified -> Optional.empty() + is Data -> Optional.of(value) + } + + /** + * Transform the data if there is any, preserving [NotModified]. + * + * `T` only ever flows out of the mapper's input here, so the variance escape is sound. + */ + fun mapData(mapper: Function<@UnsafeVariance T, U>): FetchResponse = when (this) { + is NotModified -> NotModified + is Data -> Data(mapper.apply(value)) + } + + companion object { + /** A response meaning "your cached copy is still current". */ + @JvmStatic + fun notModified(): FetchResponse = NotModified + + /** A response carrying fresh data. */ + @JvmStatic + fun data(data: T): FetchResponse = Data(data) + } +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/WatchStream.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/WatchStream.kt new file mode 100644 index 000000000..ac9f3db1c --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/data_source/WatchStream.kt @@ -0,0 +1,84 @@ +package io.juspay.superposition.openfeature.data_source + +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * A stream of change notifications from a data source. + * + * Used for watching file system changes or other event-based data source updates. Consumers poll + * or block on [getNextEvent] to receive notifications. + * + * Each subscriber gets its own stream: the data source fans every change out to all live streams, + * so one consumer cannot steal another's events. + */ +class WatchStream { + + private val eventQueue: BlockingQueue = LinkedBlockingQueue() + + /** Whether this stream has been closed by its data source. */ + @Volatile + var isClosed: Boolean = false + private set + + /** Notify that a change has occurred. Safe to call from the watching thread. */ + fun notifyChange() { + if (!isClosed) { + eventQueue.offer(true) + } + } + + /** + * Close the stream, releasing any consumer blocked in [getNextEvent]. + * + * Subsequent and in-flight waits return false. + */ + fun close() { + isClosed = true + eventQueue.offer(CLOSED) + } + + /** + * Wait for the next event, blocking until one arrives. + * + * @return true if a change occurred, false if the stream was closed + */ + @Throws(InterruptedException::class) + fun getNextEvent(): Boolean { + // Check before blocking: close() enqueues only one CLOSED sentinel, so if it was already + // consumed (e.g. by the debounce drain), take() would block forever with nothing to wake it. + if (isClosed) return false + return eventQueue.take() && !isClosed + } + + /** + * Wait for the next event, giving up after [timeoutMs]. + * + * @return true if a change occurred, false on timeout or if the stream is closed + */ + @Throws(InterruptedException::class) + fun tryGetNextEvent(timeoutMs: Long): Boolean { + // See getNextEvent: without this a closed stream whose sentinel was already drained costs a + // full timeoutMs before the caller learns it is closed. + if (isClosed) return false + return eventQueue.poll(timeoutMs, TimeUnit.MILLISECONDS) == true && !isClosed + } + + /** Whether any events are queued, without blocking. */ + fun hasPendingEvents(): Boolean = eventQueue.isNotEmpty() + + /** Discard all queued events. */ + fun drainEvents(): Int { + var count = 0 + while (eventQueue.poll() != null) { + count++ + } + return count + } + + private companion object { + /** Enqueued by [close] to release consumers blocked in [getNextEvent]. */ + const val CLOSED = false + } +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/error/SuperpositionError.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/error/SuperpositionError.kt new file mode 100644 index 000000000..bc7bafcab --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/error/SuperpositionError.kt @@ -0,0 +1,79 @@ +package io.juspay.superposition.openfeature.error + +/** + * Thrown when a Superposition provider operation fails. + * + * Extends [Exception], so it stays a *checked* exception: the `throws SuperpositionError` clauses + * across the data-source and provider interfaces are the contract that forces callers to handle + * failure, and Java code that implements or calls those interfaces still gets that from javac. + * + * @property errorCode The kind of failure this error represents. + */ +class SuperpositionError : Exception { + + val errorCode: ErrorCode + + /** The kinds of failure a provider can report. Shared with the Rust and Python clients. */ + enum class ErrorCode { + /** Configuration or setup error. */ + CONFIG_ERROR, + + /** Network or HTTP error. */ + NETWORK_ERROR, + + /** Serialization or deserialization error. */ + SERIALIZATION_ERROR, + + /** The provider itself is unusable — typically evaluated before a successful init. */ + PROVIDER_ERROR, + + /** A data source failed. */ + DATA_SOURCE_ERROR, + + /** A refresh failed or outlived its timeout. */ + REFRESH_ERROR, + } + + @JvmOverloads + constructor(message: String, cause: Throwable? = null) : super(message, cause) { + this.errorCode = ErrorCode.PROVIDER_ERROR + } + + @JvmOverloads + constructor(errorCode: ErrorCode, message: String, cause: Throwable? = null) : + super(message, cause) { + this.errorCode = errorCode + } + + companion object { + @JvmStatic + @JvmOverloads + fun configError(message: String, cause: Throwable? = null) = + SuperpositionError(ErrorCode.CONFIG_ERROR, message, cause) + + @JvmStatic + @JvmOverloads + fun networkError(message: String, cause: Throwable? = null) = + SuperpositionError(ErrorCode.NETWORK_ERROR, message, cause) + + @JvmStatic + @JvmOverloads + fun serializationError(message: String, cause: Throwable? = null) = + SuperpositionError(ErrorCode.SERIALIZATION_ERROR, message, cause) + + @JvmStatic + @JvmOverloads + fun providerError(message: String, cause: Throwable? = null) = + SuperpositionError(ErrorCode.PROVIDER_ERROR, message, cause) + + @JvmStatic + @JvmOverloads + fun dataSourceError(message: String, cause: Throwable? = null) = + SuperpositionError(ErrorCode.DATA_SOURCE_ERROR, message, cause) + + @JvmStatic + @JvmOverloads + fun refreshError(message: String, cause: Throwable? = null) = + SuperpositionError(ErrorCode.REFRESH_ERROR, message, cause) + } +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/options/RefreshStrategy.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/options/RefreshStrategy.kt new file mode 100644 index 000000000..37cc400b3 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/options/RefreshStrategy.kt @@ -0,0 +1,58 @@ +package io.juspay.superposition.openfeature.options + +/** + * How a provider keeps its cached configuration current. + * + * All durations are milliseconds, named with a `Milliseconds` suffix to match the Rust and Python + * clients. (`debounceMs` is the one exception, kept for its established name.) + */ +sealed interface RefreshStrategy { + + /** How long a single refresh may take before it is abandoned. */ + val timeoutMilliseconds: Int + + /** + * Fetch periodically at a fixed interval, on a background task started at initialization. + * + * @property intervalMilliseconds How often to poll. + */ + data class Polling + @JvmOverloads constructor( + override val timeoutMilliseconds: Int, + val intervalMilliseconds: Int, + ) : RefreshStrategy + + /** + * Fetch lazily, when the cached data is older than its TTL. + * + * Keeps backend load down at the cost of a bounded amount of staleness. If a refresh fails and + * [useStaleOnError] is set, the last known good data is served rather than failing the call. + * + * @property ttlMilliseconds How long cached data stays fresh. + * @property useStaleOnError Whether to serve stale data when a refresh fails. + */ + data class OnDemand + @JvmOverloads constructor( + override val timeoutMilliseconds: Int, + val ttlMilliseconds: Int, + val useStaleOnError: Boolean = true, + ) : RefreshStrategy + + /** + * Refresh when the underlying source signals a change. Only usable with a data source that + * supports watching; a provider configured this way against one that does not fails to + * initialize rather than silently never refreshing. + * + * @property debounceMs How long to coalesce a burst of rapid changes. + */ + data class Watch + @JvmOverloads constructor( + override val timeoutMilliseconds: Int, + val debounceMs: Int = 500, + ) : RefreshStrategy + + /** Never refresh on its own; the caller drives it by invoking `refresh()`. */ + data class Manual( + override val timeoutMilliseconds: Int, + ) : RefreshStrategy +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/options/SuperpositionOptions.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/options/SuperpositionOptions.kt new file mode 100644 index 000000000..2bffeddb6 --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/options/SuperpositionOptions.kt @@ -0,0 +1,31 @@ +package io.juspay.superposition.openfeature.options + +/** + * Connection details for the Superposition backend. + * + * Used directly by data sources, or wrapped in provider options. + * + * @property endpoint The API endpoint (e.g. `http://localhost:8080`). + * @property token Bearer token for authentication. + * @property orgId Organization ID within Superposition. + * @property workspaceId Workspace ID for the configuration set. + */ +data class SuperpositionOptions( + val endpoint: String, + val token: String, + val orgId: String, + val workspaceId: String, +) { + + /** + * Check that every field carries a value. + * + * @throws IllegalStateException if any field is blank + */ + fun validate() { + check(endpoint.isNotBlank()) { "endpoint is required" } + check(token.isNotBlank()) { "token is required" } + check(orgId.isNotBlank()) { "orgId is required" } + check(workspaceId.isNotBlank()) { "workspaceId is required" } + } +} diff --git a/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/traits/JsonValues.kt b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/traits/JsonValues.kt new file mode 100644 index 000000000..9e69f443d --- /dev/null +++ b/clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/traits/JsonValues.kt @@ -0,0 +1,62 @@ +package io.juspay.superposition.openfeature.traits + +import com.google.gson.GsonBuilder +import io.juspay.superposition.openfeature.error.SuperpositionError +import java.math.BigDecimal + +/** + * Decodes the JSON-encoded values that flag evaluation hands back. + * + * Deliberately not on [AllFeatureProvider]: decoding is what the bool/int/float/struct extractors + * are defined against, and that contract is shared with the Rust and Python clients. Exposing it on + * the interface would let an implementor silently change what a flag evaluates to in Java alone. + */ +internal object JsonValues { + + /** + * Parses flag values, preserving each number's JSON literal type. + * + * Gson's stock `Object` binding maps every number to `Double`, which erases the difference + * between `10` and `10.0` and leaves `resolveInt` no way to reject a fractional value except by + * truncating it. The only thing wrong with that binding is the number policy, so that is the + * only thing replaced here; the rest (objects to Map, arrays to List, booleans, strings, nulls) + * already produces exactly the types `Value.objectToValue` accepts. + * + * `Integer` rather than `Long` is deliberate: `objectToValue` has no `Long` case, and + * OpenFeature's `Value` has no integer type wider than `int` to widen to. A literal beyond int + * range therefore degrades to a `Double` — lossy for very large integers, but `resolveInt` must + * reject those either way. + */ + private val gson = GsonBuilder() + .setObjectToNumberStrategy { reader -> + val number = BigDecimal(reader.nextString()) + // scale() <= 0 covers both 10 (scale 0) and 1e2 (scale -2); 10.0 has scale 1. + if (number.scale() <= 0) { + runCatching { number.intValueExact() }.getOrElse { number.toDouble() } + } else { + number.toDouble() + } + } + .create() + + /** + * Parse a JSON string into plain Java values: null, Boolean, String, Integer, Double, List or + * Map. + * + * `@Throws` is load-bearing. Kotlin has no checked exceptions, so without it the Java callers + * in [AllFeatureProvider] would not be forced to handle a malformed flag value — which is the + * exact bug this method was written to fix. + * + * @throws SuperpositionError if the value is not well-formed JSON + */ + @JvmStatic + @Throws(SuperpositionError::class) + fun parse(jsonString: String): Any? = try { + gson.fromJson(jsonString, Any::class.java) + } catch (e: RuntimeException) { + throw SuperpositionError.serializationError( + "Failed to parse flag value as JSON: $jsonString", + e, + ) + } +} diff --git a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/DefaultConfigPopulator.java b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/DefaultConfigPopulator.java index 27e0c0ba9..f571a4add 100644 --- a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/DefaultConfigPopulator.java +++ b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/DefaultConfigPopulator.java @@ -121,13 +121,16 @@ public void populateFromProvidedData() throws Exception { } // create context - client.createContext(CreateContextInput.builder(). - context(Map.of("d2", Document.of("test"))). - override(Map.of("bool", Document.of(false))). - description("description"). - changeReason("change"). - workspaceId(this.workspaceId). - orgId(this.orgId).build() + client.createContext(CreateContextInput.builder() + .request(ContextPut.builder() + .context(Map.of("d2", Document.of("test"))) + .override(Map.of("bool", Document.of(false))) + .description("description") + .changeReason("change") + .build()) + .workspaceId(this.workspaceId) + .orgId(this.orgId) + .build() ); System.out.println("Provided data population completed successfully!"); @@ -220,7 +223,7 @@ private ListDefaultConfigsOutput listExistingConfigs() throws Exception { private List extractExistingKeys(ListDefaultConfigsOutput existingConfigs) { List keys = new ArrayList<>(); - ListIterator keysIterator = existingConfigs.data().listIterator(); + ListIterator keysIterator = existingConfigs.data().listIterator(); while (keysIterator.hasNext()) { keys.add(keysIterator.next().key()); @@ -241,7 +244,7 @@ private void createDefaultConfig(String key, Document value, Map schema, String // Add optional fields if they exist if (functionName != null) { - inputBuilder.functionName(functionName); + inputBuilder.valueValidationFunctionName(functionName); } if (autocompleteFunctionName != null) { inputBuilder.valueComputeFunctionName(autocompleteFunctionName); diff --git a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/ProviderTest.java b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/ProviderTest.java index 08332381f..c66f40b5c 100644 --- a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/ProviderTest.java +++ b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/ProviderTest.java @@ -25,14 +25,14 @@ class ProviderTest { final static String token = "my-token"; final static Logger log = LoggerFactory.getLogger(ProviderTest.class); final static SuperpositionProviderOptions.ExperimentationOptions eopts = SuperpositionProviderOptions.ExperimentationOptions.builder() - .refreshStrategy(RefreshStrategy.Polling.of(1000, 1000)) + .refreshStrategy(new RefreshStrategy.Polling(1000, 1000)) .build(); final static SuperpositionProviderOptions options = SuperpositionProviderOptions.builder() .orgId(orgId) .workspaceId(workspace) .endpoint(endpoint) .token(token) - .refreshStrategy(RefreshStrategy.Polling.of(1000, 100)) + .refreshStrategy(new RefreshStrategy.Polling(1000, 100)) .experimentationOptions(eopts) .build(); SuperpositionOpenFeatureProvider provider; diff --git a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/data_source/FileDataSourceTest.java b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/data_source/FileDataSourceTest.java new file mode 100644 index 000000000..e663b3585 --- /dev/null +++ b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/data_source/FileDataSourceTest.java @@ -0,0 +1,175 @@ +package io.juspay.superposition.openfeature.data_source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.juspay.superposition.openfeature.error.SuperpositionError; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import uniffi.superposition_types.Config; + +class FileDataSourceTest { + + private static final String TOML_CONFIG = """ + [default-configs] + currency = { value = "Rupee", schema = { type = "string" } } + price = { value = 10000, schema = { type = "number" } } + + [dimensions] + city = { position = 1, schema = { type = "string" }, type = "REGULAR" } + + [[overrides]] + _context_ = { city = "Boston" } + currency = "Dollar" + + [[overrides]] + _context_ = { city = "Berlin" } + price = 20000 + """; + + private static final String JSON_CONFIG = """ + { + "default-configs": { + "currency": { "value": "Rupee", "schema": { "type": "string" } } + }, + "dimensions": { + "city": { "position": 1, "schema": { "type": "string" }, "type": "REGULAR" } + }, + "overrides": [ + { "_context_": { "city": "Boston" }, "currency": "Dollar" } + ] + } + """; + + private static FileDataSource sourceOf(Path dir, String name, String content) throws Exception { + Path file = dir.resolve(name); + Files.writeString(file, content); + return new FileDataSource(file); + } + + @Test + void readsTomlConfig(@TempDir Path dir) throws Exception { + Config config = sourceOf(dir, "config.toml", TOML_CONFIG) + .fetchConfig(Optional.empty()) + .getData() + .orElseThrow() + .getData(); + + assertEquals(2, config.getDefaultConfigs().size()); + assertEquals(2, config.getContexts().size()); + assertEquals(1, config.getDimensions().size()); + } + + @Test + void readsJsonConfig(@TempDir Path dir) throws Exception { + Config config = sourceOf(dir, "config.json", JSON_CONFIG) + .fetchConfig(Optional.empty()) + .getData() + .orElseThrow() + .getData(); + + assertEquals(1, config.getDefaultConfigs().size()); + assertEquals(1, config.getContexts().size()); + } + + @Test + void rejectsUnsupportedExtension(@TempDir Path dir) { + assertThrows(SuperpositionError.class, () -> new FileDataSource(dir.resolve("config.yaml"))); + assertThrows(SuperpositionError.class, () -> new FileDataSource(dir.resolve("config"))); + } + + @Test + void prefixFilterPrunesOverridesAndContexts(@TempDir Path dir) throws Exception { + Config config = sourceOf(dir, "config.toml", TOML_CONFIG) + .fetchFilteredConfig(Optional.empty(), Optional.of(List.of("currency")), Optional.empty()) + .getData() + .orElseThrow() + .getData(); + + assertEquals(Map.of("currency", "\"Rupee\""), config.getDefaultConfigs()); + // The Berlin context only overrode `price`, so it drops out along with its override. + assertEquals(1, config.getContexts().size()); + assertEquals(1, config.getOverrides().size()); + } + + @Test + void contextFilterKeepsOnlyMatchingContexts(@TempDir Path dir) throws Exception { + Config config = sourceOf(dir, "config.toml", TOML_CONFIG) + .fetchFilteredConfig( + Optional.of(Map.of("city", "\"Boston\"")), Optional.empty(), Optional.empty()) + .getData() + .orElseThrow() + .getData(); + + assertEquals(1, config.getContexts().size()); + assertEquals(1, config.getOverrides().size()); + assertTrue(config.getOverrides().values().iterator().next().containsKey("currency")); + } + + @Test + void experimentsAreUnsupported(@TempDir Path dir) throws Exception { + FileDataSource source = sourceOf(dir, "config.toml", TOML_CONFIG); + + assertFalse(source.supportsExperiments()); + assertThrows(SuperpositionError.class, () -> source.fetchActiveExperiments(Optional.empty())); + } + + @Test + void watchFansChangesOutToEverySubscriber(@TempDir Path dir) throws Exception { + FileDataSource source = sourceOf(dir, "config.toml", TOML_CONFIG); + WatchStream first = source.watch().orElseThrow(); + WatchStream second = source.watch().orElseThrow(); + + Files.writeString(dir.resolve("config.toml"), TOML_CONFIG + "\n"); + + assertTrue(first.tryGetNextEvent(20_000), "first subscriber saw no change"); + assertTrue(second.tryGetNextEvent(20_000), "second subscriber saw no change"); + + // close() must release consumers blocked on the stream, not leave them parked forever. + source.close(); + assertFalse(first.getNextEvent()); + assertTrue(first.isClosed()); + } + + @Test + void ifModifiedSinceIsIgnored(@TempDir Path dir) throws Exception { + FetchResponse response = sourceOf(dir, "config.toml", TOML_CONFIG) + .fetchConfig(Optional.of(Instant.now().plusSeconds(3600))); + + assertFalse(response.isNotModified()); + } + + /** + * A watch that cannot be registered must fail as a checked SuperpositionError and leave nothing + * behind — no half-registered watcher, no subscriber. Registration used to publish the + * WatchService to the field *before* registering, so a failure left a non-null watchService + * with no watch thread running: the next watch() took the "existing watcher" branch and handed + * back a stream that could never fire. + */ + @Test + void aWatchThatCannotBeRegisteredFailsCleanlyAndLeavesTheSourceUsable(@TempDir Path dir) + throws Exception { + Path missingDir = dir.resolve("does-not-exist"); + FileDataSource broken = new FileDataSource(missingDir.resolve("config.toml")); + + assertThrows(SuperpositionError.class, broken::watch); + // Repeatable: the failure must not have poisoned any state. + assertThrows(SuperpositionError.class, broken::watch); + broken.close(); + + // An unrelated, valid source still watches normally. + FileDataSource healthy = sourceOf(dir, "config.toml", TOML_CONFIG); + WatchStream stream = healthy.watch().orElseThrow(); + Files.writeString(dir.resolve("config.toml"), TOML_CONFIG + "\n"); + assertTrue(stream.tryGetNextEvent(20_000), "a healthy watcher stopped delivering events"); + healthy.close(); + } +} diff --git a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/data_source/HttpDataSourceTest.java b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/data_source/HttpDataSourceTest.java new file mode 100644 index 000000000..0f4c7c091 --- /dev/null +++ b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/data_source/HttpDataSourceTest.java @@ -0,0 +1,239 @@ +package io.juspay.superposition.openfeature.data_source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import io.juspay.superposition.openfeature.options.SuperpositionOptions; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import uniffi.superposition_client.ExperimentConfig; +import uniffi.superposition_types.ExperimentStatusType; +import uniffi.superposition_types.VariantType; + +/** + * Drives HttpDataSource against a stub of the Superposition HTTP API, so 304 handling and the + * last-modified round trip are exercised for real rather than mocked out. + */ +class HttpDataSourceTest { + + /** The instant the stub serves as `last-modified`; must survive into ConfigData.getFetchedAt(). */ + private static final Instant LAST_MODIFIED = Instant.parse("2024-03-01T10:15:30Z"); + + private static final String CONFIG_BODY = """ + { + "contexts": [ + { + "id": "ctx-1", + "condition": { "city": "Boston" }, + "priority": 1, + "weight": 1, + "override_with_keys": ["ovr-1"] + } + ], + "overrides": { "ovr-1": { "currency": "Dollar" } }, + "default_configs": { "currency": "Rupee", "price": 10000 }, + "dimensions": { + "city": { + "schema": { "type": "string" }, + "position": 1, + "dimension_type": { "REGULAR": {} }, + "dependency_graph": {} + } + } + } + """; + + private static final String EXPERIMENT_BODY = """ + { + "experiments": [ + { + "id": "exp-1", + "created_at": "2024-03-01T10:00:00Z", + "created_by": "tester", + "last_modified": "2024-03-01T10:00:00Z", + "name": "checkout", + "experiment_type": "DEFAULT", + "override_keys": ["price"], + "status": "INPROGRESS", + "traffic_percentage": 50, + "context": { "city": "Boston" }, + "variants": [ + { + "id": "exp-1-control", + "variant_type": "CONTROL", + "overrides": { "price": 10000 } + }, + { + "id": "exp-1-experimental", + "variant_type": "EXPERIMENTAL", + "overrides": { "price": 9000 } + } + ], + "last_modified_by": "tester", + "description": "checkout experiment", + "change_reason": "testing" + } + ], + "experiment_groups": [ + { + "id": "grp-1", + "context_hash": "hash", + "name": "checkout-group", + "description": "d", + "change_reason": "c", + "context": { "city": "Boston" }, + "traffic_percentage": 100, + "member_experiment_ids": ["exp-1"], + "created_at": "2024-03-01T10:00:00Z", + "created_by": "tester", + "last_modified_at": "2024-03-01T10:00:00Z", + "last_modified_by": "tester", + "buckets": [{ "experiment_id": "exp-1", "variant_id": "exp-1-control" }], + "group_type": "USER_CREATED" + } + ] + } + """; + + private HttpServer server; + private HttpDataSource source; + + /** Captures the if-modified-since header the SDK actually put on the wire. */ + private final AtomicReference lastIfModifiedSince = new AtomicReference<>(); + + /** When set, the stub answers 304 instead of a body. */ + private volatile boolean respondNotModified = false; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/config", exchange -> respond(exchange, CONFIG_BODY)); + server.createContext("/experiment-config", exchange -> respond(exchange, EXPERIMENT_BODY)); + server.start(); + + source = new HttpDataSource(new SuperpositionOptions( + "http://127.0.0.1:" + server.getAddress().getPort(), "test-token", "localorg", "test")); + } + + @AfterEach + void stopServer() throws SuperpositionError { + source.close(); + server.stop(0); + } + + private void respond(HttpExchange exchange, String body) throws IOException { + lastIfModifiedSince.set(exchange.getRequestHeaders().getFirst("if-modified-since")); + // The smithy model types these headers as date-time, so they travel as ISO-8601, not HTTP-date. + exchange.getResponseHeaders().add("last-modified", LAST_MODIFIED.toString()); + exchange.getResponseHeaders().add("x-config-version", "1"); + exchange.getResponseHeaders().add("content-type", "application/json"); + + if (respondNotModified) { + exchange.sendResponseHeaders(304, -1); + exchange.close(); + return; + } + + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + @Test + void fetchConfigUsesServerLastModifiedAsFetchedAt() throws Exception { + ConfigData config = source.fetchConfig(Optional.empty()).getData().orElseThrow(); + + assertEquals(LAST_MODIFIED, config.getFetchedAt()); + assertEquals(1, config.getData().getContexts().size()); + assertEquals(2, config.getData().getDefaultConfigs().size()); + // Values cross the FFI boundary JSON-encoded, so a number stays a number. + assertEquals("10000", config.getData().getDefaultConfigs().get("price")); + } + + @Test + void notModifiedResponseIsNotAnError() throws Exception { + respondNotModified = true; + + FetchResponse response = source.fetchConfig(Optional.of(LAST_MODIFIED)); + + assertTrue(response.isNotModified()); + assertTrue(response.getData().isEmpty()); + assertEquals(LAST_MODIFIED.toString(), lastIfModifiedSince.get()); + } + + @Test + void notModifiedIsDistinctFromAServerError() { + server.removeContext("/config"); + server.createContext("/config", exchange -> { + exchange.sendResponseHeaders(500, -1); + exchange.close(); + }); + + // A 500 whose body/message could contain "304" must still surface as an error. + assertThrows(SuperpositionError.class, () -> source.fetchConfig(Optional.empty())); + } + + @Test + void fetchesExperimentsAndConvertsThemForEvaluation() throws Exception { + ExperimentData experiments = + source.fetchActiveExperiments(Optional.empty()).getData().orElseThrow(); + + assertEquals(LAST_MODIFIED, experiments.getFetchedAt()); + + ExperimentConfig config = experiments.getData(); + assertEquals(1, config.getExperiments().size()); + assertEquals(1, config.getExperimentGroups().size()); + + var experiment = config.getExperiments().get(0); + assertEquals("exp-1", experiment.getId()); + assertEquals(ExperimentStatusType.INPROGRESS, experiment.getStatus()); + assertEquals(Map.of("city", "\"Boston\""), experiment.getContext()); + assertEquals(VariantType.CONTROL, experiment.getVariants().get(0).getVariantType()); + assertEquals("9000", experiment.getVariants().get(1).getOverrides().get("price")); + + var group = config.getExperimentGroups().get(0); + assertEquals(List.of("exp-1"), group.getMemberExperimentIds()); + assertEquals(1, group.getBuckets().size()); + } + + @Test + void candidateAndMatchingExperimentsSendTheirDimensionStrategies() throws Exception { + AtomicReference query = new AtomicReference<>(); + server.removeContext("/experiment-config"); + server.createContext("/experiment-config", exchange -> { + query.set(exchange.getRequestURI().getQuery()); + respond(exchange, EXPERIMENT_BODY); + }); + + Optional> context = Optional.of(Map.of("city", "\"Boston\"")); + + source.fetchCandidateActiveExperiments(context, Optional.empty(), Optional.empty()); + assertTrue(query.get().contains("dimension_match_strategy=exact"), query.get()); + + source.fetchMatchingActiveExperiments(context, Optional.of(List.of("price")), Optional.empty()); + assertTrue(query.get().contains("dimension_match_strategy=subset"), query.get()); + assertTrue(query.get().contains("prefix=price"), query.get()); + } + + @Test + void supportsExperiments() { + assertTrue(source.supportsExperiments()); + } +} diff --git a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/provider/LocalResolutionProviderTest.java b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/provider/LocalResolutionProviderTest.java new file mode 100644 index 000000000..0105f027e --- /dev/null +++ b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/provider/LocalResolutionProviderTest.java @@ -0,0 +1,379 @@ +package io.juspay.superposition.openfeature.provider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import io.juspay.superposition.openfeature.data_source.ConfigData; +import io.juspay.superposition.openfeature.data_source.FetchResponse; +import io.juspay.superposition.openfeature.data_source.FileDataSource; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import io.juspay.superposition.openfeature.options.RefreshStrategy; +import java.nio.file.Files; +import java.time.Instant; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import uniffi.superposition_types.Config; + +class LocalResolutionProviderTest { + + private static final String TOML_CONFIG = """ + [default-configs] + currency = { value = "Rupee", schema = { type = "string" } } + price = { value = 10000, schema = { type = "number" } } + enabled = { value = true, schema = { type = "boolean" } } + ratio = { value = 1.5, schema = { type = "number" } } + tags = { value = ["a", "b"], schema = { type = "array" } } + meta = { value = { tier = "gold", credits = 5, limits = { retries = 3 } }, schema = { type = "object" } } + counts = { value = [1, 2], schema = { type = "array" } } + + [dimensions] + city = { position = 1, schema = { type = "string" }, type = "REGULAR" } + + [[overrides]] + _context_ = { city = "Boston" } + currency = "Dollar" + + [[overrides]] + _context_ = { city = "Berlin" } + price = 20000 + """; + + private Path configFile; + private LocalResolutionProvider provider; + + @BeforeEach + void setUp(@TempDir Path dir) throws Exception { + configFile = dir.resolve("config.toml"); + Files.writeString(configFile, TOML_CONFIG); + + provider = new LocalResolutionProvider( + new FileDataSource(configFile), + Optional.empty(), + new RefreshStrategy.Manual(1000)); + provider.initialize(new ImmutableContext()); + } + + @AfterEach + void tearDown() { + provider.shutdown(); + } + + private static EvaluationContext contextOf(Map attributes) { + return new ImmutableContext(attributes); + } + + @Test + void initializeMakesTheProviderReady() { + assertEquals(ProviderState.READY, provider.getState()); + assertEquals("LocalResolutionProvider", provider.getMetadata().getName()); + } + + @Test + void reinitializingALiveProviderIsANoOpAndKeepsItServing() throws Exception { + assertEquals(ProviderState.READY, provider.getState()); + + // A second initialize() must not strand the first strategy's task or leak the cache; it is + // simply ignored. The provider stays READY and keeps resolving. + provider.initialize(new ImmutableContext()); + + assertEquals(ProviderState.READY, provider.getState()); + assertEquals("Rupee", + provider.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + } + + @Test + void resolvesDefaultConfigValuesWithTheirRealTypes() { + EvaluationContext context = new ImmutableContext(); + + // The values are stored JSON-encoded, so a string must come back unquoted. + assertEquals("Rupee", provider.getStringEvaluation("currency", "none", context).getValue()); + assertEquals(10000, provider.getIntegerEvaluation("price", 0, context).getValue()); + assertEquals(10000.0, provider.getDoubleEvaluation("price", 0.0, context).getValue()); + assertEquals(true, provider.getBooleanEvaluation("enabled", false, context).getValue()); + assertEquals(1.5, provider.getDoubleEvaluation("ratio", 0.0, context).getValue()); + } + + /** + * The type contract is shared with the Rust and Python clients: no coercion, an integer + * widens to a float, and nothing else converts. Each assertion here is a value that a + * client could previously read as the wrong type. + */ + @Test + void enforcesTheCrossLanguageTypeContract() { + EvaluationContext context = new ImmutableContext(); + + // A float is not an integer. This used to truncate 1.5 to 1. + assertEquals(ErrorCode.TYPE_MISMATCH, + provider.getIntegerEvaluation("ratio", 0, context).getErrorCode()); + + // ...but an integer is a float. Widening is lossless, so it is allowed. + assertEquals(10000.0, provider.getDoubleEvaluation("price", 0.0, context).getValue()); + + // A string is not a boolean and a number is not a boolean. Python used to coerce both. + assertEquals(ErrorCode.TYPE_MISMATCH, + provider.getBooleanEvaluation("currency", false, context).getErrorCode()); + assertEquals(ErrorCode.TYPE_MISMATCH, + provider.getBooleanEvaluation("price", false, context).getErrorCode()); + + // A primitive is not an object. This used to succeed via Value.objectToValue. + assertEquals(ErrorCode.TYPE_MISMATCH, + provider.getObjectEvaluation("currency", new Value(""), context).getErrorCode()); + + // A number is not a string. + assertEquals(ErrorCode.TYPE_MISMATCH, + provider.getStringEvaluation("price", "none", context).getErrorCode()); + } + + @Test + void resolvesObjectAndArrayFlags() { + EvaluationContext context = new ImmutableContext(); + + Value meta = provider.getObjectEvaluation("meta", new Value(""), context).getValue(); + assertEquals("gold", meta.asStructure().getValue("tier").asString()); + + // Integers keep their type at every depth, rather than becoming 5.0 / 3.0. This is what + // pins the custom number strategy to nested values and not just the top level. + assertEquals(5, meta.asStructure().getValue("credits").asInteger()); + assertEquals(3, meta.asStructure().getValue("limits") + .asStructure().getValue("retries").asInteger()); + + Value tags = provider.getObjectEvaluation("tags", new Value(""), context).getValue(); + assertEquals(List.of(new Value("a"), new Value("b")), tags.asList()); + + Value counts = provider.getObjectEvaluation("counts", new Value(""), context).getValue(); + assertEquals(List.of(new Value(1), new Value(2)), counts.asList()); + } + + @Test + void appliesContextOverrides() { + EvaluationContext boston = contextOf(Map.of("city", new Value("Boston"))); + EvaluationContext berlin = contextOf(Map.of("city", new Value("Berlin"))); + + assertEquals("Dollar", provider.getStringEvaluation("currency", "none", boston).getValue()); + assertEquals(10000, provider.getIntegerEvaluation("price", 0, boston).getValue()); + assertEquals(20000, provider.getIntegerEvaluation("price", 0, berlin).getValue()); + } + + @Test + void reportsFlagNotFoundAndTypeMismatch() { + EvaluationContext context = new ImmutableContext(); + + var missing = provider.getStringEvaluation("nope", "fallback", context); + assertEquals(ErrorCode.FLAG_NOT_FOUND, missing.getErrorCode()); + + var mismatched = provider.getBooleanEvaluation("currency", false, context); + assertEquals(ErrorCode.TYPE_MISMATCH, mismatched.getErrorCode()); + } + + @Test + void resolvesAllFeaturesWithPrefixFilter() throws Exception { + Map all = provider.resolveAllFeatures(new ImmutableContext()); + assertEquals(7, all.size()); + + Map filtered = provider.resolveAllFeaturesWithFilter( + new ImmutableContext(), Optional.of(List.of("cur"))); + assertEquals(Map.of("currency", "\"Rupee\""), filtered); + } + + @Test + void globalContextIsOverriddenByTheEvaluationContext() throws Exception { + LocalResolutionProvider withGlobal = new LocalResolutionProvider( + new FileDataSource(configFile), Optional.empty(), new RefreshStrategy.Manual(1000)); + withGlobal.initialize(contextOf(Map.of("city", new Value("Boston")))); + + try { + // Global context alone resolves the Boston override... + assertEquals("Dollar", + withGlobal.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + // ...but the evaluation context wins where the two disagree. + assertEquals(20000, withGlobal.getIntegerEvaluation( + "price", 0, contextOf(Map.of("city", new Value("Berlin")))).getValue()); + } finally { + withGlobal.shutdown(); + } + } + + @Test + void servesItsCacheAsAFilteredDataSource() throws Exception { + // Prefix filter prunes the contexts and overrides that no longer contribute a key. + Config byPrefix = provider + .fetchFilteredConfig(Optional.empty(), Optional.of(List.of("currency")), Optional.empty()) + .getData().orElseThrow().getData(); + assertEquals(Map.of("currency", "\"Rupee\""), byPrefix.getDefaultConfigs()); + assertEquals(1, byPrefix.getContexts().size()); + + // Dimension filter keeps only the contexts that match. + Config byContext = provider + .fetchFilteredConfig(Optional.of(Map.of("city", "\"Berlin\"")), Optional.empty(), Optional.empty()) + .getData().orElseThrow().getData(); + assertEquals(1, byContext.getContexts().size()); + assertTrue(byContext.getOverrides().values().iterator().next().containsKey("price")); + + // Unfiltered still serves everything, stamped with the underlying fetch time. + ConfigData unfiltered = provider.fetchConfig(Optional.empty()).getData().orElseThrow(); + assertEquals(2, unfiltered.getData().getContexts().size()); + assertEquals(7, unfiltered.getData().getDefaultConfigs().size()); + } + + @Test + void experimentsFollowTheDataSourceCapability() { + // FileDataSource has no experiments, so neither does the provider built on it. + assertFalse(provider.supportsExperiments()); + assertThrows(SuperpositionError.class, () -> provider.fetchActiveExperiments(Optional.empty())); + assertThrows(SuperpositionError.class, () -> provider.fetchCandidateActiveExperiments( + Optional.empty(), Optional.empty(), Optional.empty())); + } + + @Test + void manualRefreshPicksUpFileChanges() throws Exception { + assertEquals("Rupee", provider.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + + Files.writeString(configFile, TOML_CONFIG.replace("\"Rupee\"", "\"Yen\"")); + provider.refresh(); + + assertEquals("Yen", provider.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + } + + @Test + void refreshIsBoundedByTheStrategyTimeout() throws Exception { + HangingDataSource source = new HangingDataSource(); + LocalResolutionProvider hanging = new LocalResolutionProvider( + source, Optional.empty(), new RefreshStrategy.Manual(300)); + hanging.initialize(new ImmutableContext()); + + try { + source.hang = true; + + long startedAt = System.nanoTime(); + SuperpositionError error = assertThrows(SuperpositionError.class, hanging::refresh); + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + + assertTrue(error.getMessage().contains("timed out"), error.getMessage()); + assertTrue(elapsedMs < 5_000, "refresh blocked for " + elapsedMs + "ms despite a 300ms timeout"); + + // The hung refresh must leave the last known good config in place. + assertEquals("Rupee", + hanging.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + } finally { + hanging.shutdown(); + } + } + + @Test + void aFailedRefreshLeavesTheProviderStaleUntilItRecovers() throws Exception { + HangingDataSource source = new HangingDataSource(); + LocalResolutionProvider flaky = new LocalResolutionProvider( + source, Optional.empty(), new RefreshStrategy.Manual(1000)); + flaky.initialize(new ImmutableContext()); + + try { + assertEquals(ProviderState.READY, flaky.getState()); + + source.fail = true; + assertThrows(SuperpositionError.class, flaky::refresh); + + // The cache is frozen at its last known good values, and STALE is the only signal a + // consumer has that the flags stopped tracking the source of truth. + assertEquals(ProviderState.STALE, flaky.getState()); + assertEquals("Rupee", + flaky.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + + source.fail = false; + flaky.refresh(); + assertEquals(ProviderState.READY, flaky.getState()); + } finally { + flaky.shutdown(); + } + } + + /** Serves config once, then hangs or fails on demand — standing in for a flaky backend. */ + private final class HangingDataSource extends FileDataSource { + volatile boolean hang = false; + volatile boolean fail = false; + + HangingDataSource() throws SuperpositionError { + super(configFile); + } + + @Override + public FetchResponse fetchFilteredConfig( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + if (fail) { + throw SuperpositionError.networkError("backend is down"); + } + if (hang) { + try { + Thread.sleep(60_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return super.fetchFilteredConfig(context, prefixFilter, ifModifiedSince); + } + } + + @Test + void watchStrategyRefreshesWhenTheFileChanges(@TempDir Path dir) throws Exception { + Path watched = dir.resolve("watched.toml"); + Files.writeString(watched, TOML_CONFIG); + + LocalResolutionProvider watching = new LocalResolutionProvider( + new FileDataSource(watched), Optional.empty(), new RefreshStrategy.Watch(1000, 100)); + watching.initialize(new ImmutableContext()); + + try { + assertEquals("Rupee", + watching.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + + Files.writeString(watched, TOML_CONFIG.replace("\"Rupee\"", "\"Yen\"")); + + // The JDK falls back to a polling WatchService on macOS, so allow for its interval. + String currency = null; + for (int attempt = 0; attempt < 60 && !"Yen".equals(currency); attempt++) { + Thread.sleep(500); + currency = watching.getStringEvaluation("currency", "none", new ImmutableContext()).getValue(); + } + assertEquals("Yen", currency, "watch strategy did not pick up the file change"); + } finally { + watching.shutdown(); + } + } + + @Test + void watchStrategyRejectsADataSourceThatCannotWatch() throws Exception { + LocalResolutionProvider unwatchable = new LocalResolutionProvider( + new NonWatchableSource(), Optional.empty(), new RefreshStrategy.Watch(1000, 50)); + + SuperpositionError error = assertThrows(SuperpositionError.class, + () -> unwatchable.initialize(new ImmutableContext())); + assertTrue(error.getMessage().contains("does not support watching"), error.getMessage()); + assertEquals(ProviderState.ERROR, unwatchable.getState()); + } + + /** A data source that serves config but, like the HTTP one, cannot watch for changes. */ + private final class NonWatchableSource extends FileDataSource { + NonWatchableSource() throws SuperpositionError { + super(configFile); + } + + @Override + public Optional watch() { + return Optional.empty(); + } + } +} diff --git a/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/provider/OnDemandTtlTest.java b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/provider/OnDemandTtlTest.java new file mode 100644 index 000000000..c0f05c1af --- /dev/null +++ b/clients/java/openfeature-provider/src/test/java/io/juspay/superposition/openfeature/provider/OnDemandTtlTest.java @@ -0,0 +1,130 @@ +package io.juspay.superposition.openfeature.provider; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.openfeature.sdk.ImmutableContext; +import io.juspay.superposition.openfeature.data_source.ConfigData; +import io.juspay.superposition.openfeature.data_source.FetchResponse; +import io.juspay.superposition.openfeature.data_source.FileDataSource; +import io.juspay.superposition.openfeature.error.SuperpositionError; +import io.juspay.superposition.openfeature.options.RefreshStrategy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The OnDemand TTL is measured from the last check, not from the config's last + * change. + * + *

An HTTP data source stamps {@code ConfigData.fetchedAt} with the server's + * {@code last-modified} — when the config last changed. Driving the TTL off that made a config + * which had been stable for longer than the TTL permanently "stale": every evaluation fired a + * fetch, the 304 that came back left the timestamp untouched, and the next evaluation fired + * another. The more stable the config, the more load it generated. + * + *

{@code FileDataSource} stamps {@code Instant.now()} instead, which is why the file-based + * tests never saw this. {@link ServerLike} reproduces the HTTP behaviour. + */ +class OnDemandTtlTest { + + private static final String TOML = """ + [default-configs] + currency = { value = "Rupee", schema = { type = "string" } } + [dimensions] + city = { position = 1, schema = { type = "string" }, type = "REGULAR" } + """; + + /** Behaves like HttpDataSource: fetchedAt is the server's last-modified, repeats get a 304. */ + static final class ServerLike extends FileDataSource { + final AtomicInteger fetches = new AtomicInteger(); + + /** The config last changed an hour ago — and is perfectly current. */ + final Instant serverLastModified = Instant.now().minus(1, ChronoUnit.HOURS); + + ServerLike(Path path) throws SuperpositionError { + super(path); + } + + @Override + public FetchResponse fetchFilteredConfig( + Optional> context, + Optional> prefixFilter, + Optional ifModifiedSince) throws SuperpositionError { + fetches.incrementAndGet(); + if (ifModifiedSince.isPresent()) { + return FetchResponse.notModified(); + } + ConfigData fresh = super.fetchFilteredConfig(context, prefixFilter, Optional.empty()) + .getData().orElseThrow(); + return FetchResponse.data(new ConfigData(fresh.getData(), serverLastModified)); + } + } + + private static ServerLike sourceIn(Path dir) throws Exception { + Path file = dir.resolve("config.toml"); + Files.writeString(file, TOML); + return new ServerLike(file); + } + + @Test + void anUnchangedConfigIsNotRefetchedWithinTheTtl(@TempDir Path dir) throws Exception { + ServerLike source = sourceIn(dir); + LocalResolutionProvider provider = new LocalResolutionProvider( + source, Optional.empty(), new RefreshStrategy.OnDemand(5_000, 60_000)); + provider.initialize(new ImmutableContext()); + + try { + int afterInit = source.fetches.get(); + + for (int i = 0; i < 5; i++) { + provider.getStringEvaluation("currency", "none", new ImmutableContext()); + } + + assertEquals(afterInit, source.fetches.get(), + "evaluations inside the TTL must not hit the source, however old the config is"); + } finally { + provider.shutdown(); + } + } + + @Test + void theTtlStillExpiresAndA304RestartsIt(@TempDir Path dir) throws Exception { + ServerLike source = sourceIn(dir); + LocalResolutionProvider provider = new LocalResolutionProvider( + source, Optional.empty(), new RefreshStrategy.OnDemand(5_000, 100)); + provider.initialize(new ImmutableContext()); + + try { + int afterInit = source.fetches.get(); + + // Inside the TTL: free. + provider.getStringEvaluation("currency", "none", new ImmutableContext()); + assertEquals(afterInit, source.fetches.get()); + + Thread.sleep(200); + + // Past the TTL: exactly one re-check. + provider.getStringEvaluation("currency", "none", new ImmutableContext()); + assertEquals(afterInit + 1, source.fetches.get(), + "an expired TTL must re-check the source"); + + // The 304 it got back is a successful check, so the clock restarts and this is free. + provider.getStringEvaluation("currency", "none", new ImmutableContext()); + assertEquals(afterInit + 1, source.fetches.get(), + "a 304 is a successful check and must restart the TTL"); + + // And the cache kept serving the last known good value throughout. + assertEquals("Rupee", + provider.getStringEvaluation("currency", "none", new ImmutableContext()).getValue()); + } finally { + provider.shutdown(); + } + } +} diff --git a/clients/java/provider-sdk-tests/config.toml b/clients/java/provider-sdk-tests/config.toml new file mode 100644 index 000000000..ea596515c --- /dev/null +++ b/clients/java/provider-sdk-tests/config.toml @@ -0,0 +1,59 @@ +[default-configs] +currency = { value = "Rupee", schema = { enum = [ + "Rupee", + "Dollar", + "Euro", +], type = "string" } } +price = { value = 10000, schema = { minimum = 0, type = "number" } } + + +[dimensions] +city = { position = 3, schema = { type = "string" }, type = "REGULAR" } +customers = { position = 1, schema = { definitions = { gold = { in = [ + { var = "name" }, + [ + "Angit", + "Bhrey", + ], +] }, platinum = { in = [ + { var = "name" }, + [ + "Agush", + "Sauyav", + ], +] } }, enum = [ + "platinum", + "gold", + "otherwise", +], type = "string" }, type = "LOCAL_COHORT:name" } +name = { position = 2, schema = { type = "string" }, type = "REGULAR" } +variantIds = { position = 0, schema = { pattern = ".*", type = "string" }, type = "REGULAR" } + + +[[overrides]] +_context_ = { customers = "platinum" } +price = 5000 + +[[overrides]] +_context_ = { customers = "gold" } +price = 8000 + +[[overrides]] +_context_ = { name = "karbik" } +price = 1 + +[[overrides]] +_context_ = { city = "Boston" } +currency = "Dollar" + +[[overrides]] +_context_ = { city = "Berlin" } +currency = "Euro" + +[[overrides]] +_context_ = { city = "Kolkata", variantIds = "7445772794710855680-test-control" } +price = 8000 + +[[overrides]] +_context_ = { city = "Kolkata", variantIds = "7445772794710855680-test-experimental" } +price = 7000 diff --git a/clients/java/provider-sdk-tests/src/main/kotlin/io/juspay/superposition/providertests/Main.kt b/clients/java/provider-sdk-tests/src/main/kotlin/io/juspay/superposition/providertests/Main.kt index a40070126..90f8e5e13 100644 --- a/clients/java/provider-sdk-tests/src/main/kotlin/io/juspay/superposition/providertests/Main.kt +++ b/clients/java/provider-sdk-tests/src/main/kotlin/io/juspay/superposition/providertests/Main.kt @@ -7,7 +7,15 @@ import io.juspay.superposition.client.SuperpositionClient import io.juspay.superposition.model.* import io.juspay.superposition.openfeature.SuperpositionOpenFeatureProvider import io.juspay.superposition.openfeature.SuperpositionProviderOptions +import io.juspay.superposition.openfeature.data_source.FileDataSource +import io.juspay.superposition.openfeature.data_source.HttpDataSource import io.juspay.superposition.openfeature.options.RefreshStrategy +import io.juspay.superposition.openfeature.options.SuperpositionOptions +import io.juspay.superposition.openfeature.provider.LocalResolutionProvider +import io.juspay.superposition.openfeature.provider.SuperpositionAPIProvider +import dev.openfeature.sdk.FeatureProvider +import java.nio.file.Path +import java.util.Optional import kotlinx.coroutines.runBlocking import software.amazon.smithy.java.client.core.endpoint.EndpointResolver import software.amazon.smithy.java.core.schema.Unit @@ -351,16 +359,212 @@ class Main { println("\n=== Setup complete ===\n") } + /** + * Builds the providers that resolve against this workspace and runs the shared suite against + * each, mirroring the Rust and Python integration tests: + * - LocalResolutionProvider over HTTP (no fallback) + * - SuperpositionAPIProvider (server-side resolution) + * - LocalResolutionProvider whose HTTP primary is misconfigured, falling back to a file + */ + private fun runProviderSuite(orgId: String, workspaceId: String) { + val refreshStrategy = RefreshStrategy.Polling(10000, 5000) + + val httpOptions = SuperpositionOptions( + endpoint = config.endpoint, + token = config.token, + orgId = orgId, + workspaceId = workspaceId, + ) + + // Wrong token and workspace: every call to the primary fails, forcing the fallback. + val wrongHttpOptions = SuperpositionOptions( + endpoint = config.endpoint, + token = "12345678", + orgId = orgId, + workspaceId = "workspace_id", + ) + + try { + runProviderTests( + "LocalResolutionProvider with HTTP data source (no fallback)", + LocalResolutionProvider( + HttpDataSource(httpOptions), Optional.empty(), refreshStrategy), + testExperiments = true + ) + + runProviderTests( + "SuperpositionAPIProvider", + SuperpositionAPIProvider(httpOptions), + testExperiments = true + ) + + // The file fallback carries no experiments, so the experiment case is skipped here. + runProviderTests( + "LocalResolutionProvider with failing HTTP data source, falling back to file", + LocalResolutionProvider( + HttpDataSource(wrongHttpOptions), + Optional.of(FileDataSource(Path.of("config.toml"))), + refreshStrategy), + testExperiments = false + ) + } finally { + OpenFeatureAPI.getInstance().shutdown() + println("OpenFeature closed successfully") + } + } + + /** + * The shared assertions, run once per provider. Every provider resolves the same workspace, + * so they must all agree on the outcome. + */ + private fun runProviderTests(label: String, provider: FeatureProvider, testExperiments: Boolean) { + println("\n=== Testing $label ===\n") + + OpenFeatureAPI.getInstance().setProviderAndWait(provider) + println("Provider initialized successfully\n") + + val ofClient = OpenFeatureAPI.getInstance().client + + // Test 1: Default values (no context) + println("Test 1: Default values (no context)") + run { + val context = ImmutableContext() + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 10000.0) { "Default price should be 10000, got $price" } + check(currency == "Rupee") { "Default currency should be Rupee, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 2: Platinum customer - Agush, no city + println("Test 2: Platinum customer - Agush (no city)") + run { + val context = ImmutableContext(mapOf("name" to Value("Agush"))) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 5000.0) { "Price should be 5000 (platinum customer), got $price" } + check(currency == "Rupee") { "Currency should be default Rupee, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 3: Platinum customer - Sauyav, with city Boston + println("Test 3: Platinum customer - Sauyav with city Boston") + run { + val context = ImmutableContext(mapOf( + "name" to Value("Sauyav"), + "city" to Value("Boston") + )) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 5000.0) { "Price should be 5000, got $price" } + check(currency == "Dollar") { "Currency should be Dollar, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 4: Regular customer - John (no city) + println("Test 4: Regular customer - John (no city)") + run { + val context = ImmutableContext(mapOf("name" to Value("John"))) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 10000.0) { "Price should be default 10000, got $price" } + check(currency == "Rupee") { "Currency should be default Rupee, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 5: Platinum customer - Sauyav with city Berlin + println("Test 5: Platinum customer - Sauyav with city Berlin") + run { + val context = ImmutableContext(mapOf( + "name" to Value("Sauyav"), + "city" to Value("Berlin") + )) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 5000.0) { "Price should be 5000, got $price" } + check(currency == "Euro") { "Currency should be Euro in Berlin, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 6: Regular customer - John with city Boston + println("Test 6: Regular customer - John with city Boston") + run { + val context = ImmutableContext(mapOf( + "name" to Value("John"), + "city" to Value("Boston") + )) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 10000.0) { "Price should be default 10000, got $price" } + check(currency == "Dollar") { "Currency should be Dollar in Boston, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 7: Edge case customer - karbik (specific override) + println("Test 7: Edge case customer - karbik (specific override)") + run { + val context = ImmutableContext(mapOf("name" to Value("karbik"))) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 1.0) { "Price should be 1 for karbik, got $price" } + check(currency == "Rupee") { "Currency should be default Rupee, got $currency" } + println(" ✓ Test passed\n") + } + + // Test 8: Edge case customer - karbik with city Boston + println("Test 8: Edge case customer - karbik with city Boston") + run { + val context = ImmutableContext(mapOf( + "name" to Value("karbik"), + "city" to Value("Boston") + )) + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + + check(price == 1.0) { "Price should be 1 for karbik, got $price" } + check(currency == "Dollar") { "Currency should be Dollar in Boston, got $currency" } + println(" ✓ Test passed\n") + } + + if (testExperiments) { + // Test 9: Experimentation - Bangalore customer + println("Test 9: Experimentation - Bangalore customer") + run { + val context = ImmutableContext(mapOf( + "city" to Value("Bangalore"), + "targetingKey" to Value("test") + )) + + val price = ofClient.getDoubleValue("price", 0.0, context) + val currency = ofClient.getStringValue("currency", "", context) + println(" Retrieved price: $price and currency: $currency for Bangalore customer") + + check(price == 7000.0 || price == 8000.0) { "Price should be either 7000 (experimental) or 8000 (control), got $price" } + check(currency == "Rupee") { "Currency should be default Rupee, got $currency" } + println(" ✓ Experiment Test passed\n") + } + } + + println("=== All tests passed for $label ===\n") + } + private fun runDemo(orgId: String, workspaceId: String) { val providerOptions = SuperpositionProviderOptions.builder() .orgId(orgId) .workspaceId(workspaceId) .endpoint("http://localhost:8080") .token("12345678") - .refreshStrategy(RefreshStrategy.Polling.of(10000, 5000)) + .refreshStrategy(RefreshStrategy.Polling(10000, 5000)) .experimentationOptions( SuperpositionProviderOptions.ExperimentationOptions.builder() - .refreshStrategy(RefreshStrategy.Polling.of(10000, 5000)) + .refreshStrategy(RefreshStrategy.Polling(10000, 5000)) .build() ) .build() @@ -504,7 +708,6 @@ class Main { } println("\n=== All tests passed! ===\n") - exitProcess(0) } catch (error: Exception) { println("\n❌ Error running tests: $error") error.printStackTrace() @@ -541,6 +744,7 @@ class Main { val ORG_ID = create_organisation() setupWithSDK(ORG_ID, WORKSPACE_ID) runDemo(ORG_ID, WORKSPACE_ID) + runProviderSuite(ORG_ID, WORKSPACE_ID) } catch (error: Exception) { println("\n❌ Test suite failed: $error") error.printStackTrace()