Skip to content

Commit

Permalink
Add stdin option to command chains
Browse files Browse the repository at this point in the history
  • Loading branch information
maksimowiczm authored and LucasPickering committed May 5, 2024
1 parent b3a5612 commit 65be7ad
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 33 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Add action to save response body to file ([#183](https://github.com/LucasPickering/slumber/issues/183))
- Add `theme` field to the config, to configure colors ([#193](https://github.com/LucasPickering/slumber/issues/193))
- [See docs](https://slumber.lucaspickering.me/book/api/configuration/theme.html) for more info
- Add `stdin` option to command chains ([#190](https://github.com/LucasPickering/slumber/issues/190))

### Changed

Expand Down
1 change: 1 addition & 0 deletions docs/src/api/request_collection/chain_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ Execute a command and use its stdout as the rendered value.
| Field | Type | Description | Default |
| --------- | ------------ | ----------------------------------------------------------- | -------- |
| `command` | `Template[]` | Command to execute, in the format `[program, ...arguments]` | Required |
| `stdin` | `Template` | Standard input which will be piped into the command | None |

### File

Expand Down
3 changes: 2 additions & 1 deletion docs/src/user_guide/chains.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ chains:
recipe: login
auth_token:
source: !command
command: [sh, -c, "echo '{{chains.auth_token_raw}}' | cut -d':' -f2"]
command: [ "cut", "-d':'", "-f2" ]
stdin: "{{chains.auth_token_raw}}"
requests:
login: !request
Expand Down
3 changes: 2 additions & 1 deletion docs/src/user_guide/filter_query.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ chains:
recipe: login
auth_token:
source: !command
command: [sh, -c, "echo '{{chains.auth_token_raw}}' | jq .token"]
command: [ "jq", ".token" ]
stdin: "{{chains.auth_token_raw}}
requests:
login: !request
Expand Down
5 changes: 4 additions & 1 deletion src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,10 @@ pub enum ChainSource {
section: ChainRequestSection,
},
/// Run an external command to get a result
Command { command: Vec<Template> },
Command {
command: Vec<Template>,
stdin: Option<Template>,
},
/// Load data from a file
File { path: Template },
/// Prompt the user for a value
Expand Down
37 changes: 28 additions & 9 deletions src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ mod tests {
let profile_data = indexmap! {"field1".into() => "field".into()};
let source = ChainSource::Command {
command: vec!["echo".into(), "chain".into()],
stdin: None,
};
let overrides = indexmap! {
"field1".into() => "override".into(),
Expand Down Expand Up @@ -581,10 +582,20 @@ mod tests {
}

/// Test success with chained command
#[rstest]
#[case::with_stdin(&["tail"], Some("hello!"), "hello!")]
#[case::raw_command(&["echo", "-n", "hello!"], None, "hello!")]
#[tokio::test]
async fn test_chain_command() {
let command = vec!["echo".into(), "-n".into(), "hello!".into()];
let chain = create!(Chain, source: ChainSource::Command { command });
async fn test_chain_command(
#[case] command: &[&str],
#[case] stdin: Option<&str>,
#[case] expected: &str,
) {
let source = ChainSource::Command {
command: command.iter().copied().map(Template::from).collect(),
stdin: stdin.map(Template::from),
};
let chain = create!(Chain, source: source);
let context = create!(
TemplateContext,
collection: create!(
Expand All @@ -593,21 +604,29 @@ mod tests {
),
);

assert_eq!(render!("{{chains.chain1}}", context).unwrap(), "hello!");
assert_eq!(render!("{{chains.chain1}}", context).unwrap(), expected);
}

/// Test failure with chained command
#[rstest]
#[case::no_command(&[], "No command given")]
#[case::unknown_command(&["totally not a program"], "No such file or directory")]
#[case::command_error(&["head", "/dev/random"], "invalid utf-8 sequence")]
#[case::no_command(&[], None, "No command given")]
#[case::unknown_command(&["totally not a program"], None, "No such file or directory")]
#[case::command_error(&["head", "/dev/random"], None, "invalid utf-8 sequence")]
#[case::stdin_error(
&["tail"],
Some("{{chains.stdin}}"),
"Resolving chain `chain1`: Rendering nested template for field `stdin`: \
Resolving chain `stdin`: Unknown chain: stdin"
)]
#[tokio::test]
async fn test_chain_command_error(
#[case] command: &[&str],
#[case] stdin: Option<&str>,
#[case] expected_error: &str,
) {
let source = ChainSource::Command {
command: command.iter().copied().map(Template::from).collect(),
stdin: stdin.map(Template::from),
};
let chain = create!(Chain, source: source);
let context = create!(
Expand Down Expand Up @@ -767,7 +786,7 @@ mod tests {
let command_chain = create!(
Chain,
id: "command".into(),
source: ChainSource::Command { command },
source: ChainSource::Command { command, stdin: None},
);

let context = create!(
Expand Down Expand Up @@ -802,7 +821,7 @@ mod tests {
let command_chain = create!(
Chain,
id: "command".into(),
source: ChainSource::Command { command },
source: ChainSource::Command { command, stdin: None },
);

let context = create!(
Expand Down
91 changes: 70 additions & 21 deletions src/template/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ use futures::future;
use std::{
env,
path::PathBuf,
process::Stdio,
sync::{atomic::Ordering, Arc},
};
use tokio::{fs, process::Command, sync::oneshot};
use tokio::{fs, io::AsyncWriteExt, process::Command, sync::oneshot};
use tracing::{debug, debug_span, instrument, trace};

/// Outcome of rendering a single chunk. This allows attaching some metadata to
Expand Down Expand Up @@ -284,9 +285,13 @@ impl<'a> TemplateSource<'a> for ChainTemplateSource<'a> {
ChainSource::File { path } => {
self.render_file(context, path).await?
}
ChainSource::Command { command } => {
ChainSource::Command { command, stdin } => {
// No way to guess content type on this
(self.render_command(context, command).await?, None)
(
self.render_command(context, command, stdin.as_ref())
.await?,
None,
)
}
ChainSource::Prompt { message, default } => (
self.render_prompt(
Expand Down Expand Up @@ -484,6 +489,7 @@ impl<'a> ChainTemplateSource<'a> {
&self,
context: &TemplateContext,
command: &[Template],
stdin: Option<&Template>,
) -> Result<Vec<u8>, ChainError> {
// Render each arg in the command
let command = future::try_join_all(command.iter().enumerate().map(
Expand All @@ -501,26 +507,69 @@ impl<'a> ChainTemplateSource<'a> {
let [program, args @ ..] = command.as_slice() else {
return Err(ChainError::CommandMissing);
};
debug_span!("Executing command", ?command)
.in_scope(|| async {
let output = Command::new(program)
.args(args)
.output()
.await
.map_err(|error| ChainError::Command {
command: command.to_owned(),
error,
})
.traced()?;

debug!(
stdout = %String::from_utf8_lossy(&output.stdout),
stderr = %String::from_utf8_lossy(&output.stderr),
"Command success"
);
Ok(output.stdout)

let _ = debug_span!("Executing command", ?command).entered();

// Render the stdin template, if present
let input = if let Some(template) = stdin {
let input =
template.render_stitched(context).await.map_err(|error| {
ChainError::Nested {
field: "stdin".into(),
error: error.into(),
}
})?;

Some(input)
} else {
None
};

// Spawn the command process
let mut process = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| ChainError::Command {
command: command.to_owned(),
error,
})
.traced()?;

// Write the stdin to the process
if let Some(input) = input {
process
.stdin
.as_mut()
.expect("Process missing stdin")
.write_all(input.as_bytes())
.await
.map_err(|error| ChainError::Command {
command: command.to_owned(),
error,
})
.traced()?;
}

// Wait for the process to finish
let output = process
.wait_with_output()
.await
.map_err(|error| ChainError::Command {
command: command.to_owned(),
error,
})
.traced()?;

debug!(
stdout = %String::from_utf8_lossy(&output.stdout),
stderr = %String::from_utf8_lossy(&output.stderr),
"Command success"
);

Ok(output.stdout)
}

/// Render a value by asking the user to provide it
Expand Down

0 comments on commit 65be7ad

Please sign in to comment.