port-sdk 0.1.0

Rust SDK for Port APIs.
Documentation
use port_sdk::apis::entities;
use port_sdk::auth::AuthStrategy;
use port_sdk::client::PortClient;
use port_sdk::config::{PortConfigBuilder, RetryConfig};
use port_sdk::error::PortError;
use port_sdk::types::entities::{EntityRequest, EntityResponse};
use serde_json::json;
use serial_test::serial;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use url::Url;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn test_retry_config() -> RetryConfig {
    RetryConfig {
        max_attempts: 3,
        max_elapsed_time: Some(Duration::from_secs(2)),
        initial_interval: Duration::from_millis(10),
        multiplier: 1.0,
        max_interval: Duration::from_millis(20),
        retry_on_statuses: vec![429, 500],
    }
}

async fn test_client(base_url: Url) -> Result<PortClient, PortError> {
    let config = PortConfigBuilder::default()
        .base_url(base_url)
        .auth(AuthStrategy::StaticToken("test-token".into()))
        .timeout(Duration::from_secs(5))
        .retry(Some(test_retry_config()))
        .build()?;
    PortClient::from_config(config)
}

#[tokio::test]
#[serial]
async fn client_retries_on_429() -> Result<(), PortError> {
    let server = MockServer::start().await;
    let base_url = Url::parse(&server.uri()).unwrap();

    let attempts = Arc::new(AtomicUsize::new(0));
    let attempts_for_mock = Arc::clone(&attempts);

    Mock::given(method("GET"))
        .and(path("/entities"))
        .respond_with(move |_: &wiremock::Request| {
            let attempt = attempts_for_mock.fetch_add(1, Ordering::SeqCst);
            if attempt == 0 {
                ResponseTemplate::new(429).insert_header("Retry-After", "0")
            } else {
                ResponseTemplate::new(200).set_body_json(json!({"ok": true}))
            }
        })
        .mount(&server)
        .await;

    let client = test_client(base_url).await?;
    let response: serde_json::Value = client.get("/entities").await?;
    assert_eq!(response["ok"], true);
    assert_eq!(attempts.load(Ordering::SeqCst), 2);
    Ok(())
}

#[tokio::test]
#[serial]
async fn entity_lifecycle_tracks_resources() -> Result<(), PortError> {
    let server = MockServer::start().await;
    let base_url = Url::parse(&server.uri()).unwrap();

    let entity_response = EntityResponse {
        identifier: "entity-1".into(),
        blueprint: "blueprint-1".into(),
        title: Some("Entity".into()),
        properties: None,
    };

    Mock::given(method("POST"))
        .and(path("/blueprints/blueprint-1/entities"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&entity_response))
        .expect(1)
        .mount(&server)
        .await;

    Mock::given(method("PUT"))
        .and(path("/blueprints/blueprint-1/entities/entity-1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&entity_response))
        .expect(1)
        .mount(&server)
        .await;

    Mock::given(method("DELETE"))
        .and(path("/blueprints/blueprint-1/entities/entity-1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"deleted": true})))
        .expect(1)
        .mount(&server)
        .await;

    let client = test_client(base_url).await?;

    let create_request = EntityRequest::new("entity-1", "blueprint-1", None);
    let _: EntityResponse = entities::create(&client, "blueprint-1", &create_request).await?;

    let outstanding = client.tracker().outstanding();
    assert_eq!(outstanding.get("entity").unwrap().len(), 1);

    let _: EntityResponse =
        entities::upsert(&client, "blueprint-1", "entity-1", &create_request).await?;

    let _: serde_json::Value = entities::delete(&client, "blueprint-1", "entity-1").await?;
    assert!(client.tracker().outstanding().is_empty());
    Ok(())
}