From 34c60e39878f7ee0ab971b7b68141a28f879a4d1 Mon Sep 17 00:00:00 2001 From: Michael Alan Dorman Date: Tue, 28 Jul 2026 13:53:40 -0400 Subject: [PATCH 1/2] Add support for reading and writing standalone Entry values. When implementing AtomPub support, one needs the ability to read and write `Entry` values outside of a `Feed` context. The API intentionally apes the API for `Feed` values. Because `Entry` values must now be emitted with (standalone) and without (feed) namespace declarations, the actual writing of the values is moved from the `ToXml` implementation into a private `to_xml_inner()` function with an additional parameter to govern the namespace decl. Tests: - Read standalone entries from a file and a string - Make sure non- documents are rejected - Make sure EOF is appropriate handled - Write a standalone entry - Make sure `xmlns` isn't emitted on an entry in a feed --- CHANGELOG.md | 2 + src/entry.rs | 230 ++++++++++++++++++++++++++------ tests/data/standalone_entry.xml | 12 ++ tests/read.rs | 46 ++++++- tests/write.rs | 20 +++ 5 files changed, 268 insertions(+), 42 deletions(-) create mode 100644 tests/data/standalone_entry.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index f6aece6..b23a0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add standalone entry I/O: `Entry::read_from`, `Entry::write_to`, `Entry::write_with_config`, and `FromStr`/`ToString` impls, mirroring the existing `Feed` API. Standalone serialization declares the Atom namespace on the `` root; entries embedded in a `` do not. + ## 0.12.9 - 2026-07-03 - Update `quick-xml` to `0.41` and migrate to the normalized quick-xml attribute API. [`#95`](https://github.com/rust-syndication/atom/pull/95) diff --git a/src/entry.rs b/src/entry.rs index ada5562..80835a8 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -1,8 +1,9 @@ use std::borrow::Cow; use std::io::{BufRead, Write}; +use std::str::FromStr; use quick_xml::events::attributes::Attributes; -use quick_xml::events::{BytesEnd, BytesStart, Event}; +use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; use quick_xml::Reader; use quick_xml::Writer; @@ -11,6 +12,7 @@ use crate::content::Content; use crate::error::{Error, XmlError}; use crate::extension::util::{extension_name, parse_extension}; use crate::extension::ExtensionMap; +use crate::feed::WriteConfig; use crate::fromxml::FromXml; use crate::link::Link; use crate::person::Person; @@ -505,6 +507,178 @@ impl Entry { { self.extensions = extensions.into() } + + /// Attempt to read a standalone Atom entry from the reader. + /// + /// The prolog (XML declaration, processing instructions, comments) is + /// skipped; the first start tag must be ``. + /// + /// # Examples + /// + /// ``` + /// use atom_syndication::Entry; + /// + /// let xml = r#" + /// Entry Title"#; + /// let entry = Entry::read_from(xml.as_bytes()).unwrap(); + /// assert_eq!(entry.title(), "Entry Title"); + /// ``` + pub fn read_from(reader: B) -> Result { + let mut reader = Reader::from_reader(reader); + reader.config_mut().expand_empty_elements = true; + + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf).map_err(XmlError::new)? { + Event::Start(element) => { + if decode(element.name().as_ref(), &reader)? == "entry" { + return Entry::from_xml(&mut reader, element.attributes()); + } else { + return Err(Error::InvalidStartTag); + } + } + Event::Eof => break, + _ => {} + } + + buf.clear(); + } + + Err(Error::Eof) + } + + /// Attempt to write this Atom entry as a standalone document to a writer + /// using default `WriteConfig`. + /// + /// Unlike an entry embedded in a ``, a standalone `` + /// document declares the Atom namespace on its root element. + /// + /// # Examples + /// + /// ``` + /// use atom_syndication::Entry; + /// + /// # fn main() -> Result<(), Box> { + /// let entry = Entry { + /// title: "Entry Title".into(), + /// id: "Entry ID".into(), + /// ..Default::default() + /// }; + /// + /// let out = entry.write_to(Vec::new())?; + /// assert_eq!(&out, br#" + /// Entry TitleEntry ID1970-01-01T00:00:00+00:00"#); + /// # Ok(()) } + /// ``` + pub fn write_to(&self, writer: W) -> Result { + self.write_with_config(writer, WriteConfig::default()) + } + + /// Attempt to write this Atom entry as a standalone document to a writer. + /// + /// # Examples + /// + /// ``` + /// use atom_syndication::{Entry, WriteConfig}; + /// + /// # fn main() -> Result<(), Box> { + /// let entry = Entry { + /// title: "Entry Title".into(), + /// id: "Entry ID".into(), + /// ..Default::default() + /// }; + /// + /// let mut out = Vec::new(); + /// let config = WriteConfig { + /// write_document_declaration: false, + /// indent_size: Some(2), + /// }; + /// entry.write_with_config(&mut out, config)?; + /// assert_eq!(&out, br#" + /// Entry Title + /// Entry ID + /// 1970-01-01T00:00:00+00:00 + /// "#); + /// # Ok(()) } + /// ``` + pub fn write_with_config( + &self, + writer: W, + write_config: WriteConfig, + ) -> Result { + let mut writer = match write_config.indent_size { + Some(indent_size) => Writer::new_with_indent(writer, b' ', indent_size), + None => Writer::new(writer), + }; + if write_config.write_document_declaration { + writer + .write_event(Event::Decl(BytesDecl::new("1.0", None, None))) + .map_err(XmlError::new)?; + writer + .write_event(Event::Text(BytesText::from_escaped("\n"))) + .map_err(XmlError::new)?; + } + self.to_xml_inner(&mut writer, true)?; + Ok(writer.into_inner()) + } + + /// Serializes the `` element. `declare_xmlns` controls whether the + /// root element declares the Atom namespace: standalone documents need it, + /// entries embedded in a `` inherit it from the feed root. + fn to_xml_inner( + &self, + writer: &mut Writer, + declare_xmlns: bool, + ) -> Result<(), XmlError> { + let name = "entry"; + let mut element = BytesStart::new(name); + if declare_xmlns { + element.push_attribute(("xmlns", "http://www.w3.org/2005/Atom")); + } + writer + .write_event(Event::Start(element)) + .map_err(XmlError::new)?; + writer.write_object_named(&self.title, "title")?; + writer.write_text_element("id", &self.id)?; + writer.write_text_element("updated", &self.updated.to_rfc3339())?; + writer.write_objects_named(&self.authors, "author")?; + writer.write_objects(&self.categories)?; + writer.write_objects_named(&self.contributors, "contributor")?; + writer.write_objects(&self.links)?; + + if let Some(ref published) = self.published { + writer.write_text_element("published", &published.to_rfc3339())?; + } + + if let Some(ref rights) = self.rights { + writer.write_object_named(rights, "rights")?; + } + + if let Some(ref source) = self.source { + writer.write_object(source)?; + } + + if let Some(ref summary) = self.summary { + writer.write_object_named(summary, "summary")?; + } + + if let Some(ref content) = self.content { + writer.write_object(content)?; + } + + for map in self.extensions.values() { + for extensions in map.values() { + writer.write_objects(extensions)?; + } + } + + writer + .write_event(Event::End(BytesEnd::new(name))) + .map_err(XmlError::new)?; + + Ok(()) + } } impl FromXml for Entry { @@ -578,49 +752,23 @@ impl FromXml for Entry { impl ToXml for Entry { fn to_xml(&self, writer: &mut Writer) -> Result<(), XmlError> { - let name = "entry"; - writer - .write_event(Event::Start(BytesStart::new(name))) - .map_err(XmlError::new)?; - writer.write_object_named(&self.title, "title")?; - writer.write_text_element("id", &self.id)?; - writer.write_text_element("updated", &self.updated.to_rfc3339())?; - writer.write_objects_named(&self.authors, "author")?; - writer.write_objects(&self.categories)?; - writer.write_objects_named(&self.contributors, "contributor")?; - writer.write_objects(&self.links)?; - - if let Some(ref published) = self.published { - writer.write_text_element("published", &published.to_rfc3339())?; - } - - if let Some(ref rights) = self.rights { - writer.write_object_named(rights, "rights")?; - } - - if let Some(ref source) = self.source { - writer.write_object(source)?; - } - - if let Some(ref summary) = self.summary { - writer.write_object_named(summary, "summary")?; - } - - if let Some(ref content) = self.content { - writer.write_object(content)?; - } + self.to_xml_inner(writer, false) + } +} - for map in self.extensions.values() { - for extensions in map.values() { - writer.write_objects(extensions)?; - } - } +impl FromStr for Entry { + type Err = Error; - writer - .write_event(Event::End(BytesEnd::new(name))) - .map_err(XmlError::new)?; + fn from_str(s: &str) -> Result { + Entry::read_from(s.as_bytes()) + } +} - Ok(()) +impl ToString for Entry { + fn to_string(&self) -> String { + let buf = self.write_to(Vec::new()).unwrap_or_default(); + // this unwrap should be safe since the bytes written from the Entry are all valid utf8 + String::from_utf8(buf).unwrap() } } diff --git a/tests/data/standalone_entry.xml b/tests/data/standalone_entry.xml new file mode 100644 index 0000000..3798e35 --- /dev/null +++ b/tests/data/standalone_entry.xml @@ -0,0 +1,12 @@ + + + Entry Title + http://example.com/article/1 + 2017-06-03T15:15:44-05:00 + 2017-06-01T08:30:00-05:00 + Entry summary + <p>Entry body</p> + + + yes + diff --git a/tests/read.rs b/tests/read.rs index 1834bcd..29e6371 100644 --- a/tests/read.rs +++ b/tests/read.rs @@ -6,7 +6,7 @@ use std::io::BufReader; use atom::Error; use crate::atom::extension::ExtensionMap; -use crate::atom::{Feed, Text}; +use crate::atom::{Entry, Feed, Text}; macro_rules! feed { ($f:expr) => {{ @@ -327,3 +327,47 @@ fn generator_invalid_version() { let result = Feed::read_from("".as_bytes()); assert!(matches!(result, Err(Error::Xml(_)))); } + +#[test] +fn read_standalone_entry() { + let file = File::open("tests/data/standalone_entry.xml").unwrap(); + let entry = Entry::read_from(BufReader::new(file)).unwrap(); + assert_eq!(entry.title(), "Entry Title"); + assert_eq!(entry.id(), "http://example.com/article/1"); + assert_eq!(entry.updated().to_rfc3339(), "2017-06-03T15:15:44-05:00"); + assert_eq!( + entry.published().map(|d| d.to_rfc3339()).as_deref(), + Some("2017-06-01T08:30:00-05:00") + ); + assert_eq!(entry.summary().map(Text::as_str), Some("Entry summary")); + let content = entry.content().unwrap(); + assert_eq!(content.content_type(), Some("html")); + assert_eq!(content.value(), Some("

