use crate::{
collection::{
ValueTemplate, cereal,
recipe_tree::{RecipeNode, RecipeTree},
},
http::HttpMethod,
};
use derive_more::{Deref, From, Into};
use indexmap::IndexMap;
use mime::Mime;
use reqwest::header;
use serde::{Deserialize, Serialize};
use slumber_template::{Template, TemplateParseError};
use slumber_util::{
ResultTraced, doc_link,
yaml::{self, SourceLocation, YamlError, YamlErrorKind},
};
use std::{
error::Error as StdError,
fmt::{self, Display},
io, iter,
path::{Path, PathBuf},
};
use thiserror::Error;
use tracing::info;
#[derive(Debug, Default, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(
// Allow any top-level property beginning with .
extend("patternProperties" = {
"^\\.": { "description": "Ignore any property beginning with `.`" }
}),
example = Collection::example(),
),
)]
pub struct Collection {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub profiles: IndexMap<ProfileId, Profile>,
#[serde(rename = "requests")]
pub recipes: RecipeTree,
}
impl Collection {
pub fn load(path: &Path) -> Result<Self, CollectionError> {
info!(?path, "Loading collection file");
yaml::deserialize_file(path)
.map_err(CollectionError::from)
.traced()
}
pub fn parse(input: &str) -> Result<Self, CollectionError> {
yaml::deserialize_str(input)
.map_err(CollectionError::from)
.traced()
}
}
impl Collection {
pub fn default_profile(&self) -> Option<&Profile> {
self.profiles.values().find(|profile| profile.default)
}
}
#[cfg(any(test, feature = "test"))]
impl Collection {
pub fn first_recipe_id(&self) -> &RecipeId {
self.recipes
.recipe_ids()
.next()
.expect("Collection has no recipes")
}
pub fn first_recipe(&self) -> &Recipe {
let id = self.first_recipe_id();
self.recipes.get_recipe(id).unwrap()
}
pub fn first_profile_id(&self) -> &ProfileId {
self.profiles.first().expect("Collection has no profiles").0
}
pub fn first_profile(&self) -> &Profile {
let id = self.first_profile_id();
self.profiles.get(id).unwrap()
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for Collection {
fn factory((): ()) -> Self {
use crate::test_util::by_id;
use serde_json::json;
let recipe = Recipe {
body: Some(RecipeBody::Json(
json!({"message": "hello"}).try_into().unwrap(),
)),
..Recipe::factory(())
};
let profile = Profile::factory(());
Collection {
name: None,
recipes: by_id([recipe]).into(),
profiles: by_id([profile]),
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(example = Profile::example()),
)]
pub struct Profile {
#[serde(skip)] pub id: ProfileId,
#[serde(skip)]
pub location: SourceLocation,
pub name: Option<String>,
#[serde(skip_serializing_if = "cereal::is_false")] #[cfg_attr(feature = "schema", schemars(default))]
pub default: bool,
pub data: IndexMap<String, ValueTemplate>,
}
impl Profile {
pub fn name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
pub fn default(&self) -> bool {
self.default
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for Profile {
fn factory((): ()) -> Self {
Self {
id: ProfileId::factory(()),
location: SourceLocation {
source: "memory".into(),
line: 5,
column: 4,
},
name: None,
default: false,
data: IndexMap::new(),
}
}
}
#[derive(
Clone,
Debug,
Deref,
Default,
derive_more::Display,
Eq,
From,
Hash,
Into,
PartialEq,
Serialize,
Deserialize,
)]
#[deref(forward)]
#[serde(transparent)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ProfileId(String);
#[cfg(any(test, feature = "test"))]
impl From<&str> for ProfileId {
fn from(value: &str) -> Self {
value.to_owned().into()
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for ProfileId {
fn factory((): ()) -> Self {
uuid::Uuid::new_v4().to_string().into()
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(example = Folder::example()),
)]
pub struct Folder {
#[serde(skip)] pub id: RecipeId,
#[serde(skip)]
pub location: SourceLocation,
pub name: Option<String>,
#[serde(rename = "requests")]
pub children: IndexMap<RecipeId, RecipeNode>,
}
impl Folder {
pub fn name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for Folder {
fn factory((): ()) -> Self {
Self {
id: RecipeId::factory(()),
location: SourceLocation {
source: "memory".into(),
line: 10,
column: 4,
},
name: None,
children: IndexMap::new(),
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(example = Recipe::example()),
)]
pub struct Recipe {
#[serde(skip)] pub id: RecipeId,
#[serde(skip)]
pub location: SourceLocation,
#[serde(skip_serializing_if = "cereal::is_true")] #[cfg_attr(feature = "schema", schemars(default = "persist_default"))]
pub persist: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub method: HttpMethod,
pub url: Template,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<RecipeBody>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication: Option<Authentication>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
#[cfg_attr(feature = "schema", schemars(default))]
pub query: IndexMap<String, QueryParameterValue>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
#[cfg_attr(feature = "schema", schemars(default))]
pub headers: IndexMap<String, Template>,
}
impl Recipe {
pub fn name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
pub fn mime(&self) -> Option<Mime> {
self.headers
.get(header::CONTENT_TYPE.as_str())
.and_then(|template| template.display().parse::<Mime>().ok())
.or_else(|| self.body.as_ref()?.mime())
}
pub fn query_iter(&self) -> impl Iterator<Item = (&str, usize, &Template)> {
self.query.iter().flat_map(|(k, v)| {
let iter: Box<dyn Iterator<Item = _>> = match v {
QueryParameterValue::One(value) => Box::new(iter::once(value)),
QueryParameterValue::Many(values) => Box::new(values.iter()),
};
iter.enumerate().map(move |(i, v)| (k.as_str(), i, v))
})
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for Recipe {
fn factory((): ()) -> Self {
Self {
id: RecipeId::factory(()),
location: SourceLocation {
source: "memory".into(),
line: 20,
column: 4,
},
persist: true,
name: None,
method: HttpMethod::Get,
url: "http://localhost/url".into(),
body: None,
authentication: None,
query: IndexMap::new(),
headers: IndexMap::new(),
}
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory<&str> for Recipe {
fn factory(id: &str) -> Self {
Self {
id: id.into(),
..Self::factory(())
}
}
}
#[cfg(feature = "schema")]
fn persist_default() -> bool {
true
}
#[derive(
Clone,
Debug,
Deref,
Default,
derive_more::Display,
Eq,
From,
Hash,
Into,
PartialEq,
Serialize,
Deserialize,
)]
#[deref(forward)]
#[serde(transparent)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RecipeId(String);
#[cfg(any(test, feature = "test"))]
impl From<&str> for RecipeId {
fn from(value: &str) -> Self {
value.to_owned().into()
}
}
#[cfg(any(test, feature = "test"))]
impl std::str::FromStr for RecipeId {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok::<_, ()>(s.to_owned().into())
}
}
#[cfg(any(test, feature = "test"))]
impl slumber_util::Factory for RecipeId {
fn factory((): ()) -> Self {
uuid::Uuid::new_v4().to_string().into()
}
}
#[derive(Clone, Debug, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Authentication<T = Template> {
Basic { username: T, password: Option<T> },
Bearer { token: T },
}
#[derive(Clone, Debug, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum QueryParameterValue {
One(Template),
Many(Vec<Template>),
}
#[cfg(any(test, feature = "test"))]
impl From<&'static str> for QueryParameterValue {
fn from(value: &'static str) -> Self {
QueryParameterValue::One(value.into())
}
}
#[cfg(any(test, feature = "test"))]
impl<const N: usize> From<[&'static str; N]> for QueryParameterValue {
fn from(values: [&'static str; N]) -> Self {
QueryParameterValue::Many(
values.into_iter().map(Template::from).collect(),
)
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum RecipeBody {
Json(ValueTemplate),
FormUrlencoded(IndexMap<String, Template>),
FormMultipart(IndexMap<String, Template>),
Stream(Template),
#[serde(untagged)]
Raw(Template),
}
impl RecipeBody {
pub fn json(value: serde_json::Value) -> Result<Self, TemplateParseError> {
Ok(Self::Json(value.try_into()?))
}
pub fn untemplated_json(value: serde_json::Value) -> Self {
Self::Json(ValueTemplate::from_raw_json(value))
}
pub fn mime(&self) -> Option<Mime> {
match self {
RecipeBody::Raw(_) | RecipeBody::Stream(_) => None,
RecipeBody::Json(_) => Some(mime::APPLICATION_JSON),
RecipeBody::FormUrlencoded(_) => {
Some(mime::APPLICATION_WWW_FORM_URLENCODED)
}
RecipeBody::FormMultipart(_) => Some(mime::MULTIPART_FORM_DATA),
}
}
}
#[cfg(any(test, feature = "test"))]
impl From<&'static str> for RecipeBody {
fn from(template: &'static str) -> Self {
Self::Raw(template.into())
}
}
#[derive(Debug, Error)]
pub enum CollectionError {
#[error(transparent)]
CurrentDir(io::Error),
#[error("Error loading `{}`", path.display())]
Io {
path: PathBuf,
#[source]
error: io::Error,
},
#[error("No collection file found in `{}` or its ancestors", path.display())]
NoFile { path: PathBuf },
#[error(transparent)]
Yaml(YamlCollectionError),
}
impl CollectionError {
pub fn location(&self) -> Option<&SourceLocation> {
if let Self::Yaml(YamlCollectionError(error)) = self {
Some(&error.location)
} else {
None
}
}
}
impl From<YamlError> for CollectionError {
fn from(error: YamlError) -> Self {
Self::Yaml(YamlCollectionError(error))
}
}
#[derive(Debug)]
pub struct YamlCollectionError(YamlError);
impl YamlCollectionError {
fn is_v3_error(&self) -> bool {
match &self.0.kind {
YamlErrorKind::UnexpectedField(field) => field == "chains",
YamlErrorKind::UnsupportedMerge => true,
YamlErrorKind::Unexpected { actual, .. } => {
actual.starts_with("tag")
}
YamlErrorKind::Other(error) => error
.downcast_ref::<TemplateParseError>()
.is_some_and(|error| error.to_string().contains("{{chains.")),
_ => false,
}
}
}
impl Display for YamlCollectionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)?;
if self.is_v3_error() {
write!(
f,
"\nThis looks like a collection from Slumber v3. \
Migrate to v4 or downgrade your installation to 3.x.\
\n{}",
doc_link("other/v4_migration")
)?;
}
Ok(())
}
}
impl StdError for YamlCollectionError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.0.source()
}
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::indexmap;
use itertools::Itertools;
use rstest::rstest;
use slumber_util::Factory;
#[rstest]
#[case::none(None, None, None)]
#[case::header(
// Header takes precedence over body
Some("text/plain"),
Some(RecipeBody::untemplated_json("hi!".into())),
Some("text/plain")
)]
#[case::unknown_mime(
// Fall back to body type
Some("bogus"),
Some(RecipeBody::untemplated_json("hi!".into())),
Some("application/json")
)]
#[case::json_body(
None,
Some(RecipeBody::untemplated_json("hi!".into())),
Some("application/json")
)]
#[case::unknown_body(
None,
Some(RecipeBody::Raw("hi!".into())),
None,
)]
#[case::form_urlencoded_body(
None,
Some(RecipeBody::FormUrlencoded(indexmap! {})),
Some("application/x-www-form-urlencoded")
)]
#[case::form_multipart_body(
None,
Some(RecipeBody::FormMultipart(indexmap! {})),
Some("multipart/form-data")
)]
fn test_recipe_mime(
#[case] header: Option<&'static str>,
#[case] body: Option<RecipeBody>,
#[case] expected: Option<&str>,
) {
let mut headers = IndexMap::new();
if let Some(header) = header {
headers.insert("content-type".into(), header.into());
}
let recipe = Recipe {
body,
headers,
..Recipe::factory(())
};
let expected = expected.and_then(|value| value.parse::<Mime>().ok());
assert_eq!(recipe.mime(), expected);
}
#[test]
fn test_query_iter() {
let recipe = Recipe {
query: indexmap! {
"param1".into() => ["value1.1", "value1.2"].into(),
"param2".into() => "value2.1".into(),
},
..Recipe::factory(())
};
assert_eq!(
recipe.query_iter().collect_vec().as_slice(),
&[
("param1", 0, &"value1.1".into()),
("param1", 1, &"value1.2".into()),
("param2", 0, &"value2.1".into()),
]
);
}
}