Skip to content

Commit

Permalink
Add wrapper type for JSONPath querying
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Feb 17, 2024
1 parent 4bc61bf commit 48bdd73
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 56 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 6 additions & 3 deletions src/collection/models.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<JsonPath>,
pub selector: Option<Query>,
/// 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
Expand Down
2 changes: 2 additions & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
//! +---------------+
mod parse;
mod query;
mod record;

pub use parse::*;
pub use query::*;
pub use record::*;

use crate::{
Expand Down
11 changes: 9 additions & 2 deletions src/http/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<Self>
where
Expand All @@ -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<Self> {
Ok(Self(serde_json::from_str(body)?))
}
Expand Down
96 changes: 96 additions & 0 deletions src/http/query.rs
Original file line number Diff line number Diff line change
@@ -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<String, QueryError> {
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<dyn ResponseContent>,
#[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<dyn ResponseContent>,
#[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<dyn ResponseContent> {
Box::new(Json::from(value))
}
}
12 changes: 5 additions & 7 deletions src/template/error.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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,
Expand Down
50 changes: 7 additions & 43 deletions src/template/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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<String, ChainError> {
// 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
Expand Down

0 comments on commit 48bdd73

Please sign in to comment.