Entry body

")); + assert_eq!(entry.categories().len(), 1); + assert_eq!(entry.categories()[0].term(), "technology"); + assert_eq!(entry.links().len(), 1); + assert_eq!(entry.links()[0].rel(), "edit"); + // Foreign-markup extension is preserved in the extension map. + let app = entry.extensions().get("app").unwrap(); + assert!(app.contains_key("control")); +} + +#[test] +fn read_standalone_entry_from_str() { + let entry: Entry = "T" + .parse() + .unwrap(); + assert_eq!(entry.title(), "T"); +} + +#[test] +fn read_standalone_entry_rejects_non_entry_root() { + let result = Entry::read_from("".as_bytes()); + assert!(matches!(result, Err(Error::InvalidStartTag))); +} + +#[test] +fn read_standalone_entry_eof_without_root() { + let result = Entry::read_from("".as_bytes()); + assert!(matches!(result, Err(Error::Eof))); +} diff --git a/tests/write.rs b/tests/write.rs index 1ca6707..ae0f204 100644 --- a/tests/write.rs +++ b/tests/write.rs @@ -61,6 +61,26 @@ fn write_extension() { assert_eq!(feed.to_string().parse::().unwrap(), feed); } +#[test] +fn write_standalone_entry() { + let file = File::open("tests/data/standalone_entry.xml").unwrap(); + let entry = Entry::read_from(BufReader::new(file)).unwrap(); + let out = entry.to_string(); + // A standalone entry document declares the Atom namespace on its root. + assert!( + out.starts_with("\n"), + "out: {out}" + ); + assert_eq!(out.parse::().unwrap(), entry); +} + +#[test] +fn write_embedded_entry_does_not_redeclare_xmlns() { + let feed = feed!("tests/data/entry.xml"); + let out = feed.to_string(); + assert!(!out.contains(" Date: Tue, 28 Jul 2026 15:01:36 -0400 Subject: [PATCH 2/2] Include namespace handling for standalone `Entry` values I missed this detail before: since an `Entry` can now have its own namespace declarations, we have to track them. We use the same approach as for `Feed` values. Tests: - Make sure a namespace is parsed - Make sure a namespace is emitted --- CHANGELOG.md | 1 + src/entry.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++-- tests/read.rs | 7 ++++- tests/write.rs | 8 +++++- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b23a0b7..711cb4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Add standalone entry I/O: `Entry::read_from`, `Entry::write_to`, `Entry::write_with_config`, and `FromStr`/`ToString` impls, mirroring the existing `Feed` API. Standalone serialization declares the Atom namespace on the `` root; entries embedded in a `` do not. +- Add `Entry::namespaces`, mirroring `Feed::namespaces`: `xmlns:*` bindings on the `` element are captured on parse and re-emitted on write, so extension prefixes resolve to namespace URIs and standalone documents with prefixed extension markup stay namespace-valid across a round trip. ## 0.12.9 - 2026-07-03 diff --git a/src/entry.rs b/src/entry.rs index 80835a8..377d360 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::io::{BufRead, Write}; use std::str::FromStr; @@ -19,7 +20,9 @@ use crate::person::Person; use crate::source::Source; use crate::text::Text; use crate::toxml::{ToXml, WriterExt}; -use crate::util::{atom_datetime, atom_text, decode, default_fixed_datetime, skip, FixedDateTime}; +use crate::util::{ + atom_datetime, atom_text, attr_value, decode, default_fixed_datetime, skip, FixedDateTime, +}; /// Represents an entry in an Atom feed #[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] @@ -65,6 +68,9 @@ pub struct Entry { /// The extensions for this entry. #[cfg_attr(feature = "builders", builder(setter(each = "extension")))] pub extensions: ExtensionMap, + /// The namespaces present in the entry tag. + #[cfg_attr(feature = "builders", builder(setter(each = "namespace")))] + pub namespaces: BTreeMap, } impl Entry { @@ -508,6 +514,45 @@ impl Entry { self.extensions = extensions.into() } + /// Return the namespaces for this entry. + /// + /// Combined with the extension map, this allows resolving the namespace + /// URI an extension's prefix was bound to on the `` element. + /// + /// # Examples + /// + /// ``` + /// use std::collections::BTreeMap; + /// use atom_syndication::Entry; + /// + /// let mut entry = Entry::default(); + /// let mut namespaces = BTreeMap::new(); + /// namespaces.insert("ext".to_string(), "http://example.com".to_string()); + /// entry.set_namespaces(namespaces); + /// assert_eq!(entry.namespaces().get("ext").map(|s| s.as_str()), Some("http://example.com")); + /// ``` + pub fn namespaces(&self) -> &BTreeMap { + &self.namespaces + } + + /// Set the namespaces for this entry. + /// + /// # Examples + /// + /// ``` + /// use std::collections::BTreeMap; + /// use atom_syndication::Entry; + /// + /// let mut entry = Entry::default(); + /// entry.set_namespaces(BTreeMap::new()); + /// ``` + pub fn set_namespaces(&mut self, namespaces: V) + where + V: Into>, + { + self.namespaces = namespaces.into() + } + /// Attempt to read a standalone Atom entry from the reader. /// /// The prolog (XML declaration, processing instructions, comments) is @@ -636,6 +681,11 @@ impl Entry { if declare_xmlns { element.push_attribute(("xmlns", "http://www.w3.org/2005/Atom")); } + + for (ns, uri) in &self.namespaces { + element.push_attribute((format!("xmlns:{ns}").as_bytes(), uri.as_bytes())); + } + writer .write_event(Event::Start(element)) .map_err(XmlError::new)?; @@ -682,10 +732,26 @@ impl Entry { } impl FromXml for Entry { - fn from_xml(reader: &mut Reader, _: Attributes<'_>) -> Result { + fn from_xml( + reader: &mut Reader, + mut atts: Attributes<'_>, + ) -> Result { let mut entry = Entry::default(); let mut buf = Vec::new(); + for att in atts.with_checks(false).flatten() { + match decode(att.key.as_ref(), reader)? { + Cow::Borrowed("xmlns:dc") => {} + key => { + if let Some(ns) = key.strip_prefix("xmlns:") { + entry + .namespaces + .insert(ns.to_string(), attr_value(&att, reader)?.to_string()); + } + } + } + } + loop { match reader.read_event_into(&mut buf).map_err(XmlError::new)? { Event::Start(element) => match decode(element.name().as_ref(), reader)? { @@ -788,6 +854,7 @@ impl Default for Entry { summary: None, content: None, extensions: ExtensionMap::default(), + namespaces: BTreeMap::default(), } } } diff --git a/tests/read.rs b/tests/read.rs index 29e6371..97f1e01 100644 --- a/tests/read.rs +++ b/tests/read.rs @@ -347,9 +347,14 @@ fn read_standalone_entry() { assert_eq!(entry.categories()[0].term(), "technology"); assert_eq!(entry.links().len(), 1); assert_eq!(entry.links()[0].rel(), "edit"); - // Foreign-markup extension is preserved in the extension map. + // Foreign-markup extension is preserved in the extension map, and its + // prefix is resolvable to a namespace URI via the namespaces map. let app = entry.extensions().get("app").unwrap(); assert!(app.contains_key("control")); + assert_eq!( + entry.namespaces().get("app").map(String::as_str), + Some("http://www.w3.org/2007/app") + ); } #[test] diff --git a/tests/write.rs b/tests/write.rs index ae0f204..59dea12 100644 --- a/tests/write.rs +++ b/tests/write.rs @@ -68,7 +68,13 @@ fn write_standalone_entry() { let out = entry.to_string(); // A standalone entry document declares the Atom namespace on its root. assert!( - out.starts_with("\n"), + out.starts_with("\n().unwrap(), entry);