use std::io::Write;
use std::path::{Path, PathBuf};
use api_testing_core::{Result, auth_env, cli_endpoint, cli_io, cli_util, config, history, jwt};
use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
use crate::cli::CallArgs;
use api_testing_core::cli_util::{
history_timestamp_now, maybe_relpath, parse_u64_default, shell_quote, trim_non_empty,
};
#[derive(Debug, Clone)]
pub(crate) struct EndpointSelection {
pub(crate) rest_url: String,
pub(crate) endpoint_label_used: String,
pub(crate) endpoint_value_used: String,
}
pub(crate) type AuthSourceUsed = auth_env::CliAuthSource;
#[derive(Debug, Clone)]
pub(crate) struct AuthSelection {
pub(crate) bearer_token: Option<String>,
pub(crate) token_name: String,
pub(crate) auth_source_used: AuthSourceUsed,
}
pub(crate) fn resolve_endpoint_for_call(
args: &CallArgs,
setup: &api_testing_core::config::ResolvedSetup,
) -> Result<EndpointSelection> {
let endpoints_env = &setup.endpoints_env;
let endpoints_local = &setup.endpoints_local_env;
let endpoints_files = setup.endpoints_files();
let selection = cli_endpoint::resolve_cli_endpoint(cli_endpoint::EndpointConfig {
explicit_url: args.url.as_deref(),
env_name: args.env.as_deref(),
endpoints_env,
endpoints_local,
endpoints_files: &endpoints_files,
url_env_var: "REST_URL",
env_default_var: "REST_ENV_DEFAULT",
url_prefix: "REST_URL_",
default_url: "http://localhost:6700",
setup_dir_label: "setup/rest/",
})?;
Ok(EndpointSelection {
rest_url: selection.url,
endpoint_label_used: selection.endpoint_label_used,
endpoint_value_used: selection.endpoint_value_used,
})
}
pub(crate) fn resolve_auth_for_call(
args: &CallArgs,
setup: &api_testing_core::config::ResolvedSetup,
) -> Result<AuthSelection> {
let tokens_env = setup.tokens_env.as_ref().expect("tokens_env");
let tokens_local = setup.tokens_local_env.as_ref().expect("tokens_local_env");
let tokens_files = setup.tokens_files();
let token_resolution = auth_env::resolve_profile_or_env_fallback(
auth_env::ProfileTokenConfig {
token_name_arg: args.token.as_deref(),
token_name_env_var: "REST_TOKEN_NAME",
token_name_file_var: "REST_TOKEN_NAME",
token_var_prefix: "REST_TOKEN_",
tokens_env,
tokens_local,
tokens_files: &tokens_files,
missing_profile_hint: "Set it in setup/rest/tokens.local.env or use ACCESS_TOKEN without selecting a token profile.",
env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
},
)?;
Ok(AuthSelection {
bearer_token: token_resolution.bearer_token,
token_name: token_resolution.token_name,
auth_source_used: token_resolution.source.into(),
})
}
pub(crate) fn validate_bearer_token_if_jwt(
bearer_token: &str,
auth_source: &AuthSourceUsed,
token_name: &str,
stderr: &mut dyn Write,
) -> Result<()> {
let enabled = cli_util::bool_from_env(
std::env::var("REST_JWT_VALIDATE_ENABLED").ok(),
"REST_JWT_VALIDATE_ENABLED",
true,
Some("api-rest"),
stderr,
);
let strict = cli_util::bool_from_env(
std::env::var("REST_JWT_VALIDATE_STRICT").ok(),
"REST_JWT_VALIDATE_STRICT",
false,
Some("api-rest"),
stderr,
);
let leeway_seconds =
parse_u64_default(std::env::var("REST_JWT_VALIDATE_LEEWAY_SECONDS").ok(), 0, 0);
let label = match auth_source {
AuthSourceUsed::TokenProfile => format!("token profile '{token_name}'"),
AuthSourceUsed::EnvFallback { env_name } => env_name.to_string(),
AuthSourceUsed::None => "token".to_string(),
};
let opts = jwt::JwtValidationOptions {
enabled,
strict,
leeway_seconds: i64::try_from(leeway_seconds).unwrap_or(i64::MAX),
};
match jwt::check_bearer_jwt(bearer_token, &label, opts)? {
jwt::JwtCheck::Ok => Ok(()),
jwt::JwtCheck::Warn(msg) => {
let _ = writeln!(stderr, "api-rest: warning: {msg}");
Ok(())
}
}
}
pub(crate) fn cmd_call(
args: &CallArgs,
invocation_dir: &Path,
stdout_is_tty: bool,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> i32 {
cmd_call_internal(args, invocation_dir, stdout_is_tty, true, stdout, stderr)
}
pub(crate) fn cmd_call_internal(
args: &CallArgs,
invocation_dir: &Path,
stdout_is_tty: bool,
history_enabled_by_command: bool,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> i32 {
let request_path = PathBuf::from(&args.request);
if !request_path.is_file() {
let _ = writeln!(stderr, "Request file not found: {}", request_path.display());
return 1;
}
let request_file = match api_testing_core::rest::schema::RestRequestFile::load(&request_path) {
Ok(v) => v,
Err(err) => {
let _ = writeln!(stderr, "{err}");
return 1;
}
};
let config_dir = args
.config_dir
.as_deref()
.and_then(trim_non_empty)
.map(PathBuf::from);
let setup_dir = match config::resolve_rest_setup_dir_for_call(
invocation_dir,
invocation_dir,
&request_path,
config_dir.as_deref(),
) {
Ok(v) => v,
Err(err) => {
let _ = writeln!(stderr, "{err}");
return 1;
}
};
let mut exit_code = 1;
let history_enabled = history_enabled_by_command
&& !args.no_history
&& cli_util::bool_from_env(
std::env::var("REST_HISTORY_ENABLED").ok(),
"REST_HISTORY_ENABLED",
true,
Some("api-rest"),
stderr,
);
let history_file_override = std::env::var("REST_HISTORY_FILE")
.ok()
.and_then(|s| trim_non_empty(&s))
.map(PathBuf::from);
let setup = api_testing_core::config::ResolvedSetup::rest(
setup_dir.clone(),
history_file_override.as_deref(),
);
let rotation = history::RotationPolicy {
max_mb: parse_u64_default(std::env::var("REST_HISTORY_MAX_MB").ok(), 10, 0),
keep: parse_u64_default(std::env::var("REST_HISTORY_ROTATE_COUNT").ok(), 5, 1)
.try_into()
.unwrap_or(u32::MAX),
};
let log_url = cli_util::bool_from_env(
std::env::var("REST_HISTORY_LOG_URL_ENABLED").ok(),
"REST_HISTORY_LOG_URL_ENABLED",
true,
Some("api-rest"),
stderr,
);
let history_writer = history::HistoryWriter::new(setup.history_file.clone(), rotation);
let mut history_ctx = CallHistoryContext {
enabled: history_enabled,
setup_dir: setup_dir.clone(),
history_writer,
invocation_dir: invocation_dir.to_path_buf(),
request_arg: args.request.clone(),
endpoint_label_used: String::new(),
endpoint_value_used: String::new(),
log_url,
auth_source_used: AuthSourceUsed::None,
token_name_for_log: String::new(),
};
let endpoint = match resolve_endpoint_for_call(args, &setup) {
Ok(v) => {
history_ctx.endpoint_label_used = v.endpoint_label_used.clone();
history_ctx.endpoint_value_used = v.endpoint_value_used.clone();
v
}
Err(err) => {
let _ = writeln!(stderr, "{err}");
append_history_best_effort(&history_ctx, exit_code);
return 1;
}
};
let auth = match resolve_auth_for_call(args, &setup) {
Ok(v) => {
history_ctx.auth_source_used = v.auth_source_used.clone();
history_ctx.token_name_for_log = v.token_name.clone();
v
}
Err(err) => {
let _ = writeln!(stderr, "{err}");
append_history_best_effort(&history_ctx, exit_code);
return 1;
}
};
if let Some(token) = auth.bearer_token.as_deref()
&& let Err(err) = validate_bearer_token_if_jwt(
token,
&history_ctx.auth_source_used,
&auth.token_name,
stderr,
)
{
let _ = writeln!(stderr, "{err}");
append_history_best_effort(&history_ctx, exit_code);
return 1;
}
let spinner = Progress::spinner(
ProgressOptions::default()
.with_prefix("api-rest ")
.with_finish(ProgressFinish::Clear),
);
spinner.set_message("request");
spinner.tick();
let executed = match api_testing_core::rest::runner::execute_rest_request(
&request_file,
&endpoint.rest_url,
auth.bearer_token.as_deref(),
) {
Ok(v) => v,
Err(err) => {
spinner.finish_and_clear();
let _ = writeln!(stderr, "{err}");
append_history_best_effort(&history_ctx, exit_code);
return 1;
}
};
let _ = stdout.write_all(&executed.response.body);
if let Err(err) =
api_testing_core::rest::expect::evaluate_main_response(&request_file.request, &executed)
{
spinner.finish_and_clear();
let _ = writeln!(stderr, "{err}");
cli_io::maybe_print_failure_body_to_stderr(
&executed.response.body,
8192,
stdout_is_tty,
stderr,
);
append_history_best_effort(&history_ctx, exit_code);
return 1;
}
if let Some(cleanup) = &request_file.request.cleanup {
spinner.set_message("cleanup");
spinner.tick();
if let Err(err) = api_testing_core::rest::cleanup::execute_cleanup(
cleanup,
&endpoint.rest_url,
auth.bearer_token.as_deref(),
&executed.response.body,
) {
spinner.finish_and_clear();
let _ = writeln!(stderr, "{err}");
append_history_best_effort(&history_ctx, exit_code);
return 1;
}
}
spinner.finish_and_clear();
exit_code = 0;
append_history_best_effort(&history_ctx, exit_code);
exit_code
}
#[derive(Debug, Clone)]
struct CallHistoryContext {
enabled: bool,
setup_dir: PathBuf,
history_writer: history::HistoryWriter,
invocation_dir: PathBuf,
request_arg: String,
endpoint_label_used: String,
endpoint_value_used: String,
log_url: bool,
auth_source_used: AuthSourceUsed,
token_name_for_log: String,
}
fn append_history_best_effort(ctx: &CallHistoryContext, exit_code: i32) {
if !ctx.enabled {
return;
}
let history_writer = &ctx.history_writer;
let stamp = history_timestamp_now().unwrap_or_default();
let setup_rel = maybe_relpath(&ctx.setup_dir, &ctx.invocation_dir);
let mut record = String::new();
record.push_str(&format!("# {stamp} exit={exit_code} setup_dir={setup_rel}"));
if !ctx.endpoint_label_used.is_empty() {
if ctx.endpoint_label_used == "url" && !ctx.log_url {
record.push_str(" url=<omitted>");
} else {
record.push_str(&format!(
" {}={}",
ctx.endpoint_label_used, ctx.endpoint_value_used
));
}
}
match &ctx.auth_source_used {
AuthSourceUsed::TokenProfile => {
if !ctx.token_name_for_log.is_empty() {
record.push_str(&format!(" token={}", ctx.token_name_for_log));
}
}
AuthSourceUsed::EnvFallback { env_name } => {
if !env_name.is_empty() {
record.push_str(&format!(" auth={env_name}"));
}
}
AuthSourceUsed::None => {}
}
record.push('\n');
let config_rel = maybe_relpath(&ctx.setup_dir, &ctx.invocation_dir);
let req_arg_path = Path::new(&ctx.request_arg);
let req_rel = if req_arg_path.is_absolute() {
maybe_relpath(req_arg_path, &ctx.invocation_dir)
} else {
ctx.request_arg.clone()
};
record.push_str("api-rest call \\\n");
record.push_str(&format!(" --config-dir {} \\\n", shell_quote(&config_rel)));
if ctx.endpoint_label_used == "env" && !ctx.endpoint_value_used.is_empty() {
record.push_str(&format!(
" --env {} \\\n",
shell_quote(&ctx.endpoint_value_used)
));
} else if ctx.endpoint_label_used == "url" && !ctx.endpoint_value_used.is_empty() && ctx.log_url
{
record.push_str(&format!(
" --url {} \\\n",
shell_quote(&ctx.endpoint_value_used)
));
}
if matches!(ctx.auth_source_used, AuthSourceUsed::TokenProfile)
&& !ctx.token_name_for_log.is_empty()
{
record.push_str(&format!(
" --token {} \\\n",
shell_quote(&ctx.token_name_for_log)
));
}
record.push_str(&format!(" {} \\\n", shell_quote(&req_rel)));
record.push_str("| jq .\n\n");
let _ = history_writer.append(&record);
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use nils_test_support::fs::write_text;
use nils_test_support::{EnvGuard, GlobalStateLock};
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn b64url_json(value: &serde_json::Value) -> String {
let bytes = serde_json::to_vec(value).expect("json");
URL_SAFE_NO_PAD.encode(bytes)
}
fn make_jwt(payload: serde_json::Value) -> String {
let header = serde_json::json!({"alg":"none","typ":"JWT"});
format!("{}.{}.sig", b64url_json(&header), b64url_json(&payload))
}
#[test]
fn resolve_endpoint_for_call_honors_url_and_env() {
let tmp = TempDir::new().unwrap();
let setup_dir = tmp.path().join("setup/rest");
std::fs::create_dir_all(&setup_dir).unwrap();
write_text(
&setup_dir.join("endpoints.env"),
"REST_ENV_DEFAULT=prod\nREST_URL_PROD=http://prod\nREST_URL_STAGING=http://staging\n",
);
let setup = api_testing_core::config::ResolvedSetup::rest(setup_dir, None);
let args = CallArgs {
env: None,
url: Some("http://explicit".to_string()),
token: None,
config_dir: None,
no_history: false,
request: "requests/health.request.json".to_string(),
};
let sel = resolve_endpoint_for_call(&args, &setup).unwrap();
assert_eq!(sel.rest_url, "http://explicit");
assert_eq!(sel.endpoint_label_used, "url");
let args = CallArgs {
env: Some("staging".to_string()),
url: None,
token: None,
config_dir: None,
no_history: false,
request: "requests/health.request.json".to_string(),
};
let sel = resolve_endpoint_for_call(&args, &setup).unwrap();
assert_eq!(sel.rest_url, "http://staging");
assert_eq!(sel.endpoint_label_used, "env");
let args = CallArgs {
env: Some("https://example.test".to_string()),
url: None,
token: None,
config_dir: None,
no_history: false,
request: "requests/health.request.json".to_string(),
};
let sel = resolve_endpoint_for_call(&args, &setup).unwrap();
assert_eq!(sel.rest_url, "https://example.test");
assert_eq!(sel.endpoint_label_used, "url");
}
#[test]
fn resolve_endpoint_for_call_unknown_env_lists_available() {
let tmp = TempDir::new().unwrap();
let setup_dir = tmp.path().join("setup/rest");
std::fs::create_dir_all(&setup_dir).unwrap();
write_text(
&setup_dir.join("endpoints.env"),
"REST_URL_PROD=http://prod\nREST_URL_DEV=http://dev\n",
);
let setup = api_testing_core::config::ResolvedSetup::rest(setup_dir, None);
let args = CallArgs {
env: Some("missing".to_string()),
url: None,
token: None,
config_dir: None,
no_history: false,
request: "requests/health.request.json".to_string(),
};
let err = resolve_endpoint_for_call(&args, &setup).unwrap_err();
assert!(err.to_string().contains("Unknown --env 'missing'"));
assert!(err.to_string().contains("prod"));
}
#[test]
fn resolve_auth_for_call_prefers_profile_then_env_fallback() {
let lock = GlobalStateLock::new();
let _guard = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
let tmp = TempDir::new().unwrap();
let setup_dir = tmp.path().join("setup/rest");
std::fs::create_dir_all(&setup_dir).unwrap();
write_text(&setup_dir.join("tokens.env"), "REST_TOKEN_SVC=svc-token\n");
let setup = api_testing_core::config::ResolvedSetup::rest(setup_dir, None);
let args = CallArgs {
env: None,
url: None,
token: Some("svc".to_string()),
config_dir: None,
no_history: false,
request: "requests/health.request.json".to_string(),
};
let auth = resolve_auth_for_call(&args, &setup).unwrap();
assert_eq!(auth.bearer_token.as_deref(), Some("svc-token"));
assert!(matches!(
auth.auth_source_used,
AuthSourceUsed::TokenProfile
));
let args = CallArgs {
env: None,
url: None,
token: None,
config_dir: None,
no_history: false,
request: "requests/health.request.json".to_string(),
};
let auth = resolve_auth_for_call(&args, &setup).unwrap();
assert_eq!(auth.bearer_token.as_deref(), Some("env-token"));
assert!(matches!(
auth.auth_source_used,
AuthSourceUsed::EnvFallback { .. }
));
}
#[test]
fn validate_bearer_token_warns_when_non_strict() {
let lock = GlobalStateLock::new();
let _enabled = EnvGuard::set(&lock, "REST_JWT_VALIDATE_ENABLED", "true");
let _strict = EnvGuard::set(&lock, "REST_JWT_VALIDATE_STRICT", "false");
let mut stderr = Vec::new();
let res = validate_bearer_token_if_jwt(
"not.a.jwt",
&AuthSourceUsed::None,
"default",
&mut stderr,
);
assert!(res.is_ok());
let msg = String::from_utf8_lossy(&stderr);
assert!(msg.contains("not a valid JWT"));
}
#[test]
fn validate_bearer_token_errors_when_strict_invalid() {
let lock = GlobalStateLock::new();
let _enabled = EnvGuard::set(&lock, "REST_JWT_VALIDATE_ENABLED", "true");
let _strict = EnvGuard::set(&lock, "REST_JWT_VALIDATE_STRICT", "true");
let mut stderr = Vec::new();
let err = validate_bearer_token_if_jwt(
"not.a.jwt",
&AuthSourceUsed::None,
"default",
&mut stderr,
)
.unwrap_err();
assert!(err.to_string().contains("invalid JWT"));
}
#[test]
fn validate_bearer_token_errors_when_expired() {
let lock = GlobalStateLock::new();
let _enabled = EnvGuard::set(&lock, "REST_JWT_VALIDATE_ENABLED", "true");
let _strict = EnvGuard::set(&lock, "REST_JWT_VALIDATE_STRICT", "false");
let token = make_jwt(serde_json::json!({ "exp": 1 }));
let mut stderr = Vec::new();
let err =
validate_bearer_token_if_jwt(&token, &AuthSourceUsed::TokenProfile, "svc", &mut stderr)
.unwrap_err();
assert!(err.to_string().contains("JWT expired"));
}
#[test]
fn validate_bearer_token_errors_when_nbf_in_future() {
let lock = GlobalStateLock::new();
let _enabled = EnvGuard::set(&lock, "REST_JWT_VALIDATE_ENABLED", "true");
let _strict = EnvGuard::set(&lock, "REST_JWT_VALIDATE_STRICT", "false");
let token = make_jwt(serde_json::json!({ "nbf": 4_000_000_000i64 }));
let mut stderr = Vec::new();
let err =
validate_bearer_token_if_jwt(&token, &AuthSourceUsed::TokenProfile, "svc", &mut stderr)
.unwrap_err();
assert!(err.to_string().contains("JWT not yet valid"));
}
}