unleash-edge 16.0.4

Unleash edge is a proxy for Unleash. It can return both evaluated feature toggles as well as the raw data from Unleash's client API
Documentation
use crate::types::{
    EdgeJsonResult, EdgeToken, TokenStrings, TokenValidationStatus, ValidatedTokens,
};
use crate::{
    auth::token_validator::TokenValidator,
    metrics::client_metrics::MetricsCache,
    types::{BatchMetricsRequestBody, EdgeResult},
};
use actix_web::{
    post,
    web::{self, Data, Json},
    HttpRequest, HttpResponse,
};
use dashmap::DashMap;
use utoipa;

#[utoipa::path(
    path = "/edge/validate",
    responses(
        (status = 200, description = "Return valid tokens from list of tokens passed in to validate", body = ValidatedTokens)
    ),
    request_body = TokenStrings
)]
#[post("/validate")]
pub async fn validate(
    token_cache: web::Data<DashMap<String, EdgeToken>>,
    req: HttpRequest,
    tokens: Json<TokenStrings>,
) -> EdgeJsonResult<ValidatedTokens> {
    let maybe_validator = req.app_data::<Data<TokenValidator>>();
    match maybe_validator {
        Some(validator) => {
            let known_tokens = validator
                .register_tokens(tokens.into_inner().tokens)
                .await?;
            Ok(Json(ValidatedTokens {
                tokens: known_tokens
                    .into_iter()
                    .filter(|t| t.status == TokenValidationStatus::Validated)
                    .collect(),
            }))
        }
        None => {
            let tokens_to_check = tokens.into_inner().tokens;
            let valid_tokens: Vec<EdgeToken> = tokens_to_check
                .iter()
                .filter_map(|t| token_cache.get(t).map(|e| e.value().clone()))
                .collect();
            Ok(Json(ValidatedTokens {
                tokens: valid_tokens,
            }))
        }
    }
}

#[utoipa::path(
    path = "/edge/metrics",
    responses(
        (status = 202, description = "Accepted the posted metrics")
    ),
    request_body = BatchMetricsRequestBody,
    security(
        ("Authorization" = [])
    )
)]
#[post("/metrics")]
pub async fn metrics(
    batch_metrics_request: web::Json<BatchMetricsRequestBody>,
    metrics_cache: web::Data<MetricsCache>,
) -> EdgeResult<HttpResponse> {
    metrics_cache.sink_metrics(&batch_metrics_request.metrics);
    Ok(HttpResponse::Accepted().finish())
}

pub fn configure_edge_api(cfg: &mut web::ServiceConfig) {
    cfg.service(validate).service(metrics);
}

#[cfg(test)]
mod tests {
    use crate::auth::token_validator::TokenValidator;
    use crate::edge_api::{metrics, validate};
    use crate::metrics::client_metrics::MetricsCache;
    use crate::types::{
        BatchMetricsRequestBody, EdgeToken, TokenStrings, TokenType, TokenValidationStatus,
        ValidatedTokens,
    };
    use actix_web::http::header::ContentType;
    use actix_web::web::Json;
    use actix_web::{test, web, App};
    use chrono::Utc;
    use dashmap::DashMap;
    use reqwest::StatusCode;
    use std::sync::Arc;
    use unleash_types::client_metrics::{ClientApplication, ClientMetricsEnv};

    #[tokio::test]
    pub async fn posting_bulk_metrics_gets_cached_properly() {
        let metrics_cache = Arc::new(MetricsCache::default());
        let token_cache: Arc<DashMap<String, EdgeToken>> = Arc::new(DashMap::default());
        let app = test::init_service(
            App::new()
                .app_data(web::Data::from(metrics_cache.clone()))
                .app_data(web::Data::from(token_cache.clone()))
                .service(web::scope("/edge").service(metrics).service(validate)),
        )
        .await;

        let request_body = BatchMetricsRequestBody {
            applications: vec![
                ClientApplication::new("app_one", 10),
                ClientApplication::new("app_two", 20),
            ],
            metrics: vec![
                ClientMetricsEnv {
                    feature_name: "test_feature_one".to_string(),
                    app_name: "test_application".to_string(),
                    environment: "development".to_string(),
                    timestamp: Utc::now(),
                    yes: 100,
                    no: 50,
                    variants: Default::default(),
                },
                ClientMetricsEnv {
                    feature_name: "test_feature_two".to_string(),
                    app_name: "test_application".to_string(),
                    environment: "production".to_string(),
                    timestamp: Utc::now(),
                    yes: 1000,
                    no: 800,
                    variants: Default::default(),
                },
            ],
        };
        let req = test::TestRequest::post()
            .uri("/edge/metrics")
            .insert_header(ContentType::json())
            .set_json(Json(request_body))
            .to_request();
        let res = test::call_service(&app, req).await;
        assert_eq!(res.status(), StatusCode::ACCEPTED);
    }

