diff --git a/Cargo.toml b/Cargo.toml index aa09d4a7..008206c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ chrono = {version = "^0.4.31", default-features = false, features = ["clock", "s clap = {version = "^4.4.2", features = ["derive"]} cli-clipboard = "0.4.0" crossterm = "^0.27.0" -derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "deref_mut", "display", "from"]} +derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "deref_mut", "display", "from", "from_str"]} dialoguer = {version = "^0.11.0", default-features = false, features = ["password"]} dirs = "^5.0.1" equivalent = "^1" diff --git a/src/collection/models.rs b/src/collection/models.rs index d3e9af13..e69199e4 100644 --- a/src/collection/models.rs +++ b/src/collection/models.rs @@ -1,11 +1,14 @@ //! The plain data types that make up a request collection -use crate::{collection::cereal, http::ContentType, template::Template}; +use crate::{ + collection::cereal, + http::{ContentType, Query}, + template::Template, +}; use derive_more::{Deref, Display, From}; use equivalent::Equivalent; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use serde_json_path::JsonPath; use std::path::PathBuf; /// A collection of profiles, requests, etc. This is the primary Slumber unit @@ -128,7 +131,7 @@ pub struct Chain { /// Selector to extract a value from the response. This uses JSONPath /// regardless of the content type. Non-JSON values will be converted to /// JSON, then converted back. See [ResponseContent::select]. - pub selector: Option, + pub selector: Option, /// Hard-code the content type of the response. Only needed if a selector /// is given and the content type can't be dynamically determined /// correctly. This is needed if the chain source is not an HTTP diff --git a/src/http.rs b/src/http.rs index 884ef84c..fe83d6fa 100644 --- a/src/http.rs +++ b/src/http.rs @@ -34,9 +34,11 @@ //! +---------------+ mod parse; +mod query; mod record; pub use parse::*; +pub use query::*; pub use record::*; use crate::{ diff --git a/src/http/parse.rs b/src/http/parse.rs index ffc8a2fc..6c26f96c 100644 --- a/src/http/parse.rs +++ b/src/http/parse.rs @@ -7,7 +7,7 @@ use crate::http::Response; use anyhow::{anyhow, Context}; -use derive_more::{Deref, Display}; +use derive_more::{Deref, Display, From}; use serde::{de::IntoDeserializer, Deserialize, Serialize}; use std::{borrow::Cow, ffi::OsStr, fmt::Debug, path::Path, str::FromStr}; @@ -25,6 +25,9 @@ pub enum ContentType { /// A response content type that we know how to parse. This is defined as a /// trait rather than an enum because it breaks apart the logic more clearly. pub trait ResponseContent: Debug + Display { + /// Get the type of this content + fn content_type(&self) -> ContentType; + /// Parse the response body as this type fn parse(body: &str) -> anyhow::Result where @@ -44,10 +47,14 @@ pub trait ResponseContent: Debug + Display { fn as_any(&self) -> &dyn std::any::Any; } -#[derive(Debug, Display, Deref, PartialEq)] +#[derive(Debug, Display, Deref, From, PartialEq)] pub struct Json(serde_json::Value); impl ResponseContent for Json { + fn content_type(&self) -> ContentType { + ContentType::Json + } + fn parse(body: &str) -> anyhow::Result { Ok(Self(serde_json::from_str(body)?)) } diff --git a/src/http/query.rs b/src/http/query.rs new file mode 100644 index 00000000..bd475d01 --- /dev/null +++ b/src/http/query.rs @@ -0,0 +1,96 @@ +//! Utilities for querying HTTP response data + +use crate::http::ResponseContent; +use derive_more::{Display, FromStr}; +use serde::{Deserialize, Serialize}; +use serde_json_path::{ExactlyOneError, JsonPath}; +use thiserror::Error; + +/// A wrapper around a JSONPath. This combines some common behavior, and will +/// make it easy to swap out the query language in the future if necessary. +#[derive(Clone, Debug, Display, FromStr, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Query(JsonPath); + +#[derive(Debug, Error)] +pub enum QueryError { + /// Got either 0 or 2+ results for JSON path query + #[error("Expected exactly one result from query")] + InvalidResult { + #[from] + #[source] + error: ExactlyOneError, + }, +} + +impl Query { + /// Apply a query to some content, returning a string. The query should + /// return a single result. If it's a scalar, that will be stringified. If + /// it's an array/object, it'll be converted back into its input format, + /// then stringified. + pub fn query_to_string( + &self, + value: &dyn ResponseContent, + ) -> Result { + let content_type = value.content_type(); + + // All content types get converted to JSON for querying, then converted + // back. This is fucky but we need *some* common format + let json_value = value.to_json(); + let filtered = self.0.query(&json_value).exactly_one()?; + + // If we got a scalar value, use that. Otherwise convert back to the + // input content type to re-stringify + let stringified = match filtered { + serde_json::Value::Null => "".into(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + content_type.parse_json(filtered).to_string() + } + }; + + Ok(stringified) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{http::Json, util::assert_err}; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[case("$", json(json!({"test": "hi!"})), r#"{"test":"hi!"}"#)] + #[case("$.test", json(json!({"test": "hi!"})), "hi!")] + #[case("$.test", json(json!({"test": 3})), "3")] + #[case("$.test", json(json!({"test": true})), "true")] + fn test_query_to_string( + #[case] query: &str, + #[case] content: Box, + #[case] expected: &str, + ) { + let query = Query::from_str(query).unwrap(); + let out = query.query_to_string(&*content).unwrap(); + assert_eq!(out, expected); + } + + #[rstest] + #[case("$[*]", json(json!([1, 2])), "Expected exactly one result")] + #[case("$[*]", json(json!([])), "Expected exactly one result")] + fn test_query_to_string_error( + #[case] query: &str, + #[case] content: Box, + #[case] expected_err: &str, + ) { + let query = Query::from_str(query).unwrap(); + assert_err!(query.query_to_string(&*content), expected_err); + } + + /// Helper to create JSON content + fn json(value: serde_json::Value) -> Box { + Box::new(Json::from(value)) + } +} diff --git a/src/template/error.rs b/src/template/error.rs index 01fcdc37..e44277e9 100644 --- a/src/template/error.rs +++ b/src/template/error.rs @@ -1,10 +1,10 @@ use crate::{ collection::{ChainId, ProfileId}, + http::QueryError, template::Template, util::doc_link, }; use nom::error::VerboseError; -use serde_json_path::ExactlyOneError; use std::{env::VarError, io, path::PathBuf, string::FromUtf8Error}; use thiserror::Error; @@ -91,12 +91,10 @@ pub enum ChainError { #[source] error: anyhow::Error, }, - /// Got either 0 or 2+ results for JSON path query - #[error("Expected exactly one result from selector")] - InvalidResult { - #[source] - error: ExactlyOneError, - }, + /// Got either 0 or 2+ results for JSON path query. This is generated by + /// internal code so we don't need extra context + #[error(transparent)] + Query(#[from] QueryError), /// User gave an empty list for the command #[error("No command given")] CommandMissing, diff --git a/src/template/render.rs b/src/template/render.rs index e31d0869..478ebceb 100644 --- a/src/template/render.rs +++ b/src/template/render.rs @@ -11,7 +11,6 @@ use crate::{ }; use async_trait::async_trait; use futures::future::join_all; -use serde_json_path::JsonPath; use std::{ env::{self}, path::Path, @@ -269,11 +268,13 @@ impl<'a> TemplateSource<'a> for ChainTemplateSource<'a> { // If a selector path is present, filter down the value let value = if let Some(selector) = &chain.selector { - self.apply_selector( - content_type.ok_or(ChainError::UnknownContentType)?, - &value, - selector, - )? + let content_type = + content_type.ok_or(ChainError::UnknownContentType)?; + // Parse according to detected content type + let value = content_type + .parse(&value) + .map_err(|err| ChainError::ParseResponse { error: err })?; + selector.query_to_string(&*value)? } else { value }; @@ -370,43 +371,6 @@ impl<'a> ChainTemplateSource<'a> { }); rx.await.map_err(|_| ChainError::PromptNoResponse) } - - /// Apply a selector path to a string value to filter it down. The filtering - /// method will be determined based on the content type of the response. - /// See [ResponseContent]. - fn apply_selector( - &self, - content_type: ContentType, - value: &str, - selector: &JsonPath, - ) -> Result { - // Parse according to detected content type - let value = content_type - .parse(value) - .map_err(|err| ChainError::ParseResponse { error: err })?; - - // All content types get converted to JSON for formatting, then - // converted back. This is fucky but we need *some* common format - let json_value = value.to_json(); - let filtered = selector - .query(&json_value) - .exactly_one() - .map_err(|error| ChainError::InvalidResult { error })?; - - // If we got a scalar value, use that. Otherwise convert back to the - // input content type to re-stringify - let stringified = match filtered { - serde_json::Value::Null => "".into(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Array(_) | serde_json::Value::Object(_) => { - content_type.parse_json(filtered).to_string() - } - }; - - Ok(stringified) - } } /// A value sourced from the process's environment