Skip to content

Commit

Permalink
Toggle query/header rows in recipe
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Dec 28, 2023
1 parent 262358c commit 9486819
Show file tree
Hide file tree
Showing 14 changed files with 375 additions and 176 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- Move app-level configuration into a file ([#89](https://github.com/LucasPickering/slumber/issues/89))
- Right now the only supported field is `preview_templates`
- Toggle query parameters and headers in recipe pane ([#30](https://github.com/LucasPickering/slumber/issues/30))
- You can easily enable/disable parameters and headers without having to modify the collection file now

### Changed

Expand Down
3 changes: 2 additions & 1 deletion src/cli/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::{
cli::Subcommand,
collection::{CollectionFile, ProfileId, RecipeId},
db::Database,
http::{HttpEngine, RequestBuilder},
http::{HttpEngine, RecipeOptions, RequestBuilder},
template::{Prompt, Prompter, TemplateContext},
util::ResultExt,
GlobalArgs,
Expand Down Expand Up @@ -75,6 +75,7 @@ impl Subcommand for RequestCommand {
let overrides: IndexMap<_, _> = self.overrides.into_iter().collect();
let request = RequestBuilder::new(
recipe,
RecipeOptions::default(),
TemplateContext {
profile,
chains: collection.chains.clone(),
Expand Down
16 changes: 8 additions & 8 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ impl Database {
)
.down("DROP TABLE requests"),
M::up(
// Values will be serialized as msgpack
// keys+values will be serialized as msgpack
"CREATE TABLE ui_state (
key TEXT NOT NULL,
key BLOB NOT NULL,
collection_id UUID NOT NULL,
value BLOB NOT NULL,
PRIMARY KEY (key, collection_id),
Expand Down Expand Up @@ -355,7 +355,7 @@ impl CollectionDatabase {
/// Get the value of a UI state field
pub fn get_ui<K, V>(&self, key: K) -> anyhow::Result<Option<V>>
where
K: Display,
K: Debug + Serialize,
V: Debug + DeserializeOwned,
{
let value = self
Expand All @@ -366,7 +366,7 @@ impl CollectionDatabase {
WHERE collection_id = :collection_id AND key = :key",
named_params! {
":collection_id": self.collection_id,
":key": key.to_string(),
":key": Bytes(&key),
},
|row| {
let value: Bytes<V> = row.get("value")?;
Expand All @@ -376,17 +376,17 @@ impl CollectionDatabase {
.optional()
.context("Error fetching UI state from database")
.traced()?;
debug!(%key, ?value, "Fetched UI state");
debug!(?key, ?value, "Fetched UI state");
Ok(value)
}

/// Set the value of a UI state field
pub fn set_ui<K, V>(&self, key: K, value: V) -> anyhow::Result<()>
where
K: Display,
K: Debug + Serialize,
V: Debug + Serialize,
{
debug!(%key, ?value, "Setting UI state");
debug!(?key, ?value, "Setting UI state");
self.database
.connection()
.execute(
Expand All @@ -396,7 +396,7 @@ impl CollectionDatabase {
ON CONFLICT DO UPDATE SET value = excluded.value",
named_params! {
":collection_id": self.collection_id,
":key": key.to_string(),
":key": Bytes(key),
":value": Bytes(value),
},
)
Expand Down
147 changes: 88 additions & 59 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ pub use parse::*;
pub use record::*;

use crate::{
collection::Recipe,
db::CollectionDatabase,
template::{Template, TemplateContext},
collection::Recipe, db::CollectionDatabase, template::TemplateContext,
util::ResultExt,
};
use anyhow::Context;
Expand All @@ -53,6 +51,7 @@ use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue},
Client,
};
use std::collections::HashSet;
use tokio::try_join;
use tracing::{debug, info, info_span};

Expand Down Expand Up @@ -220,22 +219,43 @@ pub struct RequestBuilder {
id: RequestId,
// We need this during the build
recipe: Recipe,
options: RecipeOptions,
template_context: TemplateContext,
}

/// OPtions for modifying a recipe during a build. This is helpful for applying
/// temporary modifications made by the user. By providing this in a separate
/// struct, we prevent the need to clone, modify, and pass recipes everywhere.
/// Recipes could be very large so cloning may be expensive, and this options
/// layer makes the available modifications clear and restricted.
#[derive(Clone, Debug, Default)]
pub struct RecipeOptions {
/// Which headers should be excluded? A blacklist allows the default to be
/// "include all".
pub disabled_headers: HashSet<String>,
/// Which query parameters should be excluded? A blacklist allows the
/// default to be "include all".
pub disabled_query_parameters: HashSet<String>,
}

impl RequestBuilder {
/// Instantiate new request builder for the given recipe. Use [Self::build]
/// to build it.
///
/// This needs an owned recipe and context so they can be moved into a
/// subtask for the build.
pub fn new(recipe: Recipe, template_context: TemplateContext) -> Self {
pub fn new(
recipe: Recipe,
options: RecipeOptions,
template_context: TemplateContext,
) -> Self {
debug!(recipe_id = %recipe.id, "Building request from recipe");
let request_id = RequestId::new();

Self {
id: request_id,
recipe,
options,
template_context,
}
}
Expand All @@ -258,20 +278,20 @@ impl RequestBuilder {

/// Outsourced build function, to make error conversion easier later
async fn build_helper(self) -> anyhow::Result<Request> {
let recipe = self.recipe;
// let recipe = self.recipe;
let template_context = &self.template_context;
let method = recipe.method.parse()?;
let method = self.recipe.method.parse()?;

// Render everything in parallel
let (url, headers, query, body) = try_join!(
Self::render_url(template_context, &recipe.url),
Self::render_headers(template_context, &recipe.headers),
Self::render_query(template_context, &recipe.query),
Self::render_body(template_context, recipe.body.as_ref()),
self.render_url(),
self.render_headers(),
self.render_query(),
self.render_body(),
)?;

info!(
recipe_id = %recipe.id,
recipe_id = %self.recipe.id,
"Built request from recipe",
);

Expand All @@ -281,7 +301,7 @@ impl RequestBuilder {
.profile
.as_ref()
.map(|profile| profile.id.clone()),
recipe_id: recipe.id.clone(),
recipe_id: self.recipe.id,
method,
url,
query,
Expand All @@ -290,70 +310,79 @@ impl RequestBuilder {
})
}

async fn render_url(
template_context: &TemplateContext,
url: &Template,
) -> anyhow::Result<String> {
url.render(template_context)
async fn render_url(&self) -> anyhow::Result<String> {
self.recipe
.url
.render(&self.template_context)
.await
.context("Error rendering URL")
}

async fn render_headers(
template_context: &TemplateContext,
headers: &IndexMap<String, Template>,
) -> anyhow::Result<HeaderMap> {
let iter = headers.iter().map(|(header, value_template)| async move {
let value = value_template
.render(template_context)
.await
.context(format!("Error rendering header `{header}`"))?;
// Strip leading/trailing line breaks because they're going to
// trigger a validation error and are probably a mistake. This is
// a balance between convenience and explicitness
let value = value.trim_matches(|c| c == '\n' || c == '\r');
// String -> header conversions are fallible, if headers
// are invalid
Ok::<(HeaderName, HeaderValue), anyhow::Error>((
header
.try_into()
.context(format!("Error parsing header name `{header}`"))?,
value.try_into().context(format!(
"Error parsing value for header `{header}`"
))?,
))
});
async fn render_headers(&self) -> anyhow::Result<HeaderMap> {
let template_context = &self.template_context;
let iter = self
.recipe
.headers
.iter()
// Filter out disabled headers
.filter(|(header, _)| {
!self.options.disabled_headers.contains(*header)
})
.map(|(header, value_template)| async move {
let value = value_template
.render(template_context)
.await
.context(format!("Error rendering header `{header}`"))?;
// Strip leading/trailing line breaks because they're going to
// trigger a validation error and are probably a mistake. This
// is a balance between convenience and
// explicitness
let value = value.trim_matches(|c| c == '\n' || c == '\r');
// String -> header conversions are fallible, if headers
// are invalid
Ok::<(HeaderName, HeaderValue), anyhow::Error>((
header.try_into().context(format!(
"Error parsing header name `{header}`"
))?,
value.try_into().context(format!(
"Error parsing value for header `{header}`"
))?,
))
});
Ok(future::try_join_all(iter)
.await?
.into_iter()
.collect::<HeaderMap>())
}

async fn render_query(
template_context: &TemplateContext,
query: &IndexMap<String, Template>,
) -> anyhow::Result<IndexMap<String, String>> {
let iter = query.iter().map(|(k, v)| async move {
Ok::<_, anyhow::Error>((
k.clone(),
v.render(template_context).await.context(format!(
"Error rendering query parameter `{k}`"
))?,
))
});
async fn render_query(&self) -> anyhow::Result<IndexMap<String, String>> {
let template_context = &self.template_context;
let iter = self
.recipe
.query
.iter()
// Filter out disabled params
.filter(|(param, _)| {
!self.options.disabled_query_parameters.contains(*param)
})
.map(|(k, v)| async move {
Ok::<_, anyhow::Error>((
k.clone(),
v.render(template_context).await.context(format!(
"Error rendering query parameter `{k}`"
))?,
))
});
Ok(future::try_join_all(iter)
.await?
.into_iter()
.collect::<IndexMap<String, String>>())
}

async fn render_body(
template_context: &TemplateContext,
body: Option<&Template>,
) -> anyhow::Result<Option<String>> {
match body {
async fn render_body(&self) -> anyhow::Result<Option<String>> {
match &self.recipe.body {
Some(body) => Ok(Some(
body.render(template_context)
body.render(&self.template_context)
.await
.context("Error rendering body")?,
)),
Expand Down
8 changes: 5 additions & 3 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
collection::{Collection, CollectionFile, ProfileId, RecipeId},
config::Config,
db::{CollectionDatabase, Database},
http::{HttpEngine, RequestBuilder},
http::{HttpEngine, RecipeOptions, RequestBuilder},
template::{Prompter, Template, TemplateChunk, TemplateContext},
tui::{
context::TuiContext,
Expand Down Expand Up @@ -206,7 +206,8 @@ impl Tui {
Message::HttpBeginRequest {
recipe_id,
profile_id,
} => self.send_request(profile_id, recipe_id)?,
options,
} => self.send_request(profile_id, recipe_id, options)?,
Message::HttpBuildError {
profile_id,
recipe_id,
Expand Down Expand Up @@ -313,6 +314,7 @@ impl Tui {
&mut self,
profile_id: Option<ProfileId>,
recipe_id: RecipeId,
options: RecipeOptions,
) -> anyhow::Result<()> {
let recipe = self
.collection_file
Expand All @@ -327,7 +329,7 @@ impl Tui {
let template_context = self
.template_context(profile_id.as_ref(), self.messages_tx.clone())?;
let http_engine = self.http_engine.clone();
let builder = RequestBuilder::new(recipe, template_context);
let builder = RequestBuilder::new(recipe, options, template_context);
let messages_tx = self.messages_tx.clone();

// Mark request state as building
Expand Down
6 changes: 5 additions & 1 deletion src/tui/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
use crate::{
collection::{Collection, ProfileId, RecipeId},
http::{RequestBuildError, RequestError, RequestId, RequestRecord},
http::{
RecipeOptions, RequestBuildError, RequestError, RequestId,
RequestRecord,
},
template::{Prompt, Prompter, Template, TemplateChunk},
util::ResultExt,
};
Expand Down Expand Up @@ -61,6 +64,7 @@ pub enum Message {
HttpBeginRequest {
profile_id: Option<ProfileId>,
recipe_id: RecipeId,
options: RecipeOptions,
},
/// Request failed to build
HttpBuildError {
Expand Down
5 changes: 4 additions & 1 deletion src/tui/view/common/text_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,10 @@ struct TextWindowActionsModal {

impl Default for TextWindowActionsModal {
fn default() -> Self {
fn on_submit(context: &mut UpdateContext, action: &TextWindowAction) {
fn on_submit(
context: &mut UpdateContext,
action: &mut TextWindowAction,
) {
// Close the modal *first*, so the action event gets handled by our
// parent rather than the modal. Jank but it works
context.queue_event(Event::CloseModal);
Expand Down
1 change: 1 addition & 0 deletions src/tui/view/component/primary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ impl EventHandler for PrimaryView {
profile_id: self
.selected_profile()
.map(|profile| profile.id.clone()),
options: self.request_pane.recipe_options(),
});
}
Update::Consumed
Expand Down
2 changes: 1 addition & 1 deletion src/tui/view/component/profile_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub struct ProfileListPaneProps {
impl ProfileListPane {
pub fn new(profiles: Vec<Profile>) -> Self {
// Loaded request depends on the profile, so refresh on change
fn on_select(context: &mut UpdateContext, _: &Profile) {
fn on_select(context: &mut UpdateContext, _: &mut Profile) {
context.queue_event(Event::HttpLoadRequest);
}

Expand Down
Loading

0 comments on commit 9486819

Please sign in to comment.