Skip to content

Commit

Permalink
Add section field to !request chain sources
Browse files Browse the repository at this point in the history
This allows the user to chain values from a response header rather than the body. Closes #184
  • Loading branch information
Sammo98 authored and LucasPickering committed May 1, 2024
1 parent 78ab3b1 commit 9af871d
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 14 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased] - ReleaseDate

### Added

- Add option to chain values from response header rather than body ([#184](https://github.com/LucasPickering/slumber/issues/184))

### Changed

- Reduce UI latency under certain scenarios
Expand Down
19 changes: 19 additions & 0 deletions docs/src/api/request_collection/chain_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Chain a value from the body of another response. This can reference either
| --------- | ----------------------------------------------- | ----------------------------------------------------------------------------- | -------- |
| `recipe` | `string` | Recipe to load value from | Required |
| `trigger` | [`ChainRequestTrigger`](#chain-request-trigger) | When the upstream recipe should be executed, as opposed to loaded from memory | `!never` |
| `section` | [`ChainRequestSection`](#chain-request-section) | The section (header or body) of the request from which to chain a value | `Body` |

### Chain Request Trigger

Expand Down Expand Up @@ -81,6 +82,24 @@ recipe: login
trigger: !always
```

### Chain Request Section

This defines which section of the response (headers or body) should be used to load the value from.

| Variant | Type | Description |
| ------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `body` | None | The body of the response |
| `header` | `string` | A specific header from the response. If the header appears multiple times in the response, only the first value will be used |


#### Examples

```yaml
!request
recipe: login
section: !header Token # This will take the value of the 'Token' header
```

### Command

Execute a command and use its stdout as the rendered value.
Expand Down
12 changes: 12 additions & 0 deletions src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ pub enum ChainSource {
/// When should this request be automatically re-executed?
#[serde(default)]
trigger: ChainRequestTrigger,
#[serde(default)]
section: ChainRequestSection,
},
/// Run an external command to get a result
Command { command: Vec<Template> },
Expand All @@ -254,6 +256,16 @@ pub enum ChainSource {
},
}

/// The component of the response to use as the chain source
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ChainRequestSection {
#[default]
Body,
Header(String),
}

/// Define when a recipe with a chained request should auto-execute the
/// dependency request.
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize)]
Expand Down
33 changes: 24 additions & 9 deletions src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ impl<T> TemplateKey<T> {
mod tests {
use super::*;
use crate::{
collection::{Chain, ChainRequestTrigger, ChainSource, RecipeId},
collection::{
Chain, ChainRequestSection, ChainRequestTrigger, ChainSource,
RecipeId,
},
config::Config,
http::{ContentType, RequestRecord},
test_util::*,
Expand Down Expand Up @@ -305,16 +308,19 @@ mod tests {
#[rstest]
#[case::no_selector(
None,
r#"{"array":[1,2],"bool":false,"number":6,"object":{"a":1},"string":"Hello World!"}"#,
ChainRequestSection::Body,
r#"{"array":[1,2],"bool":false,"number":6,"object":{"a":1},"string":"Hello World!"}"#
)]
#[case::string(Some("$.string"), "Hello World!")]
#[case::number(Some("$.number"), "6")]
#[case::bool(Some("$.bool"), "false")]
#[case::array(Some("$.array"), "[1,2]")]
#[case::object(Some("$.object"), "{\"a\":1}")]
#[case::string(Some("$.string"), ChainRequestSection::Body, "Hello World!")]
#[case::number(Some("$.number"), ChainRequestSection::Body, "6")]
#[case::bool(Some("$.bool"), ChainRequestSection::Body, "false")]
#[case::array(Some("$.array"), ChainRequestSection::Body, "[1,2]")]
#[case::object(Some("$.object"), ChainRequestSection::Body, "{\"a\":1}")]
#[case::header(None, ChainRequestSection::Header("Token".into()), "Secret Value")]
#[tokio::test]
async fn test_chain_request(
#[case] selector: Option<&str>,
#[case] section: ChainRequestSection,
#[case] expected_value: &str,
) {
let recipe_id: RecipeId = "recipe1".into();
Expand All @@ -326,9 +332,10 @@ mod tests {
"array": [1,2],
"object": {"a": 1},
});
let response_headers =
header_map(indexmap! {"Token" => "Secret Value"});
let request = create!(Request, recipe_id: recipe_id.clone());
let response =
create!(Response, body: response_body.to_string().into());
let response = create!(Response, body: response_body.to_string().into(), headers: response_headers);
database
.insert_request(&create!(
RequestRecord,
Expand All @@ -343,6 +350,7 @@ mod tests {
source: ChainSource::Request {
recipe: recipe_id.clone(),
trigger: Default::default(),
section,
},
selector: selector,
content_type: Some(ContentType::Json),
Expand Down Expand Up @@ -376,6 +384,7 @@ mod tests {
source: ChainSource::Request {
recipe: "unknown".into(),
trigger: Default::default(),
section: Default::default(),
}
),
None,
Expand All @@ -390,6 +399,7 @@ mod tests {
source: ChainSource::Request {
recipe: "recipe1".into(),
trigger: Default::default(),
section: Default::default(),
}
),
Some("recipe1"),
Expand All @@ -404,6 +414,7 @@ mod tests {
source: ChainSource::Request {
recipe: "recipe1".into(),
trigger: ChainRequestTrigger::Always,
section: Default::default(),
}
),
Some("recipe1"),
Expand All @@ -418,6 +429,7 @@ mod tests {
source: ChainSource::Request {
recipe: "recipe1".into(),
trigger: Default::default(),
section: Default::default(),
},
selector: Some("$.message".parse().unwrap()),
),
Expand All @@ -436,6 +448,7 @@ mod tests {
source: ChainSource::Request {
recipe: "recipe1".into(),
trigger: Default::default(),
section: Default::default(),
},
selector: Some("$.message".parse().unwrap()),
content_type: Some(ContentType::Json),
Expand All @@ -455,6 +468,7 @@ mod tests {
source: ChainSource::Request {
recipe: "recipe1".into(),
trigger: Default::default(),
section:Default::default()
},
selector: Some("$.*".parse().unwrap()),
content_type: Some(ContentType::Json),
Expand Down Expand Up @@ -546,6 +560,7 @@ mod tests {
source: ChainSource::Request {
recipe: recipe.id.clone(),
trigger,
section:Default::default(),
},
);
let http_engine = HttpEngine::new(&Config::default(), database.clone());
Expand Down
4 changes: 4 additions & 0 deletions src/template/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ pub enum ChainError {
#[source]
error: Box<TemplateError>,
},

/// Specified !header did not exist in the response
#[error("Header `{header}` not in response")]
MissingHeader { header: String },
}

/// Error occurred while trying to build/execute a triggered request
Expand Down
42 changes: 37 additions & 5 deletions src/template/render.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! Template rendering implementation

use crate::{
collection::{ChainId, ChainRequestTrigger, ChainSource, RecipeId},
collection::{
ChainId, ChainRequestSection, ChainRequestTrigger, ChainSource,
RecipeId,
},
http::{ContentType, RequestBuilder, RequestRecord, Response},
template::{
error::TriggeredRequestError, parse::TemplateInputChunk, ChainError,
Expand Down Expand Up @@ -264,15 +267,19 @@ impl<'a> TemplateSource<'a> for ChainTemplateSource<'a> {
// We intentionally throw the content detection error away here,
// because it isn't that intuitive for users and is hard to plumb
let (value, content_type) = match &chain.source {
ChainSource::Request { recipe, trigger } => {
ChainSource::Request {
recipe,
trigger,
section,
} => {
let response =
self.get_response(context, recipe, *trigger).await?;
// Guess content type based on HTTP header
let content_type =
ContentType::from_response(&response).ok();
// This will clone the bytes, which is necessary for the
// string conversion below anyway
(response.body.into_bytes().into(), content_type)
let value =
self.extract_response_value(response, section)?;
(value, content_type)
}
ChainSource::File { path } => {
self.render_file(context, path).await?
Expand Down Expand Up @@ -424,6 +431,31 @@ impl<'a> ChainTemplateSource<'a> {
.expect("Request Arc should have only one reference"))
}

/// Extract the specified component bytes from the response.
/// Returns an error with the missing header if not found.
fn extract_response_value(
&self,
response: Response,
component: &ChainRequestSection,
) -> Result<Vec<u8>, ChainError> {
Ok(match component {
// This will clone the bytes, which is necessary for the subsequent
// string conversion anyway
ChainRequestSection::Body => response.body.into_bytes().into(),
ChainRequestSection::Header(target_header) => {
response
.headers
// If header has multiple values, only grab the first
.get(target_header)
.ok_or_else(|| ChainError::MissingHeader {
header: target_header.clone(),
})?
.as_bytes()
.to_vec()
}
})
}

/// Render a chained value from a file. Return the files bytes, as well as
/// its content type if it's known
async fn render_file(
Expand Down
1 change: 1 addition & 0 deletions src/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ factori!(Chain, {
source = ChainSource::Request {
recipe: RecipeId::default(),
trigger: Default::default(),
section: Default::default(),
},
sensitive = false,
selector = None,
Expand Down

0 comments on commit 9af871d

Please sign in to comment.