Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ workflows:
test:
jobs:
- build-linux
- test-linux:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping 1.8 as it isn't supported by the OF Sdk.

name: Java 8 - Linux - OpenJDK
docker-image: cimg/openjdk:8.0
requires:
- build-linux
- test-linux:
name: Java 11 - Linux - OpenJDK
docker-image: cimg/openjdk:11.0
Expand All @@ -36,7 +31,7 @@ workflows:
jobs:
build-linux:
docker:
- image: cimg/openjdk:8.0
- image: cimg/openjdk:11.0
steps:
- checkout
- run: java -version
Expand Down Expand Up @@ -81,7 +76,7 @@ jobs:

packaging:
docker:
- image: cimg/openjdk:8.0
- image: cimg/openjdk:11.0
steps:
- run: java -version
- run: sudo apt-get install make -y -q
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ Your project will need compatible versions of the LaunchDarkly Server-Side SDK f

Example gradle dependencies:
```groovy
implementation group: 'com.launchdarkly', name: 'launchdarkly-java-server-sdk', version: '[6.0.0, 7.0.0)'
implementation 'dev.openfeature:sdk:[1.2.0,2.0.0)'
implementation group: 'com.launchdarkly', name: 'launchdarkly-java-server-sdk', version: '[7.1.0, 8.0.0)'
implementation 'dev.openfeature:sdk:[1.7.0,2.0.0)'
```

### Installation
Expand Down Expand Up @@ -54,8 +54,7 @@ import com.launchdarkly.openfeature.serverprovider.Provider;

public class Main {
public static void main(String[] args) {
LDClient ldClient = new LDClient("my-sdk-key");
OpenFeatureAPI.getInstance().setProvider(new Provider(ldClient));
OpenFeatureAPI.getInstance().setProvider(new Provider("my-sdk-key"));

// Refer to OpenFeature documentation for getting a client and performing evaluations.
}
Expand Down Expand Up @@ -86,6 +85,16 @@ There are several other attributes which have special functionality within a sin
- A key of `anonymous`. Must be a boolean value. [Equivalent to the 'anonymous' builder method in the SDK.](https://launchdarkly.github.io/java-server-sdk/com/launchdarkly/sdk/ContextBuilder.html#anonymous(boolean))
- A key of `name`. Must be a string. [Equivalent to the 'name' builder method in the SDK.](https://launchdarkly.github.io/java-server-sdk/com/launchdarkly/sdk/ContextBuilder.html#name(java.lang.String))

### Initialization and Shutdown

The LaunchDarkly supports Initialization and Shutdown using the OpenFeature API. The provider begins initialization as soon as it is constructed, and the underlying LaunchDarkly SDK will block execution based on the configured start wait time. If you wish to defer the blocking behavior, then you can use the `startWait` function when building the `LDConfig`.

OpenFeature will report when the provider is ready, and additionally the `setProviderAndWait` function of the OpenFeature
API can be used to wait until the provider is ready, or it has encountered a permanent error.

It the provider has been shutdown, because the OpenFeature API has been shutdown, or because the provider was no longer in use by the OpenFeature API, then the underlying LaunchDarkly SDK will be closed.
This is an important consideration if you are using the `getLdClient` method of the provider to access the underlying SDK instance.

### Examples

#### A single user context
Expand Down
25 changes: 22 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ java {
repositories {
mavenLocal()
mavenCentral()
// Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots:
maven { url "https://oss.sonatype.org/content/groups/public/" }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To allow using pre-releases when we want to.

}

test {
Expand All @@ -41,6 +43,15 @@ checkstyle {
checkstyleTest.enabled = false
}

task generateJava(type: Copy) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template code generation is so we have access to the version to put into the wrapper.

// This updates Version.java
from 'src/templates/java'
into "src/main/java"
filter(org.apache.tools.ant.filters.ReplaceTokens, tokens: [VERSION: version.toString()])
}

compileJava.dependsOn 'generateJava'

publishing {
publications {
mavenJava(MavenPublication) {
Expand Down Expand Up @@ -111,11 +122,19 @@ dependencies {
// This dependency is used internally, and not exposed to consumers on their own compile classpath.
implementation 'com.google.guava:guava:23.0'

implementation group: 'com.launchdarkly', name: 'launchdarkly-java-server-sdk', version: '[6.0.0, 7.0.0)'
implementation 'dev.openfeature:sdk:[1.2.0,2.0.0)'
implementation group: 'com.launchdarkly', name: 'launchdarkly-java-server-sdk', version: '[7.1.0, 8.0.0)'

implementation 'dev.openfeature:sdk:[1.7.0,2.0.0)'

// Use JUnit test framework
testImplementation 'junit:junit:4.12'
testImplementation(platform('org.junit:junit-bom:5.10.0'))
testImplementation('org.junit.jupiter:junit-jupiter')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted some things from JUnit 5, so I moved forward.

testImplementation "org.mockito:mockito-core:3.+"
}

test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ private String getTargetingKey(String targetingKey, Value keyAsValue) {
targetingKey = !Objects.equals(targetingKey, "") ? targetingKey : keyAsValue.asString();
}

if (targetingKey == null || targetingKey.equals("")) {
if (targetingKey == null || targetingKey.isEmpty()) {
logger.error("The EvaluationContext must contain either a 'targetingKey' or a 'key' and the type " + "must be a string.");
}
return targetingKey;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
package com.launchdarkly.openfeature.serverprovider;

import com.launchdarkly.logging.LDLogAdapter;
import com.launchdarkly.logging.LDLogger;
import com.launchdarkly.sdk.EvaluationDetail;
import com.launchdarkly.sdk.LDValue;
import com.launchdarkly.sdk.server.Components;
import com.launchdarkly.sdk.server.LDClient;
import com.launchdarkly.sdk.server.LDConfig;
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration;
import dev.openfeature.sdk.*;

import java.io.IOException;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.concurrent.TimeoutException;

/**
* An OpenFeature {@link FeatureProvider} which enables the use of the LaunchDarkly Server-Side SDK for Java
* with OpenFeature.
* <pre><code>
*import dev.openfeature.sdk.OpenFeatureAPI;
*import com.launchdarkly.sdk.server.LDClient;
* import dev.openfeature.sdk.OpenFeatureAPI;
*
*public class Main {
* public class Main {
* public static void main(String[] args) {
* LDClient ldClient = new LDClient("my-sdk-key");
* OpenFeatureAPI.getInstance().setProvider(new Provider(ldClient));
* OpenFeatureAPI.getInstance().setProvider(new Provider("fake-key"));
*
* // Refer to OpenFeature documentation for getting a client and performing evaluations.
* }
*}
* }
* </code></pre>
*/
public class Provider implements FeatureProvider {
public class Provider extends EventProvider {
private static final class ProviderMetaData implements Metadata {
@Override
public String getName() {
Expand All @@ -43,39 +47,38 @@ public String getName() {

private final LDClientInterface client;

private ProviderState state = ProviderState.NOT_READY;

/**
* Create a provider with the given LaunchDarkly client and provider configuration.
* <pre><code>
* // Using the provider with a custom log level.
* new Provider(ldclient, ProviderConfiguration
* .builder()
* .logging(Components.logging().level(LDLogLevel.INFO)
* .build());
* </code></pre>
* Create a provider with the specified SDK and default configuration.
* <p>
* If you need to specify any configuration use {@link Provider#Provider(String, LDConfig)} instead.
*
* @param client A {@link LDClient} instance.
* @param config Configuration for the provider.
* @param sdkKey the SDK key for your LaunchDarkly environment
*/
public Provider(LDClientInterface client, ProviderConfiguration config) {
this.client = client;
LoggingConfiguration loggingConfig = config.getLoggingConfigurationFactory().build(null);
LDLogAdapter adapter = loggingConfig.getLogAdapter();
logger = LDLogger.withAdapter(adapter, loggingConfig.getBaseLoggerName());

evaluationContextConverter = new EvaluationContextConverter(logger);
evaluationDetailConverter = new EvaluationDetailConverter(logger);
valueConverter = new ValueConverter(logger);
public Provider(String sdkKey) {
this(sdkKey, new LDConfig.Builder().build());
}

/**
* Create a provider with the given LaunchDarkly client.
* <p>
* The provider will be created with default configuration.
* Crate a provider with the specified SDK key and configuration.
*
* @param client A {@link LDClient} instance.
* @param sdkKey the SDK key for your LaunchDarkly environment
* @param config a client configuration object
*/
public Provider(LDClientInterface client) {
this(client, ProviderConfiguration.builder().build());
public Provider(String sdkKey, LDConfig config) {
this(new LDClient(sdkKey, LDConfig.Builder.fromConfig(config)
.wrapper(Components.wrapperInfo()
.wrapperName("open-feature-java-server")
.wrapperVersion(Version.SDK_VERSION)).build()));
}

Provider(LDClientInterface client) {
this.client = client;
logger = client.getLogger();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We added this in the migrations changes, which is really handy for OpenFeature.

evaluationContextConverter = new EvaluationContextConverter(logger);
evaluationDetailConverter = new EvaluationDetailConverter(logger);
valueConverter = new ValueConverter(logger);
}

@Override
Expand All @@ -86,41 +89,121 @@ public Metadata getMetadata() {
@Override
public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
EvaluationDetail<Boolean> detail
= this.client.boolVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);
= this.client.boolVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like formatting always changes even when I am using the same rules and the same tools, but at different points in time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDEs are weird.


return evaluationDetailConverter.toEvaluationDetails(detail);
}

@Override
public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
EvaluationDetail<String> detail
= this.client.stringVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);
= this.client.stringVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);

return evaluationDetailConverter.toEvaluationDetails(detail);
}

@Override
public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
EvaluationDetail<Integer> detail
= this.client.intVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);
= this.client.intVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);

return evaluationDetailConverter.toEvaluationDetails(detail);
}

@Override
public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
EvaluationDetail<Double> detail
= this.client.doubleVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);
= this.client.doubleVariationDetail(key, evaluationContextConverter.toLdContext(ctx), defaultValue);

return evaluationDetailConverter.toEvaluationDetails(detail);
}

@Override
public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
EvaluationDetail<LDValue> detail
= this.client.jsonValueVariationDetail(
key, evaluationContextConverter.toLdContext(ctx), valueConverter.toLdValue(defaultValue));
= this.client.jsonValueVariationDetail(
key, evaluationContextConverter.toLdContext(ctx), valueConverter.toLdValue(defaultValue));

return evaluationDetailConverter.toEvaluationDetailsLdValue(detail);
}

@Override
public ProviderState getState() {
return state;
}

@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
// If we are ready, then set the state. Don't return, because we still need to listen for future
// changes.
if (client.isInitialized()) {
state = ProviderState.READY;
}

client.getFlagTracker().addFlagChangeListener(detail -> {
emitProviderConfigurationChanged(
ProviderEventDetails.builder().flagsChanged(Collections.singletonList(detail.getKey())).build());
});
// Listen for future status changes.
client.getDataSourceStatusProvider().addStatusListener((res) -> {
switch (res.getState()) {
// We will not re-enter INITIALIZING, but it is here to make the switch exhaustive.
case INITIALIZING: {
}
break;
case INTERRUPTED: {
state = ProviderState.STALE;
var message = res.getLastError() != null ? res.getLastError().getMessage() : "encountered an unknown error";
emitProviderStale(ProviderEventDetails.builder().message(message).build());
}
break;
case VALID: {
// If we are ready, then we don't want to emit it again. Other conditions we may be updating the
// reason we are stale or interrupted, so we want to emit an event each time.
if (state != ProviderState.READY) {
state = ProviderState.READY;
emitProviderReady(ProviderEventDetails.builder().build());
}
}
break;
case OFF: {
// Currently there is not a shutdown state.
// Our client/provider cannot be restarted, so we just go to error.
state = ProviderState.ERROR;
emitProviderError(ProviderEventDetails.builder().message("Provider shutdown").build());
}
}
});
if (state == ProviderState.READY) {
return;
}

boolean initialized = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID,
ChronoUnit.FOREVER.getDuration());
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun fact, FOREVER, is just the most digits that can put in without an overflow.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waitFor also should return if we hit the "OFF" state from a terminal error.


if (!initialized) {
// Here we throw an exception for the OpenFeature SDK, which will handle emitting an event.
throw new RuntimeException("Failed to initialize LaunchDarkly client.");
}
}

@Override
public void shutdown() {
try {
client.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

/**
* Get the LaunchDarkly client associated with this provider.
* <p>
* This can be used to access LaunchDarkly features which are not available in OpenFeature.
*
* @return the launchdarkly client instance
*/
public LDClientInterface getLdClient() {
return client;
}
}
Loading