cloudconvert-sdk 0.1.0

Async Rust SDK primitives for the CloudConvert API v2.
Documentation
use std::{env, fs, time::SystemTime};

use bytes::Bytes;
use cloudconvert_sdk::{
    ApiKey, CloudConvertClient, ConvertTask, ImportUrlTask, JobCreateRequest, Layer,
    PositionHorizontal, PositionVertical, TaskRequest, WatermarkTask,
};

const LIVE_TEST_REASON: &str =
    "requires CLOUDCONVERT_API_KEY in .env or the environment and performs live API calls";

#[tokio::test]
#[ignore = "requires CLOUDCONVERT_API_KEY in .env or the environment and performs live API calls"]
async fn live_api_accepts_watermark_job_shape() -> cloudconvert_sdk::Result<()> {
    if live_tests_are_disabled_in_ci() {
        return Ok(());
    }

    let client = CloudConvertClient::builder(live_api_key()).build()?;
    let tag = format!("rust-sdk-live-watermark-{}", timestamp());
    let request = JobCreateRequest::builder()
        .tag(tag.clone())
        .task(
            "import-file",
            ImportUrlTask::new("http://invalid.url").filename("input.pdf"),
        )
        .task(
            "add-watermark",
            WatermarkTask::text("import-file", "Rust SDK Live Test")
                .input_format("pdf")
                .layer(Layer::Above)
                .position(PositionVertical::Center, PositionHorizontal::Center)
                .opacity(35)
                .rotation(-20),
        )
        .task("export-file", TaskRequest::export_url("add-watermark"))
        .build();

    let job = client.jobs().create(request).await?;
    let job_id = job.id.clone();
    let job_tag = job.tag.clone();
    let task_operations = job
        .tasks
        .iter()
        .map(|task| task.operation.as_str())
        .collect::<Vec<_>>();

    client.jobs().delete(job_id).await?;

    assert_eq!(job_tag.as_deref(), Some(tag.as_str()));
    assert!(task_operations.contains(&"import/url"));
    assert!(task_operations.contains(&"watermark"));
    assert!(task_operations.contains(&"export/url"));

    Ok(())
}

#[tokio::test]
#[ignore = "requires CLOUDCONVERT_API_KEY in .env or the environment and performs live API calls"]
async fn live_api_creates_and_deletes_import_url_task() -> cloudconvert_sdk::Result<()> {
    if live_tests_are_disabled_in_ci() {
        return Ok(());
    }

    let client = CloudConvertClient::builder(live_api_key()).build()?;
    let task = client
        .tasks()
        .create(TaskRequest::from(
            ImportUrlTask::new("http://invalid.url").filename("input.pdf"),
        ))
        .await?;
    let task_id = task.id.clone();
    let operation = task.operation.clone();

    client.tasks().delete(task_id).await?;

    assert_eq!(operation, "import/url");

    Ok(())
}

#[tokio::test]
#[ignore = "requires CLOUDCONVERT_API_KEY in .env or the environment and performs live API calls"]
async fn live_api_uploads_converts_exports_and_downloads() -> cloudconvert_sdk::Result<()> {
    if live_tests_are_disabled_in_ci() {
        return Ok(());
    }

    let client = CloudConvertClient::builder(live_api_key()).build()?;
    let tag = format!("rust-sdk-live-upload-convert-{}", timestamp());
    let request = JobCreateRequest::builder()
        .tag(tag)
        .task("upload-file", TaskRequest::import_upload())
        .task(
            "convert-file",
            ConvertTask::new("upload-file", "pdf")
                .input_format("txt")
                .filename("cloudconvert-sdk-live-test.pdf"),
        )
        .task(
            "export-file",
            cloudconvert_sdk::ExportUrlTask::new("convert-file").inline(false),
        )
        .build();

    let job = client.jobs().create(request).await?;
    let job_id = job.id.clone();
    let result = run_uploaded_conversion(&client, &job_id, &job).await;
    let cleanup = client.jobs().delete(&job_id).await;

    result?;
    cleanup?;
    Ok(())
}

#[cfg(feature = "socket")]
#[tokio::test]
#[ignore = "requires CLOUDCONVERT_API_KEY in .env or the environment and performs live API calls"]
async fn live_api_waits_for_terminal_job_over_socket() -> cloudconvert_sdk::Result<()> {
    if live_tests_are_disabled_in_ci() {
        return Ok(());
    }

    let client = CloudConvertClient::builder(live_api_key()).build()?;
    let tag = format!("rust-sdk-live-socket-{}", timestamp());
    let request = JobCreateRequest::builder()
        .tag(tag)
        .task(
            "import-file",
            ImportUrlTask::new("http://invalid.url").filename("input.pdf"),
        )
        .task("export-file", TaskRequest::export_url("import-file"))
        .build();

    let job = client.jobs().create(request).await?;
    let job_id = job.id.clone();
    let result = client.jobs().wait_socket(&job_id).await;
    let cleanup = client.jobs().delete(&job_id).await;

    let finished = result?;
    cleanup?;
    assert!(finished.is_terminal());

    Ok(())
}

async fn run_uploaded_conversion(
    client: &CloudConvertClient,
    job_id: &str,
    job: &cloudconvert_sdk::Job,
) -> cloudconvert_sdk::Result<()> {
    let upload_task_id = job
        .tasks
        .iter()
        .find(|task| task.operation == "import/upload")
        .and_then(|task| task.id.as_deref())
        .ok_or_else(|| std::io::Error::other("import/upload task should have an id"))?;
    let upload_task = client.tasks().get(upload_task_id).await?;
    client
        .upload_bytes(
            &upload_task,
            "cloudconvert-sdk-live-test.txt",
            Bytes::from_static(b"CloudConvert Rust SDK live upload test\n"),
        )
        .await?;

    let finished = client.jobs().wait(job_id).await?;
    let export = finished
        .export_urls()
        .into_iter()
        .find_map(|file| file.url.as_deref())
        .ok_or_else(|| std::io::Error::other("finished job should include an export/url result"))?;
    let bytes = client.download(export).await?;
    assert!(!bytes.is_empty());

    Ok(())
}

fn live_api_key() -> ApiKey {
    if let Ok(value) = env::var("CLOUDCONVERT_API_KEY") {
        let value = value.trim();
        if !value.is_empty() {
            return ApiKey::new(value);
        }
    }

    let env_file =
        fs::read_to_string(".env").unwrap_or_else(|_| panic!("missing {LIVE_TEST_REASON}"));
    for line in env_file.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if let Some((key, value)) = line.split_once('=')
            && key.trim() == "CLOUDCONVERT_API_KEY"
        {
            let value = unquote(value.trim());
            if !value.is_empty() {
                return ApiKey::new(value);
            }
        }
    }

    panic!("missing {LIVE_TEST_REASON}");
}

fn live_tests_are_disabled_in_ci() -> bool {
    env::var_os("CI").is_some()
}

fn unquote(value: &str) -> String {
    value
        .strip_prefix('"')
        .and_then(|value| value.strip_suffix('"'))
        .or_else(|| {
            value
                .strip_prefix('\'')
                .and_then(|value| value.strip_suffix('\''))
        })
        .unwrap_or(value)
        .to_string()
}

fn timestamp() -> u64 {
    SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .expect("system clock is before UNIX_EPOCH")
        .as_secs()
}