Skip to content

Commit

Permalink
Add default field to profiles
Browse files Browse the repository at this point in the history
For the CLI, the default profile will be used when --profile isn't passed. For the TUI, we'll just preselect this field on first startup. Only one profile can be marked as default, and that's enforced during deserialization.
  • Loading branch information
LucasPickering committed Sep 16, 2024
1 parent 93a994a commit 9def3f9
Show file tree
Hide file tree
Showing 13 changed files with 123 additions and 49 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- [See docs for more](https://slumber.lucaspickering.me/book/api/request_collection/chain_source.html#select)
- Cancel in-flight requests with the `cancel` action (bound to escape by default)
- Add `slumber new` subcommand to generate new collection files [#376](https://github.com/LucasPickering/slumber/issues/376)
- Add `default` field to profiles
- When using the CLI, the `--profile` argument can be omitted to use the default profile

### Fixed

Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ mod tests {
profiles: by_id([Profile {
id: "example".into(),
name: Some("Example Profile".into()),
default: false,
data: indexmap! {
"host".into() => "https://httpbin.org".into()
},
Expand Down
12 changes: 10 additions & 2 deletions crates/cli/src/commands/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ pub struct BuildRequestCommand {
/// ID of the recipe to render into a request
recipe_id: RecipeId,

/// ID of the profile to pull template values from
/// ID of the profile to pull template values from. If omitted and the
/// collection has default profile defined, use that profile. Otherwise,
/// profile data will not be available.
#[clap(long = "profile", short)]
profile: Option<ProfileId>,

Expand Down Expand Up @@ -165,10 +167,16 @@ impl BuildRequestCommand {
})?;
}

// Fall back to default profile if defined in the collection
let selected_profile = self.profile.or_else(|| {
let default_profile = collection.default_profile()?;
Some(default_profile.id.clone())
});

// Build the request
let overrides: IndexMap<_, _> = self.overrides.into_iter().collect();
let template_context = TemplateContext {
selected_profile: self.profile.clone(),
selected_profile,
collection,
// Passing the HTTP engine is how we tell the template renderer that
// it's ok to execute subrequests during render
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ mod tests {
Profile {
id: "profile1".into(),
name: Some("Profile 1".into()),
default: false,
data: indexmap! {
"user_guid".into() => "abc123".into(),
"username".into() => "xX{{chains.username}}Xx".into(),
Expand All @@ -266,6 +267,7 @@ mod tests {
Profile {
id: "profile2".into(),
name: Some("Profile 2".into()),
default: true,
data: indexmap! {
"host".into() => "https://httpbin.org".into(),

Expand Down
73 changes: 58 additions & 15 deletions crates/core/src/collection/cereal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use crate::{
template::Template,
};
use anyhow::Context;
use indexmap::IndexMap;
use itertools::Itertools;
use serde::{
de::{
self, EnumAccess, Error as _, MapAccess, SeqAccess, VariantAccess,
Expand Down Expand Up @@ -119,6 +121,34 @@ where
s.parse().map_err(D::Error::custom)
}

/// Deserialize a profile mapping. This also enforces that only one profile is
/// marked as default
pub fn deserialize_profiles<'de, D>(
deserializer: D,
) -> Result<IndexMap<ProfileId, Profile>, D::Error>
where
D: Deserializer<'de>,
{
let profiles: IndexMap<ProfileId, Profile> =
deserialize_id_map(deserializer)?;

// Make sure at most one profile is the default
let is_default = |profile: &&Profile| profile.default;

if profiles.values().filter(is_default).count() > 1 {
return Err(de::Error::custom(format!(
"Only one profile can be the default, but multiple were: {}",
profiles
.values()
.filter(is_default)
.map(Profile::id)
.format(", ")
)));
}

Ok(profiles)
}

/// Deserialize query parameters from either a sequence of `key=value` or a map
/// of `key: value`. Serialie back to a sequence `key=value`, since that will
/// always support duplicate keys
Expand Down Expand Up @@ -589,21 +619,34 @@ mod tests {
use std::time::Duration;

#[rstest]
// boolean
#[case::bool_true(Token::Bool(true), "true")]
#[case::bool_false(Token::Bool(false), "false")]
// numeric
#[case::u64(Token::U64(1000), "1000")]
#[case::i64_negative(Token::I64(-1000), "-1000")]
#[case::float_positive(Token::F64(10.1), "10.1")]
#[case::float_negative(Token::F64(-10.1), "-10.1")]
// string
#[case::str(Token::Str("hello"), "hello")]
#[case::str_null(Token::Str("null"), "null")]
#[case::str_true(Token::Str("true"), "true")]
#[case::str_false(Token::Str("false"), "false")]
fn test_deserialize_template(#[case] token: Token, #[case] expected: &str) {
assert_de_tokens(&Template::from(expected), &[token]);
#[case::multiple_default(
mapping([
("profile1", mapping([
("default", serde_yaml::Value::Bool(true)),
("data", mapping([("a", "1")]))
])),
("profile2", mapping([
("default", serde_yaml::Value::Bool(true)),
("data", mapping([("a", "2")]))
])),
]),
"Only one profile can be the default, but multiple were: \
profile1, profile2",
)]
fn test_deserialize_profiles_error(
#[case] yaml: impl Into<serde_yaml::Value>,
#[case] expected_error: &str,
) {
#[derive(Debug, Deserialize)]
#[serde(transparent)]
struct Wrap(
#[allow(dead_code)]
#[serde(deserialize_with = "deserialize_profiles")]
IndexMap<ProfileId, Profile>,
);

let yaml = yaml.into();
assert_err!(serde_yaml::from_value::<Wrap>(yaml), expected_error);
}

/// Test serializing and deserializing recipe bodies. Round trips should all
Expand Down
15 changes: 1 addition & 14 deletions crates/core/src/collection/insomnia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,20 +284,6 @@ impl Resource {
}
}

impl From<Environment> for Profile {
fn from(environment: Environment) -> Self {
Profile {
id: environment.id.into(),
name: Some(environment.name),
data: environment
.data
.into_iter()
.map(|(k, v)| (k, Template::raw(v)))
.collect(),
}
}
}

impl From<RequestGroup> for RecipeNode {
fn from(folder: RequestGroup) -> Self {
RecipeNode::Folder(Folder {
Expand Down Expand Up @@ -484,6 +470,7 @@ fn build_profiles(
Profile {
id,
name: Some(environment.name),
default: false,
data,
},
)
Expand Down
21 changes: 20 additions & 1 deletion crates/core/src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use strum::{EnumIter, IntoEnumIterator};
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[serde(deny_unknown_fields)]
pub struct Collection {
#[serde(default, deserialize_with = "cereal::deserialize_id_map")]
#[serde(default, deserialize_with = "cereal::deserialize_profiles")]
pub profiles: IndexMap<ProfileId, Profile>,
#[serde(default, deserialize_with = "cereal::deserialize_id_map")]
pub chains: IndexMap<ChainId, Chain>,
Expand All @@ -49,6 +49,12 @@ pub struct Profile {
#[serde(skip)] // This will be auto-populated from the map key
pub id: ProfileId,
pub name: Option<String>,
/// For the CLI, use this profile when no `--profile` flag is passed. For
/// the TUI, select this profile by default from the list. Only one profile
/// in the collection can be marked as default. This is enforced by a
/// custom deserializer function.
#[serde(default)]
pub default: bool,
pub data: IndexMap<String, Template>,
}

Expand All @@ -57,6 +63,10 @@ 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"))]
Expand All @@ -65,6 +75,7 @@ impl crate::test_util::Factory for Profile {
Self {
id: ProfileId::factory(()),
name: None,
default: false,
data: IndexMap::new(),
}
}
Expand Down Expand Up @@ -500,6 +511,14 @@ pub enum ChainOutputTrim {
Both,
}

impl Collection {
/// Get the profile marked as `default: true`, if any. At most one profile
/// can be marked as default.
pub fn default_profile(&self) -> Option<&Profile> {
self.profiles.values().find(|profile| profile.default)
}
}

/// Test-only helpers
#[cfg(any(test, feature = "test"))]
impl Collection {
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/collection/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ fn build_profiles(servers: Vec<Server>) -> IndexMap<ProfileId, Profile> {
// will be the same value, but we provide it for
// discoverability; the user may want to rename it
name: Some(url),
default: false,
data,
},
)
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/src/view/component/primary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ impl ToStringGenerate for MenuAction {}

impl PrimaryView {
pub fn new(collection: &Collection) -> Self {
let profile_pane = ProfilePane::new(&collection.profiles).into();
let profile_pane = ProfilePane::new(collection).into();
let recipe_list_pane = RecipeListPane::new(&collection.recipes).into();
let selected_pane = FixedSelectState::builder()
// Changing panes kicks us out of fullscreen
Expand Down
32 changes: 20 additions & 12 deletions crates/tui/src/view/component/profile_select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use crate::{
},
};
use anyhow::anyhow;
use indexmap::IndexMap;
use itertools::Itertools;
use persisted::PersistedKey;
use ratatui::{
Expand All @@ -28,7 +27,7 @@ use ratatui::{
use serde::Serialize;
use slumber_config::Action;
use slumber_core::{
collection::{HasId, Profile, ProfileId},
collection::{Collection, HasId, Profile, ProfileId},
util::doc_link,
};

Expand All @@ -50,20 +49,25 @@ pub struct ProfilePane {
struct SelectedProfileKey;

impl ProfilePane {
pub fn new(profiles: &IndexMap<ProfileId, Profile>) -> Self {
pub fn new(collection: &Collection) -> Self {
let mut selected_profile_id =
Persisted::new_default(SelectedProfileKey);

// Two invalid cases we need to handle here:
// - Nothing is persisted but the map has values now
// - Persisted ID isn't in the map now
// In either case, just fall back to the first value in the map (or
// `None` if it's empty)
// In either case, just fall back to:
// - Default profile if available
// - First profile if available
// - `None` if map is empty
match &*selected_profile_id {
Some(id) if profiles.contains_key(id) => {}
Some(id) if collection.profiles.contains_key(id) => {}
_ => {
*selected_profile_id.get_mut() =
profiles.first().map(|(id, _)| id.clone())
*selected_profile_id.get_mut() = collection
.default_profile()
.or(collection.profiles.values().next())
.map(Profile::id)
.cloned()
}
}

Expand Down Expand Up @@ -339,10 +343,10 @@ mod tests {
/// persistence
#[rstest]
#[case::empty(&[] , None, None)]
#[case::preselect(&["p1", "p2"] , None, Some("p1"))]
#[case::unknown(&["p1", "p2"] , Some("p3"), Some("p1"))]
#[case::preselect(&["p1", "p2", "default"] , None, Some("default"))]
#[case::unknown(&["p1", "p2", "default"] , Some("p3"), Some("default"))]
#[case::unknown_empty(&[] , Some("p1"), None)]
#[case::persisted(&["p1", "p2"] , Some("p2"), Some("p2"))]
#[case::persisted(&["p1", "p2", "default"] , Some("p2"), Some("p2"))]
fn test_initial_profile(
_harness: TestHarness,
#[case] profile_ids: &[&str],
Expand All @@ -351,6 +355,7 @@ mod tests {
) {
let profiles = by_id(profile_ids.iter().map(|&id| Profile {
id: id.into(),
default: id == "default",
..Profile::factory(())
}));
if let Some(persisted_id) = persisted_id {
Expand All @@ -361,7 +366,10 @@ mod tests {
}

let expected = expected.map(ProfileId::from);
let component = ProfilePane::new(&profiles);
let component = ProfilePane::new(&Collection {
profiles,
..Collection::factory(())
});
assert_eq!(*component.selected_profile_id, expected);
}
}
9 changes: 5 additions & 4 deletions docs/src/api/request_collection/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ A profile is a collection of static template values. It's useful for configuring

## Fields

| Field | Type | Description | Default |
| ------ | -------------------------------------------- | --------------------------------- | ---------------------- |
| `name` | `string` | Descriptive name to use in the UI | Value of key in parent |
| `data` | [`mapping[string, Template]`](./template.md) | Fields, mapped to their values | `{}` |
| Field | Type | Description | Default |
| --------- | -------------------------------------------- | ----------------------------------------------------------- | ---------------------- |
| `name` | `string` | Descriptive name to use in the UI | Value of key in parent |
| `default` | `boolean` | Use this profile in the CLI when `--profile` isn't provided | `null` |
| `data` | [`mapping[string, Template]`](./template.md) | Fields, mapped to their values | `{}` |

## Examples

Expand Down
1 change: 1 addition & 0 deletions slumber.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
profiles:
works:
name: This Works
default: true
data:
host: https://httpbin.org
username: xX{{chains.username}}Xx
Expand Down
1 change: 1 addition & 0 deletions test_data/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ profiles:
user_guid: abc123
profile2:
name: Profile 2
default: true
data:
<<: *base_profile_data

Expand Down

0 comments on commit 9def3f9

Please sign in to comment.