Skip to content

Commit

Permalink
Add action to save response body to file
Browse files Browse the repository at this point in the history
Closes #183
  • Loading branch information
LucasPickering committed May 2, 2024
1 parent 9af871d commit 27464ff
Show file tree
Hide file tree
Showing 19 changed files with 639 additions and 205 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added

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

### Changed

Expand Down
1 change: 1 addition & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
//! | RequestRecord |
//! +---------------+

mod cereal;
mod content_type;
mod query;
mod record;
Expand Down
92 changes: 92 additions & 0 deletions src/http/cereal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Serialization/deserialization for HTTP-releated types

use serde::{de, Deserialize, Deserializer, Serialize, Serializer};

/// Serialization/deserialization for [reqwest::Method]
pub mod serde_method {
use super::*;
use reqwest::Method;

pub fn serialize<S>(
method: &Method,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(method.as_str())
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<Method, D::Error>
where
D: Deserializer<'de>,
{
<&str>::deserialize(deserializer)?
.parse()
.map_err(de::Error::custom)
}
}

/// Serialization/deserialization for [reqwest::header::HeaderMap]
pub mod serde_header_map {
use super::*;
use indexmap::IndexMap;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};

pub fn serialize<S>(
headers: &HeaderMap,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// HeaderValue -> str is fallible, so we'll serialize as bytes instead
<IndexMap<&str, &[u8]>>::serialize(
&headers
.into_iter()
.map(|(k, v)| (k.as_str(), v.as_bytes()))
.collect(),
serializer,
)
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<HeaderMap, D::Error>
where
D: Deserializer<'de>,
{
<IndexMap<String, Vec<u8>>>::deserialize(deserializer)?
.into_iter()
.map::<Result<(HeaderName, HeaderValue), _>, _>(|(k, v)| {
// Fallibly map each key and value to header types
Ok((
k.try_into().map_err(de::Error::custom)?,
v.try_into().map_err(de::Error::custom)?,
))
})
.collect()
}
}

/// Serialization/deserialization for [reqwest::StatusCode]
pub mod serde_status_code {
use super::*;
use reqwest::StatusCode;

pub fn serialize<S>(
status_code: &StatusCode,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u16(status_code.as_u16())
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<StatusCode, D::Error>
where
D: Deserializer<'de>,
{
StatusCode::from_u16(u16::deserialize(deserializer)?)
.map_err(de::Error::custom)
}
}
172 changes: 72 additions & 100 deletions src/http/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

use crate::{
collection::{ProfileId, RecipeId},
http::{ContentType, ResponseContent},
http::{cereal, ContentType, ResponseContent},
util::ResultExt,
};
use anyhow::Context;
use bytes::Bytes;
use bytesize::ByteSize;
use chrono::{DateTime, Duration, Utc};
use derive_more::{Display, From};
use indexmap::IndexMap;
use mime::Mime;
use reqwest::{
header::{self, HeaderMap, HeaderValue},
header::{self, HeaderMap},
Method, StatusCode,
};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -111,11 +111,11 @@ pub struct Request {
/// The recipe used to generate this request (for historical context)
pub recipe_id: RecipeId,

#[serde(with = "serde_method")]
#[serde(with = "cereal::serde_method")]
pub method: Method,
/// URL, including query params/fragment
pub url: Url,
#[serde(with = "serde_header_map")]
#[serde(with = "cereal::serde_header_map")]
pub headers: HeaderMap,
/// Body content as bytes. This should be decoded as needed
pub body: Option<Body>,
Expand Down Expand Up @@ -171,9 +171,9 @@ impl Request {
/// potentially be very large.
#[derive(Debug, Serialize, Deserialize)]
pub struct Response {
#[serde(with = "serde_status_code")]
#[serde(with = "cereal::serde_status_code")]
pub status: StatusCode,
#[serde(with = "serde_header_map")]
#[serde(with = "cereal::serde_header_map")]
pub headers: HeaderMap,
pub body: Body,
}
Expand All @@ -194,11 +194,39 @@ impl Response {
}
}

/// Get the value of the `content-type` header
pub fn content_type(&self) -> Option<&[u8]> {
/// Get a suggested file name for the content of this response. First we'll
/// check the Content-Disposition header. If it's missing or doesn't have a
/// file name, we'll check the Content-Type to at least guess at an
/// extension.
pub fn file_name(&self) -> Option<String> {
self.headers
.get(header::CONTENT_TYPE)
.map(HeaderValue::as_bytes)
.get(header::CONTENT_DISPOSITION)
.and_then(|value| {
// Parse header for the `filename="{}"` parameter
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
let value = value.to_str().ok()?;
value.split(';').find_map(|part| {
let (key, value) = part.trim().split_once('=')?;
if key == "filename" {
Some(value.trim_matches('"').to_owned())
} else {
None
}
})
})
.or_else(|| {
// Grab the extension from the Content-Type header. Don't use
// self.conten_type() because we want to accept unknown types.
let content_type = self.headers.get(header::CONTENT_TYPE)?;
let mime: Mime = content_type.to_str().ok()?.parse().ok()?;
Some(format!("data.{}", mime.subtype()))
})
}

/// Get the content type of this response, based on the `Content-Type`
/// header. Return `None` if the header is missing or an unknown type.
pub fn content_type(&self) -> Option<ContentType> {
ContentType::from_response(self).ok()
}
}

Expand Down Expand Up @@ -309,103 +337,47 @@ impl PartialEq for Body {
}
}

/// Serialization/deserialization for [reqwest::Method]
mod serde_method {
use super::*;
use serde::{de, Deserializer, Serializer};

pub fn serialize<S>(
method: &Method,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(method.as_str())
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<Method, D::Error>
where
D: Deserializer<'de>,
{
<&str>::deserialize(deserializer)?
.parse()
.map_err(de::Error::custom)
}
}

/// Serialization/deserialization for [reqwest::header::HeaderMap]
mod serde_header_map {
use super::*;
use reqwest::header::{HeaderName, HeaderValue};
use serde::{de, Deserializer, Serializer};

pub fn serialize<S>(
headers: &HeaderMap,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// HeaderValue -> str is fallible, so we'll serialize as bytes instead
<IndexMap<&str, &[u8]>>::serialize(
&headers
.into_iter()
.map(|(k, v)| (k.as_str(), v.as_bytes()))
.collect(),
serializer,
)
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<HeaderMap, D::Error>
where
D: Deserializer<'de>,
{
<IndexMap<String, Vec<u8>>>::deserialize(deserializer)?
.into_iter()
.map::<Result<(HeaderName, HeaderValue), _>, _>(|(k, v)| {
// Fallibly map each key and value to header types
Ok((
k.try_into().map_err(de::Error::custom)?,
v.try_into().map_err(de::Error::custom)?,
))
})
.collect()
}
}

/// Serialization/deserialization for [reqwest::StatusCode]
mod serde_status_code {
use super::*;
use serde::{de, Deserializer, Serializer};

pub fn serialize<S>(
status_code: &StatusCode,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u16(status_code.as_u16())
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<StatusCode, D::Error>
where
D: Deserializer<'de>,
{
StatusCode::from_u16(u16::deserialize(deserializer)?)
.map_err(de::Error::custom)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::*;
use factori::create;
use indexmap::indexmap;
use rstest::rstest;
use serde_json::json;

#[rstest]
#[case::content_disposition(
create!(Response, headers: header_map(indexmap! {
"content-disposition" => "form-data;name=\"field\"; filename=\"fish.png\"",
"content-type" => "image/png",
})),
Some("fish.png")
)]
#[case::content_type_known(
create!(Response, headers: header_map(indexmap! {
"content-disposition" => "form-data",
"content-type" => "application/json",
})),
Some("data.json")
)]
#[case::content_type_unknown(
create!(Response, headers: header_map(indexmap! {
"content-disposition" => "form-data",
"content-type" => "image/jpeg",
})),
Some("data.jpeg")
)]
#[case::none(
create!(Response),None
)]
fn test_file_name(
#[case] response: Response,
#[case] expected: Option<&str>,
) {
assert_eq!(response.file_name().as_deref(), expected);
}

#[test]
fn test_to_curl() {
let headers = indexmap! {
Expand Down
8 changes: 4 additions & 4 deletions src/template/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ pub struct Prompt {
/// Should the value the user is typing be masked? E.g. password input
pub sensitive: bool,
/// How the prompter will pass the answer back
pub channel: PromptChannel,
pub channel: PromptChannel<String>,
}

/// Channel used to return a prompt response. This is its own type so we can
/// provide wrapping functionality while letting the user decompose the `Prompt`
/// type.
#[derive(Debug, From)]
pub struct PromptChannel(oneshot::Sender<String>);
pub struct PromptChannel<T>(oneshot::Sender<T>);

impl PromptChannel {
impl<T> PromptChannel<T> {
/// Return the value that the user gave
pub fn respond(self, response: String) {
pub fn respond(self, response: T) {
// This error *shouldn't* ever happen, because the templating task
// stays open until it gets a response
let _ = self
Expand Down
Loading

0 comments on commit 27464ff

Please sign in to comment.