huginn door: report every lost key across the batch, accept only objects

The strict decode stopped at the first document with a lost key and let
serde read a struct from a JSON array. It now names every lost path in the
batch and refuses a non-object document or selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 06:04:43 +02:00
commit 055b73e8f8
2 changed files with 26 additions and 9 deletions

View file

@ -48,11 +48,15 @@ pub fn documents(text: &str) -> Result<Vec<PipelineDocument>, Trouble> {
object @ Value::Object(_) => vec![object],
_ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")),
};
items
.into_iter()
.enumerate()
.map(|(index, item)| strict(item, &format!("documents[{index}]")))
.collect()
let mut typed = Vec::with_capacity(items.len());
let mut lost = Vec::new();
for (index, item) in items.into_iter().enumerate() {
let (document, paths) = decode(item, &format!("documents[{index}]"))?;
typed.push(document);
lost.extend(paths);
}
refuse_lost(&lost)?;
Ok(typed)
}
pub fn selection(text: &str) -> Result<Selection, Trouble> {
@ -60,7 +64,9 @@ pub fn selection(text: &str) -> Result<Selection, Trouble> {
}
pub fn selection_value(value: Value) -> Result<Selection, Trouble> {
strict(value, "selection")
let (selection, lost) = decode(value, "selection")?;
refuse_lost(&lost)?;
Ok(selection)
}
/// The kind a pipeline id names in its middle segment (`<root>:<kind>:<local>`).
@ -80,13 +86,23 @@ fn parse(text: &str) -> Result<Value, Trouble> {
serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {error}")))
}
fn strict<T: Serialize + DeserializeOwned>(input: Value, at: &str) -> Result<T, Trouble> {
/// The typed value of one JSON object and the paths its decode lost. Serde
/// would also read a struct from an array, positionally, so only an object
/// is a document or a selection.
fn decode<T: Serialize + DeserializeOwned>(input: Value, at: &str) -> Result<(T, Vec<String>), Trouble> {
if !input.is_object() {
return Err(Trouble::invalid(format!("{at} must be a JSON object")));
}
let typed: T = serde_json::from_value(input.clone()).map_err(|error| Trouble::invalid(format!("{at} does not decode: {error}")))?;
let kept = serde_json::to_value(&typed).map_err(|error| Trouble::Internal(format!("{at} does not re-serialise: {error}")))?;
let mut paths = Vec::new();
dropped(&input, &kept, at, &mut paths);
Ok((typed, paths))
}
fn refuse_lost(paths: &[String]) -> Result<(), Trouble> {
if paths.is_empty() {
Ok(typed)
Ok(())
} else {
Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", "))))
}

View file

@ -394,6 +394,7 @@ fn a_document_that_does_not_decode_is_invalid_input() {
let cases = [
(json!([{ "kind": "nonsense", "value": {} }]), "documents[0] does not decode"),
(json!("text"), "JSON array"),
(json!([[]]), "documents[0] must be a JSON object"),
(json!([]), "1 to 64"),
(json!((0..65).map(|_| campaign_json()).collect::<Vec<_>>()), "1 to 64"),
];
@ -832,7 +833,7 @@ fn a_resolved_document_shows_its_closing_record() {
let resolution = json!({
"kind": "resolution",
"value": {
"subject": { "kind": "question", "id": format!("{CAMPAIGN}:question:Q1") },
"subject": { "kind": "Question", "id": format!("{CAMPAIGN}:question:Q1") },
"sequence": 1,
"outcome": { "Withdrawn": { "reason": "moot" } },
"rationale": "Resolved.",