Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add wrapper type for JSONPath querying #101

Merged
merged 1 commit into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading