fluxgate 1.0.0

Client for the FluxGate feature toggle edge server, over OFREP, with an optional OpenFeature provider
Documentation
#![cfg(feature = "openfeature")]

use fluxgate::FluxGateClient;
use fluxgate::openfeature::FluxGateProvider;
use open_feature::provider::FeatureProvider;
use open_feature::{EvaluationContext, EvaluationErrorCode, EvaluationReason, OpenFeature};
use serde_json::json;
use wiremock::matchers::{body_json, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

async fn edge() -> MockServer {
    let server = MockServer::start().await;
    let flags = [
        (
            "new-checkout",
            json!({"key": "new-checkout", "value": true, "reason": "TARGETING_MATCH"}),
        ),
        (
            "checkout-theme",
            json!({"key": "checkout-theme", "value": "express", "variant": "express", "reason": "SPLIT", "metadata": {"team": "web"}}),
        ),
        (
            "limit",
            json!({"key": "limit", "value": 10, "variant": "ten", "reason": "STATIC"}),
        ),
        (
            "config",
            json!({"key": "config", "value": {"a": 1, "b": [true]}, "variant": "v1", "reason": "STATIC"}),
        ),
        (
            "off",
            json!({"key": "off", "value": false, "reason": "DISABLED"}),
        ),
    ];
    for (key, body) in flags {
        Mock::given(path(format!("/ofrep/v1/evaluate/flags/{key}")))
            .respond_with(ResponseTemplate::new(200).set_body_json(body))
            .mount(&server)
            .await;
    }
    Mock::given(path("/ofrep/v1/evaluate/flags/missing"))
        .respond_with(
            ResponseTemplate::new(404).set_body_json(json!({"errorCode": "FLAG_NOT_FOUND"})),
        )
        .mount(&server)
        .await;
    server
}

fn provider(server: &MockServer) -> FluxGateProvider {
    FluxGateProvider::new(FluxGateClient::new(&server.uri(), "client-1.api-key").unwrap())
}

fn user() -> EvaluationContext {
    EvaluationContext::default()
        .with_targeting_key("user-1")
        .with_custom_field("country", "CA")
}

#[tokio::test]
async fn resolves_every_type() {
    let server = edge().await;
    let provider = provider(&server);

    let enabled = provider
        .resolve_bool_value("new-checkout", &user())
        .await
        .unwrap();
    assert!(enabled.value);
    assert_eq!(enabled.reason, Some(EvaluationReason::TargetingMatch));

    let theme = provider
        .resolve_string_value("checkout-theme", &user())
        .await
        .unwrap();
    assert_eq!(theme.value, "express");
    assert_eq!(theme.variant.as_deref(), Some("express"));
    assert_eq!(theme.reason, Some(EvaluationReason::Split));
    assert_eq!(theme.flag_metadata.unwrap().values.len(), 1);

    assert_eq!(
        provider
            .resolve_int_value("limit", &user())
            .await
            .unwrap()
            .value,
        10
    );
    assert_eq!(
        provider
            .resolve_float_value("limit", &user())
            .await
            .unwrap()
            .value,
        10.0
    );

    let config = provider
        .resolve_struct_value("config", &user())
        .await
        .unwrap()
        .value;
    assert_eq!(config.fields["a"], open_feature::Value::Int(1));
}

#[tokio::test]
async fn maps_errors() {
    let server = edge().await;
    let provider = provider(&server);

    let missing = provider
        .resolve_bool_value("missing", &user())
        .await
        .unwrap_err();
    assert_eq!(missing.code, EvaluationErrorCode::FlagNotFound);

    let mismatch = provider
        .resolve_int_value("checkout-theme", &user())
        .await
        .unwrap_err();
    assert_eq!(mismatch.code, EvaluationErrorCode::TypeMismatch);

    let no_key = provider
        .resolve_bool_value("new-checkout", &EvaluationContext::default())
        .await
        .unwrap_err();
    assert_eq!(no_key.code, EvaluationErrorCode::TargetingKeyMissing);
}

#[tokio::test]
async fn the_off_marker_is_false_for_booleans_and_the_default_otherwise() {
    let server = edge().await;
    let provider = provider(&server);

    let off = provider.resolve_bool_value("off", &user()).await.unwrap();
    assert!(!off.value);
    assert_eq!(off.reason, Some(EvaluationReason::Disabled));

    let error = provider
        .resolve_string_value("off", &user())
        .await
        .unwrap_err();
    assert_eq!(
        error.code,
        EvaluationErrorCode::General("DISABLED".to_owned())
    );
}

#[tokio::test]
async fn works_through_the_open_feature_api() {
    let server = edge().await;
    Mock::given(path("/ofrep/v1/evaluate/flags/country-check"))
        .and(body_json(
            json!({"context": {"targetingKey": "user-1", "country": "CA"}}),
        ))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(json!({"key": "country-check", "value": "CA", "variant": "ca"})),
        )
        .mount(&server)
        .await;

    let mut api = OpenFeature::default();
    api.set_provider(provider(&server)).await;
    let client = api.create_client();

    assert_eq!(
        client
            .get_string_value("country-check", Some(&user()), None)
            .await
            .unwrap(),
        "CA"
    );
    assert_eq!(
        client
            .get_string_value("off", Some(&user()), None)
            .await
            .unwrap_or_else(|_| "classic".to_owned()),
        "classic"
    );
}