use super::paths::{DataPath, PublishSource};
use super::xml::{validation_error, value_to_xml};
use crate::base::{IssueCode, Result, Severity};
use crate::expr::{CompileCx, Expr, Path};
use crate::function::{FunctionConfig, Outcome, SyncCtx};
use ::log::debug;
use datavalue::OwnedDataValue;
use serde_json::Value;
use std::borrow::Cow;
#[derive(Debug, FunctionConfig)]
pub(crate) struct PublishConfig {
#[config(records)]
pub source: PublishSource,
#[config(records)]
pub target: DataPath,
#[config(default = false)]
pub pretty: bool,
#[config(records, default = "root")]
pub root_element: Expr<String>,
}
impl PublishConfig {
pub(crate) fn compile_for(input: &Value, xml: bool, cx: &mut CompileCx<'_>) -> Option<Self> {
let (other, owner) = if xml {
("pretty", "publish_json")
} else {
("root_element", "publish_xml")
};
let input = if input.get(other).is_some() {
let mut owned = input.clone();
if let Value::Object(object) = &mut owned {
object.remove(other);
}
cx.at(other, |cx| {
cx.issue_as(
IssueCode::UnknownKey,
Severity::Advisory,
format!("`{other}` is `{owner}`'s field; this call ignores it"),
);
});
Cow::Owned(owned)
} else {
Cow::Borrowed(input)
};
<Self as FunctionConfig>::compile(&input, cx)
}
#[allow(clippy::type_complexity, reason = "one call site")]
fn paths<'p>(
&'p self,
cx: &mut SyncCtx<'_, '_, 'p>,
) -> Result<(Cow<'p, Path>, Option<Cow<'p, Path>>, Cow<'p, Path>)> {
let (source, fallback) = self.source.paths(cx)?;
let target = self.target.path(cx)?;
Ok((source, fallback, target))
}
pub(crate) fn json<'p>(&'p self, cx: &mut SyncCtx<'_, '_, 'p>) -> Result<Outcome> {
let (source, fallback, target) = self.paths(cx)?;
debug!("PublishJson: Serializing '{source}' to '{target}'");
let text = {
let found = find(cx, &source, fallback.as_deref(), "PublishJson")?;
if self.pretty {
serde_json::to_string_pretty(&*found).map_err(|e| {
validation_error(format_args!("Failed to serialize to JSON: {e}"))
})?
} else {
found.to_json_string()
}
};
cx.write(&target, OwnedDataValue::String(text), None);
Ok(Outcome::Ok)
}
pub(crate) fn xml<'p>(&'p self, cx: &mut SyncCtx<'_, '_, 'p>) -> Result<Outcome> {
let (source, fallback, target) = self.paths(cx)?;
debug!("PublishXml: Serializing '{source}' to '{target}'");
let xml = match self.root_element.constant() {
Some(root) => value_to_xml(
&*find(cx, &source, fallback.as_deref(), "PublishXml")?,
root,
),
None => {
let found = find(cx, &source, fallback.as_deref(), "PublishXml")?.into_owned();
let root = cx.eval(&self.root_element)?;
value_to_xml(&found, &root)
}
};
cx.write(&target, OwnedDataValue::String(xml), None);
Ok(Outcome::Ok)
}
}
fn find<'a>(
cx: &'a SyncCtx<'_, '_, '_>,
source: &Path,
fallback: Option<&Path>,
who: &str,
) -> Result<Cow<'a, OwnedDataValue>> {
match cx
.rc
.get(source)
.or_else(|| fallback.and_then(|f| cx.rc.get(f)))
{
Some(found) if !matches!(*found, OwnedDataValue::Null) => Ok(found),
_ => Err(validation_error(format_args!(
"{who}: Source '{source}' not found or is null"
))),
}
}
#[cfg(test)]
mod tests {
use crate::function::builtins::one_task::{Golden, Observed, context_text_after};
use datavalue::OwnedDataValue;
use serde_json::{Value, json};
fn context() -> Value {
json!({
"data": {
"obj": {"a": [1, {"k": "v & <w>"}], "m": null, "t": true, "z": 1},
"data": {"inner": 1},
"num": 7, "text": "hi", "nothing": null,
"kind": "obj", "slot": "out_slot", "tag": "doc"
},
"metadata": {"m": 1}
})
}
fn publish(golden: &mut Golden, function: &str, extra: Value) -> Observed {
let mut input = json!({"source": "obj", "target": "out"});
for (k, v) in extra.as_object().expect("an object") {
input[k] = v.clone();
}
golden.observe(function, &input, &Value::Null, &context())
}
#[test]
fn publish_json_writes_compact_and_pretty_text() {
let mut golden = Golden::new("publish", "publish_json_writes_compact_and_pretty_text");
let compact = publish(&mut golden, "publish_json", json!({}));
assert_eq!(compact.status, Some(200));
let pretty = publish(&mut golden, "publish_json", json!({"pretty": true}));
assert!(
pretty.context.contains(r#""out":"{\n \"a\": ["#),
"{}",
pretty.context
);
}
#[test]
fn compact_pretty_and_xml_keep_the_sources_key_order() {
let text = r#"{"data":{"obj":{"z":1,"a":{"y":2,"b":[3,{"q":1,"c":2.0}]}}},"metadata":{},"temp_data":{}}"#;
let context = || OwnedDataValue::from_json(text).expect("JSON");
let compact = json!({"source": "obj", "target": "out"});
assert_eq!(
context_text_after("publish_json", &compact, context()),
concat!(
r#"{"data":{"obj":{"z":1,"a":{"y":2,"b":[3,{"q":1,"c":2.0}]}},"#,
r#""out":"{\"z\":1,\"a\":{\"y\":2,\"b\":[3,{\"q\":1,\"c\":2.0}]}}"},"#,
r#""metadata":{},"temp_data":{}}"#
)
);
let out = |function: &str, pretty: bool| {
let input = json!({"source": "obj", "target": "out", "pretty": pretty});
let written: Value =
serde_json::from_str(&context_text_after(function, &input, context())).unwrap();
written["data"]["out"]
.as_str()
.expect("a string")
.to_owned()
};
assert_eq!(
out("publish_json", false),
r#"{"z":1,"a":{"y":2,"b":[3,{"q":1,"c":2.0}]}}"#
);
assert_eq!(
out("publish_json", true),
concat!(
"{\n \"z\": 1,\n \"a\": {\n \"y\": 2,\n \"b\": [\n 3,\n",
" {\n \"q\": 1,\n \"c\": 2.0\n }\n ]\n }\n}"
)
);
assert_eq!(
out("publish_xml", false),
"<root><z>1</z><a><y>2</y><b><item>3</item><item><q>1</q><c>2.0</c></item></b></a></root>"
);
}
#[test]
fn publish_json_reads_through_the_data_fallback() {
let mut golden = Golden::new("publish", "publish_json_reads_through_the_data_fallback");
let observed = publish(&mut golden, "publish_json", json!({"source": "data.obj"}));
assert_eq!(observed.status, Some(200));
publish(&mut golden, "publish_json", json!({"source": "data.inner"}));
publish(&mut golden, "publish_json", json!({"source": "data"}));
publish(&mut golden, "publish_json", json!({"source": "obj.a.1"}));
publish(&mut golden, "publish_json", json!({"source": "num"}));
publish(&mut golden, "publish_json", json!({"source": "text"}));
}
#[test]
fn a_missing_or_null_source_fails_with_the_validation_error() {
let mut golden = Golden::new(
"publish",
"a_missing_or_null_source_fails_with_the_validation_error",
);
for (function, source, message) in [
(
"publish_json",
"absent",
"Validation error: PublishJson: Source 'data.absent' not found or is null",
),
(
"publish_json",
"nothing",
"Validation error: PublishJson: Source 'data.nothing' not found or is null",
),
(
"publish_json",
"data.absent",
"Validation error: PublishJson: Source 'data.data.absent' not found or is null",
),
(
"publish_xml",
"absent",
"Validation error: PublishXml: Source 'data.absent' not found or is null",
),
(
"publish_xml",
"nothing",
"Validation error: PublishXml: Source 'data.nothing' not found or is null",
),
] {
let observed = publish(&mut golden, function, json!({"source": source}));
assert_eq!(observed.status, Some(500), "{function} {source}");
assert_eq!(observed.error_code.as_deref(), Some("VALIDATION_ERROR"));
assert!(
observed
.errors
.contains(&format!("Task t error: {message}")),
"{}",
observed.errors
);
}
}
#[test]
fn a_computed_source_and_target_resolve_per_call() {
let mut golden = Golden::new("publish", "a_computed_source_and_target_resolve_per_call");
for function in ["publish_json", "publish_xml"] {
publish(
&mut golden,
function,
json!({"source": {"var": "data.kind"}}),
);
publish(
&mut golden,
function,
json!({"source": {"cat": ["data.", {"var": "data.kind"}]}}),
);
publish(
&mut golden,
function,
json!({"target": {"var": "data.slot"}}),
);
publish(
&mut golden,
function,
json!({"source": {"cat": ["o", "bj"]}}),
);
}
}
#[test]
fn publish_xml_writes_the_document_under_the_root_element() {
let mut golden = Golden::new(
"publish",
"publish_xml_writes_the_document_under_the_root_element",
);
let observed = publish(&mut golden, "publish_xml", json!({}));
assert!(
observed.context.contains("<root><a><item>1</item>"),
"{}",
observed.context
);
assert!(observed.context.contains("v & <w>"));
publish(
&mut golden,
"publish_xml",
json!({"root_element": "envelope"}),
);
publish(
&mut golden,
"publish_xml",
json!({"root_element": {"var": "data.tag"}}),
);
publish(&mut golden, "publish_xml", json!({"source": "num"}));
publish(&mut golden, "publish_xml", json!({"source": "obj.a"}));
publish(&mut golden, "publish_xml", json!({"source": "text"}));
}
#[test]
fn a_source_that_exists_under_data_wins_over_the_fallback() {
let mut golden = Golden::new(
"publish",
"a_source_that_exists_under_data_wins_over_the_fallback",
);
let context = json!({"data": {"data": {"x": 1}, "x": 2}});
for function in ["publish_json", "publish_xml"] {
let observed = golden.observe(
function,
&json!({"source": "data.x", "target": "out"}),
&Value::Null,
&context,
);
assert!(observed.context.contains('1'), "{}", observed.context);
}
}
#[test]
fn an_empty_remainder_publishes_the_whole_of_data() {
let mut golden = Golden::new("publish", "an_empty_remainder_publishes_the_whole_of_data");
let context = json!({"data": {"e": "", "d": "data.", "k": 1, "o": {"x": 1}}});
for function in ["publish_json", "publish_xml"] {
for source in [
json!(""),
json!("data."),
json!({"cat": ["data.", ""]}),
json!({"var": "data.e"}),
json!({"cat": ["data.", {"var": "data.e"}]}),
json!({"var": "data.d"}),
] {
let input = json!({"source": source, "target": "out"});
let observed = golden.observe(function, &input, &Value::Null, &context);
assert_eq!(observed.status, Some(200), "{function} {source}");
}
}
let context = json!({"data": {"": {"only": 1}, "k": 2}});
let input = json!({"source": "", "target": "out"});
golden.observe("publish_json", &input, &Value::Null, &context);
}
#[test]
fn a_computed_parameter_that_fails_to_evaluate_records_a_logic_error() {
let mut golden = Golden::new(
"publish",
"a_computed_parameter_that_fails_to_evaluate_records_a_logic_error",
);
let context = json!({"data": {"word": "abc", "obj": {"a": 1}}});
let broken = json!({"+": [{"var": "data.word"}, "x"]});
for (function, input) in [
("publish_json", json!({"source": broken, "target": "out"})),
("publish_json", json!({"source": "obj", "target": broken})),
("publish_xml", json!({"source": broken, "target": "out"})),
("publish_xml", json!({"source": "obj", "target": broken})),
(
"publish_xml",
json!({"source": "obj", "target": "out", "root_element": broken}),
),
] {
let observed = golden.observe(function, &input, &Value::Null, &context);
assert_eq!(observed.status, Some(500), "{function} {input}");
assert_eq!(observed.error_code.as_deref(), Some("LOGIC_ERROR"));
assert!(
observed
.errors
.contains("Task t error: Logic evaluation error: "),
"{}",
observed.errors
);
}
}
}