nils-api-testing-core 1.12.0

Library crate for nils-api-testing-core in the nils-cli workspace.
Documentation
use std::path::{Path, PathBuf};

use crate::Result;
use crate::cli_util;
use crate::suite::auth::SuiteAuthManager;
use crate::suite::resolve::{resolve_path_from_repo_root, write_file};
use crate::suite::runtime::{
    path_relative_to_repo_or_abs, plan_case_output_paths, resolve_rest_base_url,
    resolve_rest_token_profile,
};
use crate::suite::safety::{
    MSG_WRITE_CAPABLE_REQUIRES_ALLOW_WRITE_TRUE, MSG_WRITE_CASES_DISABLED, rest_method_is_write,
    writes_enabled,
};
use crate::suite::schema::{SuiteCase, SuiteDefaults};

pub(super) enum PrepareOutcome<T> {
    Ready(T),
    Skipped { message: String },
    Failed { message: String },
}

pub(super) struct RestCasePlan {
    pub(super) request_abs: PathBuf,
    pub(super) request_file: crate::rest::schema::RestRequestFile,
    pub(super) config_dir: String,
    pub(super) url: String,
    pub(super) token: String,
    pub(super) access_token_for_case: String,
}

pub(super) struct RestFlowCasePlan {
    pub(super) login_abs: PathBuf,
    pub(super) request_abs: PathBuf,
    pub(super) login_request_file: crate::rest::schema::RestRequestFile,
    pub(super) main_request_file: crate::rest::schema::RestRequestFile,
    pub(super) config_dir: String,
    pub(super) url: String,
    pub(super) token_jq: String,
}

pub(super) struct RestCaseRunOutput {
    pub(super) status: String,
    pub(super) message: Option<String>,
    pub(super) command_snippet: Option<String>,
    pub(super) stdout_path: PathBuf,
    pub(super) stderr_path: PathBuf,
}

#[allow(clippy::too_many_arguments)]
pub(super) fn prepare_rest_case(
    repo_root: &Path,
    case: &SuiteCase,
    id: &str,
    defaults: &SuiteDefaults,
    env_rest_url: &str,
    env_gql_url: &str,
    allow_writes_flag: bool,
    effective_env: &str,
    auth_manager: Option<&mut SuiteAuthManager>,
) -> Result<PrepareOutcome<RestCasePlan>> {
    let request_rel = case.request.trim();
    if request_rel.is_empty() {
        anyhow::bail!("REST case '{id}' is missing request");
    }
    let request_abs = resolve_path_from_repo_root(repo_root, request_rel);
    if !request_abs.is_file() {
        anyhow::bail!("REST case '{id}' request not found: {request_rel}");
    }

    let config_dir = cli_util::trim_non_empty(&case.config_dir)
        .unwrap_or_else(|| defaults.rest.config_dir.clone());
    let token =
        cli_util::trim_non_empty(&case.token).unwrap_or_else(|| defaults.rest.token.clone());

    let mut url = cli_util::trim_non_empty(&case.url).unwrap_or_else(|| defaults.rest.url.clone());
    if url.trim().is_empty() && !env_rest_url.trim().is_empty() {
        url = env_rest_url.to_string();
    }

    let request_file = crate::rest::schema::RestRequestFile::load(&request_abs)?;

    if rest_method_is_write(&request_file.request.method) {
        if !case.allow_write {
            return Ok(PrepareOutcome::Failed {
                message: MSG_WRITE_CAPABLE_REQUIRES_ALLOW_WRITE_TRUE.to_string(),
            });
        }
        if !writes_enabled(allow_writes_flag, effective_env) {
            return Ok(PrepareOutcome::Skipped {
                message: MSG_WRITE_CASES_DISABLED.to_string(),
            });
        }
    }

    let mut access_token_for_case = String::new();
    if let Some(mgr) = auth_manager
        && !token.trim().is_empty()
    {
        match mgr.ensure_token(token.trim(), repo_root, defaults, env_rest_url, env_gql_url) {
            Ok(t) => access_token_for_case = t,
            Err(err) => {
                return Ok(PrepareOutcome::Failed { message: err });
            }
        }
    }

    Ok(PrepareOutcome::Ready(RestCasePlan {
        request_abs,
        request_file,
        config_dir,
        url,
        token,
        access_token_for_case,
    }))
}

pub(super) fn prepare_rest_flow_case(
    repo_root: &Path,
    case: &SuiteCase,
    id: &str,
    defaults: &SuiteDefaults,
    env_rest_url: &str,
    allow_writes_flag: bool,
    effective_env: &str,
) -> Result<PrepareOutcome<RestFlowCasePlan>> {
    let login_rel = case.login_request.trim();
    let request_rel = case.request.trim();
    if login_rel.is_empty() {
        anyhow::bail!("rest-flow case '{id}' is missing loginRequest");
    }
    if request_rel.is_empty() {
        anyhow::bail!("rest-flow case '{id}' is missing request");
    }
    let login_abs = resolve_path_from_repo_root(repo_root, login_rel);
    let request_abs = resolve_path_from_repo_root(repo_root, request_rel);
    if !login_abs.is_file() {
        anyhow::bail!("rest-flow case '{id}' loginRequest not found: {login_rel}");
    }
    if !request_abs.is_file() {
        anyhow::bail!("rest-flow case '{id}' request not found: {request_rel}");
    }

    let config_dir = cli_util::trim_non_empty(&case.config_dir)
        .unwrap_or_else(|| defaults.rest.config_dir.clone());
    let mut url = cli_util::trim_non_empty(&case.url).unwrap_or_else(|| defaults.rest.url.clone());
    if url.trim().is_empty() && !env_rest_url.trim().is_empty() {
        url = env_rest_url.to_string();
    }

    let token_jq = cli_util::trim_non_empty(&case.token_jq)
        .unwrap_or_else(super::context::default_rest_flow_token_jq);

    let login_request_file = crate::rest::schema::RestRequestFile::load(&login_abs)?;
    let main_request_file = crate::rest::schema::RestRequestFile::load(&request_abs)?;

    if rest_method_is_write(&login_request_file.request.method)
        || rest_method_is_write(&main_request_file.request.method)
    {
        if !case.allow_write {
            return Ok(PrepareOutcome::Failed {
                message: MSG_WRITE_CAPABLE_REQUIRES_ALLOW_WRITE_TRUE.to_string(),
            });
        }
        if !writes_enabled(allow_writes_flag, effective_env) {
            return Ok(PrepareOutcome::Skipped {
                message: MSG_WRITE_CASES_DISABLED.to_string(),
            });
        }
    }

    Ok(PrepareOutcome::Ready(RestFlowCasePlan {
        login_abs,
        request_abs,
        login_request_file,
        main_request_file,
        config_dir,
        url,
        token_jq,
    }))
}

#[allow(clippy::too_many_arguments)]
pub(super) fn run_rest_case(
    repo_root: &Path,
    run_dir_abs: &Path,
    safe_id: &str,
    effective_no_history: bool,
    effective_env: &str,
    defaults: &SuiteDefaults,
    env_rest_url: &str,
    rest_config_dir: &str,
    rest_url: &str,
    rest_token: &str,
    access_token_for_case: &str,
    request_abs: &Path,
    request_file: &crate::rest::schema::RestRequestFile,
) -> Result<RestCaseRunOutput> {
    let outputs = plan_case_output_paths(run_dir_abs, safe_id);
    let stdout_path = outputs.stdout_path;
    let stderr_path = outputs.stderr_path;
    write_file(&stdout_path, b"")?;
    write_file(&stderr_path, b"")?;

    let mut status = "pending".to_string();
    let mut message: Option<String> = None;

    let base_url = match resolve_rest_base_url(
        repo_root,
        rest_config_dir,
        rest_url,
        effective_env,
        defaults,
        env_rest_url,
    ) {
        Ok(v) => v,
        Err(err) => {
            write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
            status = "failed".to_string();
            message = Some("rest_runner_failed".to_string());
            String::new()
        }
    };

    if status != "failed" {
        let setup_dir_abs = resolve_path_from_repo_root(repo_root, rest_config_dir);
        let bearer = if !access_token_for_case.trim().is_empty() {
            Some(access_token_for_case.trim().to_string())
        } else if !rest_token.trim().is_empty() {
            match resolve_rest_token_profile(&setup_dir_abs, rest_token) {
                Ok(t) => Some(t),
                Err(err) => {
                    write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
                    status = "failed".to_string();
                    message = Some("rest_runner_failed".to_string());
                    None
                }
            }
        } else {
            None
        };

        if status != "failed" {
            match crate::rest::runner::execute_rest_request(
                request_file,
                &base_url,
                bearer.as_deref(),
            ) {
                Ok(executed) => {
                    write_file(&stdout_path, &executed.response.body)?;
                    if let Err(err) = crate::rest::expect::evaluate_main_response(
                        &request_file.request,
                        &executed,
                    ) {
                        write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
                        status = "failed".to_string();
                        message = Some("rest_runner_failed".to_string());
                    } else {
                        status = "passed".to_string();
                    }
                }
                Err(err) => {
                    write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
                    status = "failed".to_string();
                    message = Some("rest_runner_failed".to_string());
                }
            }
        }
    }

    let mut argv: Vec<String> = vec![
        "api-rest".to_string(),
        "call".to_string(),
        "--config-dir".to_string(),
        rest_config_dir.to_string(),
    ];
    if effective_no_history {
        argv.push("--no-history".to_string());
    }
    if !rest_url.trim().is_empty() {
        argv.push("--url".to_string());
        argv.push(rest_url.to_string());
    } else if !effective_env.trim().is_empty() {
        argv.push("--env".to_string());
        argv.push(effective_env.to_string());
    }
    if !rest_token.trim().is_empty() && access_token_for_case.trim().is_empty() {
        argv.push("--token".to_string());
        argv.push(rest_token.to_string());
    }
    argv.push(path_relative_to_repo_or_abs(repo_root, request_abs));

    let args = super::mask_args_for_command_snippet(&argv[1..]);
    let env_prefix = if !access_token_for_case.trim().is_empty() {
        "ACCESS_TOKEN=REDACTED REST_TOKEN_NAME= GQL_JWT_NAME="
    } else {
        ""
    };
    let snippet = if env_prefix.is_empty() {
        format!("{} {}", cli_util::shell_quote("api-rest"), args)
    } else {
        format!(
            "{env_prefix} {} {}",
            cli_util::shell_quote("api-rest"),
            args
        )
    };

    Ok(RestCaseRunOutput {
        status,
        message,
        command_snippet: Some(snippet.trim().to_string()),
        stdout_path,
        stderr_path,
    })
}