    #[tokio::test]
    pub async fn validating_incorrect_tokens_returns_empty_list() {
        let metrics_cache = Arc::new(MetricsCache::default());
        let token_cache: Arc<DashMap<String, EdgeToken>> = Arc::new(DashMap::default());
        let app = test::init_service(
            App::new()
                .app_data(web::Data::from(metrics_cache.clone()))
                .app_data(web::Data::from(token_cache.clone()))
                .service(web::scope("/edge").service(metrics).service(validate)),
        )
        .await;
        let mut valid_token =
            EdgeToken::try_from("test-app:development.abcdefghijklmnopqrstu".to_string()).unwrap();
        valid_token.token_type = Some(TokenType::Client);
        valid_token.status = TokenValidationStatus::Validated;
        token_cache.insert(valid_token.token.clone(), valid_token.clone());
        let token_strings = TokenStrings {
            tokens: vec!["random_token:rqweqwew.qweqwjeqwkejlqwe".into()],
        };
        let req = test::TestRequest::post()
            .uri("/edge/validate")
            .insert_header(ContentType::json())
            .set_json(Json(token_strings))
            .to_request();
        let res: ValidatedTokens = test::call_and_read_body_json(&app, req).await;
        assert_eq!(res.tokens.len(), 0);
    }

    #[tokio::test]
    pub async fn validating_a_mix_of_tokens_only_returns_valid_tokens() {
        let metrics_cache = Arc::new(MetricsCache::default());
        let token_cache: Arc<DashMap<String, EdgeToken>> = Arc::new(DashMap::default());
        let app = test::init_service(
            App::new()
                .app_data(web::Data::from(metrics_cache.clone()))
                .app_data(web::Data::from(token_cache.clone()))
                .service(web::scope("/edge").service(metrics).service(validate)),
        )
        .await;
        let mut valid_token =
            EdgeToken::try_from("test-app:development.abcdefghijklmnopqrstu".to_string()).unwrap();
        valid_token.token_type = Some(TokenType::Client);
        valid_token.status = TokenValidationStatus::Validated;
        token_cache.insert(valid_token.token.clone(), valid_token.clone());

        let token_strings = TokenStrings {
            tokens: vec![
                "test-app:development.abcdefghijklmnopqrstu".into(),
                "probablyaninvalidproject:development.some_crazy_secret".into(),
            ],
        };
        let req = test::TestRequest::post()
            .uri("/edge/validate")
            .insert_header(ContentType::json())
            .set_json(Json(token_strings))
            .to_request();
        let res: ValidatedTokens = test::call_and_read_body_json(&app, req).await;
        assert_eq!(res.tokens.len(), 1);
        assert!(res.tokens.iter().any(|t| t.token == valid_token.token));
    }

    #[tokio::test]
    pub async fn adding_a_token_validator_filters_so_only_validated_tokens_are_returned() {
        let metrics_cache = Arc::new(MetricsCache::default());
        let token_cache: Arc<DashMap<String, EdgeToken>> = Arc::new(DashMap::default());
        let token_validator = TokenValidator {
            unleash_client: Arc::new(Default::default()),
            token_cache: token_cache.clone(),
            persistence: None,
        };
        let app = test::init_service(
            App::new()
                .app_data(web::Data::from(metrics_cache.clone()))
                .app_data(web::Data::from(token_cache.clone()))
                .app_data(web::Data::new(token_validator))
                .service(web::scope("/edge").service(metrics).service(validate)),
        )
        .await;
        let mut valid_token =
            EdgeToken::try_from("test-app:development.abcdefghijklmnopqrstu".to_string()).unwrap();
        valid_token.token_type = Some(TokenType::Client);
        valid_token.status = TokenValidationStatus::Validated;
        let mut invalid_token = EdgeToken::try_from(
            "probablyaninvalidproject:development.some_crazy_secret".to_string(),
        )
        .unwrap();
        invalid_token.status = TokenValidationStatus::Invalid;
        invalid_token.token_type = Some(TokenType::Admin);
        token_cache.insert(valid_token.token.clone(), valid_token.clone());
        token_cache.insert(invalid_token.token.clone(), invalid_token.clone());
        let token_strings = TokenStrings {
            tokens: vec![
                "test-app:development.abcdefghijklmnopqrstu".into(),
                "probablyaninvalidproject:development.some_crazy_secret".into(),
            ],
        };
        let req = test::TestRequest::post()
            .uri("/edge/validate")
            .insert_header(ContentType::json())
            .set_json(Json(token_strings))
            .to_request();
        let res: ValidatedTokens = test::call_and_read_body_json(&app, req).await;
        assert_eq!(res.tokens.len(), 1);
        assert!(res.tokens.iter().any(|t| t.token == valid_token.token));
    }
}