use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BulkAction {
Index,
Create,
Update,
Delete,
}
impl BulkAction {
pub fn as_str(&self) -> &'static str {
match self {
BulkAction::Index => "index",
BulkAction::Create => "create",
BulkAction::Update => "update",
BulkAction::Delete => "delete",
}
}
fn has_doc_line(&self) -> bool {
!matches!(self, BulkAction::Delete)
}
fn parse(name: &str) -> Option<Self> {
match name {
"index" => Some(BulkAction::Index),
"create" => Some(BulkAction::Create),
"update" => Some(BulkAction::Update),
"delete" => Some(BulkAction::Delete),
_ => None,
}
}
}
#[derive(Debug)]
pub struct BulkItem {
pub action: BulkAction,
pub stream: String,
pub doc_id: String,
pub explicit_id: bool,
pub doc: Value,
pub raw: std::sync::Arc<str>,
}
#[derive(Debug, Default)]
pub struct BulkParseOutcome {
pub items: Vec<(usize, BulkItem)>,
pub rejections: Vec<(usize, String, String, String)>,
pub total: usize,
}
pub fn parse_bulk_body(
body: &str,
default_index: Option<&str>,
) -> Result<BulkParseOutcome, String> {
let mut outcome = BulkParseOutcome::default();
let mut lines = body
.split('\n')
.map(str::trim_end)
.filter(|l| !l.is_empty());
let mut position = 0usize;
while let Some(action_line) = lines.next() {
let action_json: Value = serde_json::from_str(action_line)
.map_err(|e| format!("action line {} is not valid JSON: {e}", position + 1))?;
let obj = action_json
.as_object()
.ok_or_else(|| format!("action line {} must be an object", position + 1))?;
let (action_name, meta) = obj
.iter()
.next()
.map(|(k, v)| (k.as_str(), v))
.ok_or_else(|| format!("action line {} is empty", position + 1))?;
let index = meta
.get("_index")
.and_then(|v| v.as_str())
.or(default_index)
.unwrap_or("")
.to_string();
let explicit = match meta.get("_id") {
Some(Value::String(s)) => Some(s.clone()),
Some(Value::Number(n)) => Some(n.to_string()),
_ => None,
};
let explicit_id = explicit.is_some();
let doc_id = explicit.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
let Some(action) = BulkAction::parse(action_name) else {
return Err(format!(
"unknown bulk action '{action_name}' at action {}",
position + 1
));
};
let reject = |outcome: &mut BulkParseOutcome, reason: String| {
outcome
.rejections
.push((position, action_name.to_string(), index.clone(), reason));
};
if !action.has_doc_line() {
if index.is_empty() {
reject(&mut outcome, "no index specified in action or URL".to_string());
} else if !explicit_id {
reject(&mut outcome, format!("{action_name} requires an _id"));
} else {
outcome.items.push((
position,
BulkItem {
action,
stream: index,
doc_id,
explicit_id,
doc: Value::Null,
raw: std::sync::Arc::from(""),
},
));
}
position += 1;
continue;
}
let Some(doc_line) = lines.next() else {
reject(&mut outcome, "missing document line".to_string());
position += 1; break; };
match serde_json::from_str::<Value>(doc_line) {
Ok(doc) if doc.is_object() => {
if index.is_empty() {
reject(&mut outcome, "no index specified in action or URL".to_string());
} else if action == BulkAction::Update && !explicit_id {
reject(&mut outcome, "update requires an _id".to_string());
} else {
outcome.items.push((
position,
BulkItem {
action,
stream: index,
doc_id,
explicit_id,
doc,
raw: std::sync::Arc::from(doc_line),
},
));
}
}
Ok(_) => reject(&mut outcome, "document must be a JSON object".to_string()),
Err(e) => reject(&mut outcome, format!("document is not valid JSON: {e}")),
}
position += 1;
}
outcome.total = position;
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_index_and_create_actions() {
let body = concat!(
"{\"index\":{\"_index\":\"logs\"}}\n",
"{\"message\":\"one\"}\n",
"{\"create\":{\"_index\":\"other\",\"_id\":\"abc\"}}\n",
"{\"message\":\"two\"}\n",
);
let out = parse_bulk_body(body, None).unwrap();
assert_eq!(out.total, 2);
assert_eq!(out.items.len(), 2);
assert!(out.rejections.is_empty());
assert_eq!(out.items[0].1.stream, "logs");
assert_eq!(out.items[1].1.doc_id, "abc");
assert_eq!(out.items[1].1.action, BulkAction::Create);
}
#[test]
fn url_index_is_default_but_action_wins() {
let body = concat!(
"{\"index\":{}}\n",
"{\"m\":1}\n",
"{\"index\":{\"_index\":\"explicit\"}}\n",
"{\"m\":2}\n",
);
let out = parse_bulk_body(body, Some("from-url")).unwrap();
assert_eq!(out.items[0].1.stream, "from-url");
assert_eq!(out.items[1].1.stream, "explicit");
}
#[test]
fn parses_delete_and_update_and_rejects_bad_docs_per_item() {
let body = concat!(
"{\"delete\":{\"_index\":\"logs\",\"_id\":\"1\"}}\n",
"{\"index\":{\"_index\":\"logs\"}}\n",
"not-json\n",
"{\"update\":{\"_index\":\"logs\",\"_id\":\"1\"}}\n",
"{\"doc\":{\"x\":1}}\n",
"{\"index\":{\"_index\":\"logs\"}}\n",
"{\"ok\":true}\n",
);
let out = parse_bulk_body(body, None).unwrap();
assert_eq!(out.total, 4);
assert_eq!(out.items.len(), 3);
assert_eq!(out.rejections.len(), 1);
assert_eq!(out.rejections[0].0, 1);
let (pos, delete) = &out.items[0];
assert_eq!((*pos, delete.action), (0, BulkAction::Delete));
assert!(delete.explicit_id && delete.doc.is_null() && delete.raw.is_empty());
let (pos, update) = &out.items[1];
assert_eq!((*pos, update.action), (2, BulkAction::Update));
assert_eq!(update.doc["doc"]["x"], 1);
assert_eq!(out.items[2].1.action, BulkAction::Index);
assert!(!out.items[2].1.explicit_id);
}
#[test]
fn numeric_ids_are_coerced_to_strings() {
let body = concat!(
"{\"index\":{\"_index\":\"recs\",\"_id\":42}}\n",
"{\"v\":1}\n",
"{\"delete\":{\"_index\":\"recs\",\"_id\":7}}\n",
);
let out = parse_bulk_body(body, None).unwrap();
assert!(out.rejections.is_empty());
assert_eq!(out.items[0].1.doc_id, "42");
assert!(out.items[0].1.explicit_id);
assert_eq!(out.items[1].1.doc_id, "7");
}
#[test]
fn delete_and_update_require_an_id() {
let body = concat!(
"{\"delete\":{\"_index\":\"logs\"}}\n",
"{\"update\":{\"_index\":\"logs\"}}\n",
"{\"doc\":{}}\n",
"{\"index\":{\"_index\":\"logs\"}}\n",
"{\"ok\":true}\n",
);
let out = parse_bulk_body(body, None).unwrap();
assert_eq!(out.total, 3);
assert_eq!(out.items.len(), 1);
assert_eq!(out.rejections.len(), 2);
assert!(out.rejections[0].3.contains("requires an _id"));
assert!(out.rejections[1].3.contains("requires an _id"));
}
#[test]
fn missing_index_is_rejected() {
let body = "{\"index\":{}}\n{\"m\":1}\n";
let out = parse_bulk_body(body, None).unwrap();
assert!(out.items.is_empty());
assert_eq!(out.rejections.len(), 1);
}
#[test]
fn garbage_action_line_is_a_request_error() {
assert!(parse_bulk_body("garbage\n{\"m\":1}\n", None).is_err());
}
}