pipedash-plugin-api 0.14.0

Plugin API for Pipedash - CI/CD pipeline dashboard
Documentation
use std::collections::BTreeSet;
use std::future::Future;

use futures::stream::{
    self,
    StreamExt,
    TryStreamExt,
};
use serde::de::DeserializeOwned;

use crate::error::{
    PluginError,
    PluginResult,
};
use crate::health::ClientOptions;
use crate::logs::{
    LogCursor,
    LogSink,
    RunRef,
};
use crate::types::{
    AvailablePipeline,
    Organization,
    TriggerParams,
    WorkflowParameter,
    WorkflowParameterType,
};

pub const CONCURRENCY: usize = 8;
pub const LOG_CHUNK_BYTES: usize = 64 * 1024;

pub fn run_metadata_str<'a>(run: &RunRef<'a>, key: &str) -> Option<&'a str> {
    run.metadata
        .get(key)
        .and_then(serde_json::Value::as_str)
        .filter(|value| !value.is_empty())
}

pub fn client_options(origin: Option<&str>, insecure: bool) -> ClientOptions {
    ClientOptions {
        origin: origin.and_then(|url| reqwest::Url::parse(url).ok()),
        accept_invalid_certs: insecure,
        ..ClientOptions::default()
    }
}

pub fn input_str<'a>(params: &'a TriggerParams, key: &str) -> Option<&'a str> {
    params
        .inputs
        .as_ref()
        .and_then(|inputs| inputs.get(key))
        .and_then(serde_json::Value::as_str)
}

pub fn string_parameter(
    name: &str, label: &str, description: &str, default: Option<String>, required: bool,
) -> WorkflowParameter {
    WorkflowParameter {
        name: name.to_string(),
        label: Some(label.to_string()),
        description: Some(description.to_string()),
        param_type: WorkflowParameterType::String { default },
        required,
    }
}

pub async fn write_in_chunks<I>(
    sink: &mut LogSink, items: impl IntoIterator<Item = I>,
    mut render: impl FnMut(&mut Vec<u8>, I) -> PluginResult<()>,
) -> PluginResult<()> {
    let mut chunk = Vec::new();
    for item in items {
        render(&mut chunk, item)?;
        if chunk.len() >= LOG_CHUNK_BYTES {
            sink.write(std::mem::take(&mut chunk).into()).await?;
        }
    }
    sink.write(chunk.into()).await
}

pub fn organizations<'a>(
    names: impl IntoIterator<Item = &'a str>, description: Option<&str>,
) -> Vec<Organization> {
    names
        .into_iter()
        .collect::<BTreeSet<_>>()
        .into_iter()
        .map(|name| Organization {
            id: name.to_string(),
            name: name.to_string(),
            description: description.map(str::to_string),
        })
        .collect()
}

pub fn require_configured<T>(items: Vec<T>, what: &str) -> PluginResult<Vec<T>> {
    if items.is_empty() {
        return Err(PluginError::InvalidConfig(format!("No {what} configured")));
    }
    Ok(items)
}

pub fn retain_matching(items: &mut Vec<AvailablePipeline>, search: &str) {
    let search = search.to_lowercase();
    items.retain(|pipeline| {
        pipeline.name.to_lowercase().contains(&search)
            || pipeline.id.to_lowercase().contains(&search)
            || pipeline
                .description
                .as_ref()
                .is_some_and(|description| description.to_lowercase().contains(&search))
    });
}

pub async fn join_buffered<F: Future>(requests: Vec<F>, limit: usize) -> Vec<F::Output> {
    stream::iter(requests).buffered(limit).collect().await
}

pub async fn try_buffered<F, T>(requests: Vec<F>, limit: usize) -> PluginResult<Vec<T>>
where
    F: Future<Output = PluginResult<T>>,
{
    stream::iter(requests).buffered(limit).try_collect().await
}

pub async fn collect_tolerant<F, T>(
    requests: Vec<F>, limit: usize, mut on_error: impl FnMut(&PluginError),
) -> PluginResult<Vec<T>>
where
    F: Future<Output = PluginResult<T>>,
{
    let results: Vec<_> = stream::iter(requests).buffered(limit).collect().await;
    let mut items = Vec::new();
    let mut first_error = None;

    for result in results {
        match result {
            Ok(item) => items.push(item),
            Err(error) if error.is_transient() => return Err(error),
            Err(error) => {
                on_error(&error);
                first_error.get_or_insert(error);
            }
        }
    }

    match first_error {
        Some(error) if items.is_empty() => Err(error),
        _ => Ok(items),
    }
}

pub fn resume_position<T: DeserializeOwned>(
    from: Option<&LogCursor>, sink: &mut LogSink, keep: impl FnOnce(&T) -> bool,
) -> PluginResult<Option<T>> {
    let position = from
        .and_then(|cursor| cursor.parse::<T>())
        .filter(|position| keep(position));
    if from.is_some() && position.is_none() {
        sink.restart()?;
    }
    Ok(position)
}

pub fn digit_id<'a>(id: &'a str, what: &str) -> PluginResult<&'a str> {
    checked_id(id, what, |byte| byte.is_ascii_digit())
}

pub fn numeric_id<T: std::str::FromStr>(id: &str, what: &str) -> PluginResult<T> {
    digit_id(id, what)?
        .parse()
        .map_err(|_| PluginError::InvalidConfig(format!("Invalid {what}: {id}")))
}

pub fn uuid_id<'a>(id: &'a str, what: &str) -> PluginResult<&'a str> {
    checked_id(id, what, |byte| byte.is_ascii_hexdigit() || byte == b'-')
}

fn checked_id<'a>(id: &'a str, what: &str, valid: fn(u8) -> bool) -> PluginResult<&'a str> {
    if id.is_empty() || !id.bytes().all(valid) {
        return Err(PluginError::InvalidConfig(format!("Invalid {what}: {id}")));
    }
    Ok(id)
}

#[cfg(test)]
mod tests;