diff --git a/CHANGES.md b/CHANGES.md
index 2077b26dc..26460a850 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -13,6 +13,10 @@
categories inline, as before, and also references that document.
- Comment moderation: list, change status, delete.
+- **New AtomPub Guide** (`docs/roller-atompub-guide.adoc`). It documents
+ every AtomPub URI, permission, element and status code, with examples
+ that the test suite checks against the code.
+
### Behaviour changes worth reading before upgrading
- **AtomPub no longer uses ROME Propono.** Roller now has its own AtomPub
diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java
new file mode 100644
index 000000000..796f3a174
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java
@@ -0,0 +1,487 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. The ASF licenses this file to You
+ * under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License. For additional information regarding
+ * copyright in this work, please see the NOTICE file in the top level
+ * directory of this distribution.
+ */
+
+package org.apache.roller.weblogger.webservices.atomprotocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamReader;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Keeps the XML examples in the AtomPub guide ({@code docs/atompub/examples})
+ * in step with the code.
+ *
+ *
+ * Response examples are written by {@link AtomWriter} from the same wire
+ * model the collections build, indented, and compared with the committed
+ * files. Run with {@code -Datompub.docs.update=true} to rewrite them.
+ * Request examples ({@code *-request.xml}) are written by hand; they must
+ * be accepted by {@link AtomReader}.
+ * All examples are checked against the RFC 4287 / RFC 5023 grammars, and
+ * every Roller extension element against {@code docs/atompub/roller-atompub.rnc}.
+ *
+ */
+public class AtomPubDocExamplesTest {
+
+ private static final String ROLLER = "https://example.com/roller";
+ private static final String APP = ROLLER + "/roller-services/app";
+ private static final String BLOG = APP + "/myblog";
+ private static final Date PUBLISHED = Date.from(Instant.parse("2026-10-10T12:00:00Z"));
+ private static final Date UPDATED = Date.from(Instant.parse("2026-10-10T12:30:00Z"));
+
+ private static final String ROLLER_SCHEMA = "roller-atompub.rnc";
+
+ private static final Path DOCS = locateDocs();
+
+ @Test
+ public void generatedExamplesMatchTheDocs() throws Exception {
+ boolean update = Boolean.getBoolean("atompub.docs.update");
+ List stale = new ArrayList<>();
+ for (Map.Entry example : generatedExamples().entrySet()) {
+ String expected = indent(example.getValue());
+ Path file = DOCS.resolve("examples").resolve(example.getKey());
+ if (update) {
+ Files.write(file, expected.getBytes(StandardCharsets.UTF_8));
+ } else if (!Files.exists(file)
+ || !expected.equals(new String(Files.readAllBytes(file), StandardCharsets.UTF_8))) {
+ stale.add(example.getKey());
+ }
+ }
+ assertTrue(stale.isEmpty(), "AtomPub doc examples are out of date: " + stale
+ + ". Regenerate with: mvn -pl app test -Dtest=AtomPubDocExamplesTest"
+ + " -Datompub.docs.update=true");
+ }
+
+ @Test
+ public void requestExamplesAreAcceptedByTheServer() throws Exception {
+ AtomEntry entry = read("entry-request.xml");
+ assertEquals("Hello AtomPub", entry.getTitle());
+ assertEquals(2, entry.getCategories().size());
+
+ AtomEntry category = read("category-request.xml");
+ assertEquals("Travel", category.getTitle());
+ assertEquals("https://example.com/images/travel.png", category.getExtension("image"));
+
+ AtomEntry template = read("template-request.xml");
+ assertEquals("custom", template.getExtension("action"));
+ assertTrue(template.getMobileRendition().contains("sidebar-mobile"));
+
+ AtomEntry status = read("comment-status-request.xml");
+ assertEquals("approved", status.getExtension("status"));
+ }
+
+ @Test
+ public void examplesConformToTheSchemas() throws Exception {
+ try (Stream files = Files.list(DOCS.resolve("examples"))) {
+ for (Path file : (Iterable) files.filter(f -> f.toString().endsWith(".xml"))::iterator) {
+ byte[] xml = Files.readAllBytes(file);
+ String root = parse(xml).name;
+ String schema = root.endsWith("service") ? "/atompub/app-service.rnc"
+ : root.endsWith("categories") ? null : "/atompub/atom.rnc";
+ if (schema != null) {
+ List errors = RelaxNgValidator.validateResource(schema, xml);
+ assertTrue(errors.isEmpty(), file.getFileName() + ": " + errors);
+ }
+ for (byte[] extension : extensionElements(parse(xml))) {
+ List errors;
+ try (InputStream rnc = Files.newInputStream(DOCS.resolve(ROLLER_SCHEMA))) {
+ errors = RelaxNgValidator.validate(rnc, ROLLER_SCHEMA, extension);
+ }
+ assertTrue(errors.isEmpty(), file.getFileName() + ": "
+ + new String(extension, StandardCharsets.UTF_8) + " " + errors);
+ }
+ }
+ }
+ }
+
+ // ------------------------------------------------------------ examples
+
+ private static Map generatedExamples() throws Exception {
+ Map examples = new LinkedHashMap<>();
+ examples.put("service.xml", write(w -> w.writeServiceDoc(w.out, serviceDoc())));
+ examples.put("entry-response.xml", write(w -> w.writeEntry(w.out, weblogEntry())));
+ examples.put("entries-feed.xml", write(w -> w.writeFeed(w.out, entriesFeed())));
+ examples.put("media-link-entry.xml", write(w -> w.writeEntry(w.out, mediaLinkEntry())));
+ examples.put("categories.xml", write(w -> w.writeCategoriesDoc(w.out, categories())));
+ examples.put("category-entry.xml", write(w -> w.writeEntry(w.out, categoryEntry())));
+ examples.put("template-entry.xml", write(w -> w.writeEntry(w.out, templateEntry())));
+ examples.put("comment-entry.xml", write(w -> w.writeEntry(w.out, commentEntry())));
+ examples.put("comments-feed.xml", write(w -> w.writeFeed(w.out, commentsFeed())));
+ return examples;
+ }
+
+ private static AtomServiceDoc serviceDoc() {
+ AtomServiceDoc service = new AtomServiceDoc();
+ AtomWorkspace workspace = new AtomWorkspace();
+ workspace.setTitle("My Weblog");
+ service.getWorkspaces().add(workspace);
+
+ AtomCollection entries = collection("Weblog Entries", BLOG + "/entries",
+ "application/atom+xml;type=entry");
+ AtomCategories inline = categories();
+ entries.getCategories().add(inline);
+ AtomCategories byReference = new AtomCategories();
+ byReference.setHref(BLOG + "/categories.atomcat");
+ entries.getCategories().add(byReference);
+ entries.getCategories().add(new AtomCategories());
+ workspace.getCollections().add(entries);
+
+ AtomCollection media = collection("Media Files: default", BLOG + "/resources/default",
+ "image/*");
+ media.getAccepts().add("application/pdf");
+ workspace.getCollections().add(media);
+ workspace.getCollections().add(collection("Templates", BLOG + "/templates",
+ "application/atom+xml;type=entry"));
+ workspace.getCollections().add(collection("Categories", BLOG + "/categories",
+ "application/atom+xml;type=entry"));
+ workspace.getCollections().add(collection("Comments", BLOG + "/comments", ""));
+ return service;
+ }
+
+ private static AtomEntry weblogEntry() {
+ AtomEntry entry = new AtomEntry();
+ entry.setId(ROLLER + "/myblog/entry/hello_atompub");
+ entry.setTitle("Hello AtomPub");
+ entry.setPublished(PUBLISHED);
+ entry.setUpdated(UPDATED);
+ entry.setEdited(UPDATED);
+ entry.setContent(CollectionSupport.text("html", "Posted with AtomPub .
"));
+ AtomPerson author = new AtomPerson();
+ author.setName("dave");
+ author.setEmail("dave@example.com");
+ entry.getAuthors().add(author);
+ AtomCategory category = new AtomCategory();
+ category.setTerm("Java");
+ category.setScheme(ROLLER + "/myblog/");
+ entry.getCategories().add(category);
+ AtomCategory tag = new AtomCategory();
+ tag.setTerm("atompub");
+ entry.getCategories().add(tag);
+ entry.getLinks().add(new AtomLink("alternate", ROLLER + "/myblog/entry/hello_atompub"));
+ entry.getLinks().add(new AtomLink("edit", BLOG + "/entry/4a7d1ed4"));
+ return entry;
+ }
+
+ private static AtomFeed entriesFeed() {
+ AtomFeed feed = new AtomFeed();
+ feed.setId(BLOG + "/entries/0");
+ feed.setTitle("My Weblog");
+ feed.setUpdated(UPDATED);
+ feed.getLinks().add(link("alternate", ROLLER + "/myblog"));
+ feed.getLinks().add(link("next", BLOG + "/entries/20"));
+ feed.getEntries().add(weblogEntry());
+ return feed;
+ }
+
+ private static AtomEntry mediaLinkEntry() {
+ AtomEntry entry = new AtomEntry();
+ entry.setId(BLOG + "/resource/default/sunset.jpg");
+ entry.setTitle("sunset.jpg");
+ entry.setUpdated(UPDATED);
+ entry.setEdited(UPDATED);
+ AtomContent content = new AtomContent();
+ content.setType("image/jpeg");
+ content.setSrc(ROLLER + "/myblog/mediaresource/2b9c4f0e");
+ entry.setContent(content);
+ entry.getLinks().add(link("alternate", ROLLER + "/myblog/mediaresource/2b9c4f0e"));
+ entry.getLinks().add(link("edit", BLOG + "/resource/default/sunset.jpg.media-link"));
+ entry.getLinks().add(link("edit-media", BLOG + "/resource/default/sunset.jpg"));
+ return entry;
+ }
+
+ private static AtomCategories categories() {
+ AtomCategories cats = new AtomCategories();
+ cats.setFixed(true);
+ cats.setScheme(ROLLER + "/myblog/");
+ for (String name : Arrays.asList("General", "Java", "Travel")) {
+ AtomCategory cat = new AtomCategory();
+ cat.setTerm(name);
+ cat.setLabel(name);
+ cats.getCategories().add(cat);
+ }
+ return cats;
+ }
+
+ private static AtomEntry categoryEntry() {
+ AtomEntry entry = new AtomEntry();
+ entry.setId(BLOG + "/category/7c3e9a10");
+ entry.setTitle("Travel");
+ entry.setUpdated(UPDATED);
+ entry.setSummary(CollectionSupport.text("text", "Trips and places"));
+ entry.setExtension("image", "https://example.com/images/travel.png");
+ entry.setExtension("position", "2");
+ entry.setExtension("inUse", "true");
+ entry.getLinks().add(link("edit", BLOG + "/category/7c3e9a10"));
+ return entry;
+ }
+
+ private static AtomEntry templateEntry() {
+ AtomEntry entry = new AtomEntry();
+ entry.setId(BLOG + "/template/0e5d8b62");
+ entry.setTitle("sidebar");
+ entry.setSummary(CollectionSupport.text("text", "The sidebar"));
+ entry.setUpdated(UPDATED);
+ entry.setEdited(UPDATED);
+ entry.setContent(CollectionSupport.text("text",
+ ""));
+ entry.setMobileRendition("");
+ entry.setExtension("action", "custom");
+ entry.setExtension("link", "sidebar");
+ entry.setExtension("navbar", "true");
+ entry.setExtension("hidden", "false");
+ entry.setExtension("required", "false");
+ entry.getLinks().add(link("edit", BLOG + "/template/0e5d8b62"));
+ return entry;
+ }
+
+ private static AtomEntry commentEntry() {
+ AtomEntry entry = new AtomEntry();
+ entry.setId(BLOG + "/comment/8f0e2a6c");
+ entry.setTitle("Comment on Hello AtomPub");
+ entry.setPublished(PUBLISHED);
+ entry.setUpdated(PUBLISHED);
+ AtomPerson author = new AtomPerson();
+ author.setName("Pat Reader");
+ author.setEmail("pat@example.org");
+ author.setUri("https://pat.example.org/");
+ entry.getAuthors().add(author);
+ entry.setContent(CollectionSupport.text("text", "Nice post!"));
+ entry.setInReplyToRef(ROLLER + "/myblog/entry/hello_atompub");
+ entry.setInReplyToHref(BLOG + "/entry/4a7d1ed4");
+ entry.setExtension("status", "pending");
+ entry.setExtension("remoteHost", "192.0.2.10");
+ entry.getLinks().add(link("edit", BLOG + "/comment/8f0e2a6c"));
+ entry.getLinks().add(link("related", ROLLER + "/myblog/entry/hello_atompub"));
+ return entry;
+ }
+
+ private static AtomFeed commentsFeed() {
+ AtomFeed feed = new AtomFeed();
+ feed.setId(BLOG + "/comments/0");
+ feed.setTitle("My Weblog comments");
+ feed.setUpdated(UPDATED);
+ feed.getLinks().add(link("next", BLOG + "/comments/20?status=pending"));
+ feed.getEntries().add(commentEntry());
+ return feed;
+ }
+
+ private static AtomCollection collection(String title, String href, String accept) {
+ AtomCollection collection = new AtomCollection();
+ collection.setTitle(title);
+ collection.setHref(href);
+ collection.getAccepts().add(accept);
+ return collection;
+ }
+
+ private static AtomLink link(String rel, String href) {
+ return CollectionSupport.link(rel, href);
+ }
+
+ // ------------------------------------------------------------- helpers
+
+ @FunctionalInterface
+ private interface Writing {
+ void run(WriterOut w) throws AtomException;
+ }
+
+ /** An AtomWriter with the stream it writes to. */
+ private static final class WriterOut extends AtomWriter {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ }
+
+ private static byte[] write(Writing writing) throws AtomException {
+ WriterOut w = new WriterOut();
+ writing.run(w);
+ return w.out.toByteArray();
+ }
+
+ private static AtomEntry read(String name) throws IOException, AtomException {
+ try (InputStream in = Files.newInputStream(DOCS.resolve("examples").resolve(name))) {
+ return new AtomReader().parseEntry(in);
+ }
+ }
+
+ /** docs/atompub, from the app module (Maven) or the repository root (IDE). */
+ private static Path locateDocs() {
+ Path cwd = Paths.get("").toAbsolutePath();
+ for (Path base : Arrays.asList(cwd.getParent(), cwd)) {
+ if (base != null && Files.isDirectory(base.resolve("docs/atompub"))) {
+ return base.resolve("docs/atompub");
+ }
+ }
+ throw new IllegalStateException("Cannot find docs/atompub from " + cwd);
+ }
+
+ // ----------------------------------------- a small XML tree and printer
+
+ /** An element: qualified name, attributes and namespace declarations, children. */
+ private static final class Node {
+ final String name;
+ final List attributes = new ArrayList<>();
+ final List children = new ArrayList<>();
+ String namespace;
+
+ Node(String name) {
+ this.name = name;
+ }
+ }
+
+ private static Node parse(byte[] xml) throws Exception {
+ XMLInputFactory factory = XMLInputFactory.newInstance();
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE);
+ XMLStreamReader r = factory.createXMLStreamReader(new ByteArrayInputStream(xml), "UTF-8");
+ List stack = new ArrayList<>();
+ Node root = null;
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ Node node = new Node(qname(r.getPrefix(), r.getLocalName()));
+ node.namespace = r.getNamespaceURI();
+ for (int i = 0; i < r.getNamespaceCount(); i++) {
+ String prefix = r.getNamespacePrefix(i);
+ node.attributes.add(new String[] {
+ prefix == null || prefix.isEmpty() ? "xmlns" : "xmlns:" + prefix,
+ r.getNamespaceURI(i)});
+ }
+ for (int i = 0; i < r.getAttributeCount(); i++) {
+ node.attributes.add(new String[] {
+ qname(r.getAttributePrefix(i), r.getAttributeLocalName(i)),
+ r.getAttributeValue(i)});
+ }
+ if (stack.isEmpty()) {
+ root = node;
+ } else {
+ stack.get(stack.size() - 1).children.add(node);
+ }
+ stack.add(node);
+ } else if (event == XMLStreamConstants.END_ELEMENT) {
+ stack.remove(stack.size() - 1);
+ } else if ((event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA)
+ && !stack.isEmpty() && !r.isWhiteSpace()) {
+ stack.get(stack.size() - 1).children.add(r.getText());
+ }
+ }
+ return root;
+ }
+
+ private static String qname(String prefix, String local) {
+ return prefix == null || prefix.isEmpty() ? local : prefix + ":" + local;
+ }
+
+ /** Re-indents a document: two spaces per level, text-only elements on one line. */
+ private static String indent(byte[] xml) throws Exception {
+ StringBuilder out = new StringBuilder("\n");
+ print(parse(xml), 0, out);
+ return out.toString();
+ }
+
+ private static void print(Node node, int depth, StringBuilder out) {
+ String pad = " ".repeat(depth);
+ out.append(pad).append('<').append(node.name);
+ for (String[] attribute : node.attributes) {
+ out.append(' ').append(attribute[0]).append("=\"")
+ .append(escape(attribute[1], true)).append('"');
+ }
+ if (node.children.isEmpty()) {
+ out.append("/>\n");
+ return;
+ }
+ boolean textOnly = node.children.stream().allMatch(c -> c instanceof String);
+ if (textOnly) {
+ out.append('>');
+ for (Object text : node.children) {
+ out.append(escape((String) text, false));
+ }
+ out.append("").append(node.name).append(">\n");
+ return;
+ }
+ out.append(">\n");
+ for (Object child : node.children) {
+ if (child instanceof Node) {
+ print((Node) child, depth + 1, out);
+ } else {
+ out.append(pad).append(" ").append(escape((String) child, false)).append('\n');
+ }
+ }
+ out.append(pad).append("").append(node.name).append(">\n");
+ }
+
+ private static String escape(String text, boolean attribute) {
+ String escaped = text.replace("&", "&").replace("<", "<").replace(">", ">");
+ return attribute ? escaped.replace("\"", """) : escaped;
+ }
+
+ /** Each roller:* or thr:* element, serialized alone with its namespace declared. */
+ private static List extensionElements(Node node) {
+ List found = new ArrayList<>();
+ collectExtensions(node, found);
+ return found;
+ }
+
+ private static void collectExtensions(Node node, List found) {
+ for (Object child : node.children) {
+ if (!(child instanceof Node)) {
+ continue;
+ }
+ Node element = (Node) child;
+ if (AtomConstants.ROLLER_NS.equals(element.namespace)
+ || AtomConstants.THREAD_NS.equals(element.namespace)) {
+ StringBuilder xml = new StringBuilder();
+ String prefix = element.name.substring(0, element.name.indexOf(':'));
+ xml.append('<').append(element.name).append(" xmlns:").append(prefix)
+ .append("=\"").append(element.namespace).append('"');
+ for (String[] attribute : element.attributes) {
+ if (!attribute[0].startsWith("xmlns")) {
+ xml.append(' ').append(attribute[0]).append("=\"")
+ .append(escape(attribute[1], true)).append('"');
+ }
+ }
+ xml.append('>');
+ for (Object text : element.children) {
+ xml.append(escape(String.valueOf(text), false));
+ }
+ xml.append("").append(element.name).append('>');
+ found.add(xml.toString().getBytes(StandardCharsets.UTF_8));
+ } else {
+ collectExtensions(element, found);
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java
index 3d4ca17c6..c51f3cdde 100644
--- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java
@@ -19,24 +19,14 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
import java.time.Instant;
-import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.Test;
-import org.xml.sax.ErrorHandler;
-import org.xml.sax.InputSource;
-import org.xml.sax.SAXParseException;
-import com.thaiopensource.util.PropertyMapBuilder;
-import com.thaiopensource.validate.ValidateProperty;
-import com.thaiopensource.validate.ValidationDriver;
-import com.thaiopensource.validate.rng.CompactSchemaReader;
/**
* Validates the XML produced by {@link AtomWriter} against the official RELAX NG
@@ -49,25 +39,8 @@ public class AtomSchemaValidationTest {
private static final Date PUBLISHED = Date.from(Instant.parse("2026-06-03T12:34:56Z"));
private static final Date UPDATED = Date.from(Instant.parse("2026-06-04T01:02:03Z"));
- /** Validate xml against a classpath RELAX NG Compact schema; return errors (empty == valid). */
private List validate(String schemaResource, byte[] xml) throws Exception {
- List errors = new ArrayList<>();
- ErrorHandler handler = new ErrorHandler() {
- @Override public void warning(SAXParseException e) { /* ignore warnings */ }
- @Override public void error(SAXParseException e) { errors.add(e.getMessage()); }
- @Override public void fatalError(SAXParseException e) { errors.add(e.getMessage()); }
- };
- PropertyMapBuilder props = new PropertyMapBuilder();
- props.put(ValidateProperty.ERROR_HANDLER, handler);
- ValidationDriver driver =
- new ValidationDriver(props.toPropertyMap(), CompactSchemaReader.getInstance());
-
- try (InputStream schema = getClass().getResourceAsStream(schemaResource)) {
- assertTrue(driver.loadSchema(new InputSource(schema)),
- "schema " + schemaResource + " failed to compile: " + errors);
- }
- driver.validate(new InputSource(new ByteArrayInputStream(xml)));
- return errors;
+ return RelaxNgValidator.validateResource(schemaResource, xml);
}
private byte[] writeEntry(AtomEntry entry) throws Exception {
diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java
new file mode 100644
index 000000000..4d8968ef0
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java
@@ -0,0 +1,72 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+package org.apache.roller.weblogger.webservices.atomprotocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.xml.sax.ErrorHandler;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+import com.thaiopensource.util.PropertyMapBuilder;
+import com.thaiopensource.validate.ValidateProperty;
+import com.thaiopensource.validate.ValidationDriver;
+import com.thaiopensource.validate.rng.CompactSchemaReader;
+
+/** Validates XML against a RELAX NG Compact schema with Jing. */
+final class RelaxNgValidator {
+
+ private RelaxNgValidator() {
+ }
+
+ /**
+ * Validates {@code xml} against the schema in {@code schema}; returns the
+ * errors, empty when the document is valid.
+ */
+ static List validate(InputStream schema, String schemaName, byte[] xml)
+ throws IOException, SAXException {
+ List errors = new ArrayList<>();
+ ErrorHandler handler = new ErrorHandler() {
+ @Override public void warning(SAXParseException e) { /* ignore warnings */ }
+ @Override public void error(SAXParseException e) { errors.add(e.getMessage()); }
+ @Override public void fatalError(SAXParseException e) { errors.add(e.getMessage()); }
+ };
+ PropertyMapBuilder props = new PropertyMapBuilder();
+ props.put(ValidateProperty.ERROR_HANDLER, handler);
+ ValidationDriver driver =
+ new ValidationDriver(props.toPropertyMap(), CompactSchemaReader.getInstance());
+ if (!driver.loadSchema(new InputSource(schema))) {
+ throw new IllegalStateException("schema " + schemaName + " failed to compile: " + errors);
+ }
+ driver.validate(new InputSource(new ByteArrayInputStream(xml)));
+ return errors;
+ }
+
+ /** Validates against a classpath schema. */
+ static List validateResource(String schemaResource, byte[] xml)
+ throws IOException, SAXException {
+ try (InputStream schema = RelaxNgValidator.class.getResourceAsStream(schemaResource)) {
+ return validate(schema, schemaResource, xml);
+ }
+ }
+}
diff --git a/assembly-release/src/main/assembly/binary.xml b/assembly-release/src/main/assembly/binary.xml
index ae75c95c5..85ea353fb 100644
--- a/assembly-release/src/main/assembly/binary.xml
+++ b/assembly-release/src/main/assembly/binary.xml
@@ -51,6 +51,10 @@
target/generated-docs/roller-template-guide.pdf
${artifact.artifactId}-${artifact.version}/docs
+
+ target/generated-docs/roller-atompub-guide.pdf
+ ${artifact.artifactId}-${artifact.version}/docs
+
target/generated-docs/roller-user-guide.pdf
${artifact.artifactId}-${artifact.version}/docs
diff --git a/docs/README.md b/docs/README.md
index 6991392fa..6fb2052a2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@ In this directory you'll find Roller docs in AsciiDoc format, various examples a
* `roller-install-guide.adoc` - How to install Roller
* `roller-user-guide.adoc` - How to use Roller
* `roller-template-guide.adoc` - How to create Roller templates and themes
+* `roller-atompub-guide.adoc` - Reference for Roller's AtomPub interface; its examples and extension schema are in `atompub/`
* `security-model.md` - Who Roller trusts, what each user may do, and what counts as a vulnerability
* `examples/` - example configuration and script files
* `testing/` - Various test scripts
diff --git a/docs/atompub/examples/README.txt b/docs/atompub/examples/README.txt
new file mode 100644
index 000000000..bbca40501
--- /dev/null
+++ b/docs/atompub/examples/README.txt
@@ -0,0 +1,4 @@
+Examples included by ../../roller-atompub-guide.adoc. Files ending in
+-request.xml are written by hand; the others are generated by
+AtomPubDocExamplesTest (mvn -pl app test -Dtest=AtomPubDocExamplesTest
+-Datompub.docs.update=true). The test fails when they no longer match.
diff --git a/docs/atompub/examples/categories.xml b/docs/atompub/examples/categories.xml
new file mode 100644
index 000000000..67ef32542
--- /dev/null
+++ b/docs/atompub/examples/categories.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/docs/atompub/examples/category-entry.xml b/docs/atompub/examples/category-entry.xml
new file mode 100644
index 000000000..f3cfac6c0
--- /dev/null
+++ b/docs/atompub/examples/category-entry.xml
@@ -0,0 +1,14 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/category/7c3e9a10
+ Travel
+ 2026-10-10T12:30:00Z
+ Trips and places
+
+ https://example.com/images/travel.png
+ 2
+ true
+
+ no
+
+
diff --git a/docs/atompub/examples/category-request.xml b/docs/atompub/examples/category-request.xml
new file mode 100644
index 000000000..3f1b86ccb
--- /dev/null
+++ b/docs/atompub/examples/category-request.xml
@@ -0,0 +1,11 @@
+
+
+ urn:uuid:5b1f7a1e-0b6a-4c0e-9a37-3f1d2c4e8b11
+ Travel
+ 2026-10-10T12:00:00Z
+
+ dave
+
+ Trips and places
+ https://example.com/images/travel.png
+
diff --git a/docs/atompub/examples/comment-entry.xml b/docs/atompub/examples/comment-entry.xml
new file mode 100644
index 000000000..e169b7094
--- /dev/null
+++ b/docs/atompub/examples/comment-entry.xml
@@ -0,0 +1,21 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/comment/8f0e2a6c
+ Comment on Hello AtomPub
+ 2026-10-10T12:00:00Z
+ 2026-10-10T12:00:00Z
+
+ Pat Reader
+ https://pat.example.org/
+ pat@example.org
+
+ Nice post!
+
+
+
+ pending
+ 192.0.2.10
+
+ no
+
+
diff --git a/docs/atompub/examples/comment-status-request.xml b/docs/atompub/examples/comment-status-request.xml
new file mode 100644
index 000000000..d74140ef2
--- /dev/null
+++ b/docs/atompub/examples/comment-status-request.xml
@@ -0,0 +1,10 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/comment/8f0e2a6c
+ Comment on Hello AtomPub
+ 2026-10-10T12:00:00Z
+
+ dave
+
+ approved
+
diff --git a/docs/atompub/examples/comments-feed.xml b/docs/atompub/examples/comments-feed.xml
new file mode 100644
index 000000000..4b4e5ba4e
--- /dev/null
+++ b/docs/atompub/examples/comments-feed.xml
@@ -0,0 +1,27 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/comments/0
+ My Weblog comments
+ 2026-10-10T12:30:00Z
+
+
+ https://example.com/roller/roller-services/app/myblog/comment/8f0e2a6c
+ Comment on Hello AtomPub
+ 2026-10-10T12:00:00Z
+ 2026-10-10T12:00:00Z
+
+ Pat Reader
+ https://pat.example.org/
+ pat@example.org
+
+ Nice post!
+
+
+
+ pending
+ 192.0.2.10
+
+ no
+
+
+
diff --git a/docs/atompub/examples/entries-feed.xml b/docs/atompub/examples/entries-feed.xml
new file mode 100644
index 000000000..a9211a9aa
--- /dev/null
+++ b/docs/atompub/examples/entries-feed.xml
@@ -0,0 +1,27 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/entries/0
+ My Weblog
+ 2026-10-10T12:30:00Z
+
+
+
+ https://example.com/roller/myblog/entry/hello_atompub
+ Hello AtomPub
+ 2026-10-10T12:00:00Z
+ 2026-10-10T12:30:00Z
+
+ dave
+ dave@example.com
+
+
+
+ <p>Posted with <b>AtomPub</b>.</p>
+
+
+ 2026-10-10T12:30:00Z
+
+ no
+
+
+
diff --git a/docs/atompub/examples/entry-request.xml b/docs/atompub/examples/entry-request.xml
new file mode 100644
index 000000000..4fb2779ac
--- /dev/null
+++ b/docs/atompub/examples/entry-request.xml
@@ -0,0 +1,15 @@
+
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ Hello AtomPub
+ 2026-10-10T12:00:00Z
+
+ dave
+
+
+
+ <p>Posted with <b>AtomPub</b>.</p>
+
+ no
+
+
diff --git a/docs/atompub/examples/entry-response.xml b/docs/atompub/examples/entry-response.xml
new file mode 100644
index 000000000..b513e743c
--- /dev/null
+++ b/docs/atompub/examples/entry-response.xml
@@ -0,0 +1,20 @@
+
+
+ https://example.com/roller/myblog/entry/hello_atompub
+ Hello AtomPub
+ 2026-10-10T12:00:00Z
+ 2026-10-10T12:30:00Z
+
+ dave
+ dave@example.com
+
+
+
+ <p>Posted with <b>AtomPub</b>.</p>
+
+
+ 2026-10-10T12:30:00Z
+
+ no
+
+
diff --git a/docs/atompub/examples/media-link-entry.xml b/docs/atompub/examples/media-link-entry.xml
new file mode 100644
index 000000000..5427acd06
--- /dev/null
+++ b/docs/atompub/examples/media-link-entry.xml
@@ -0,0 +1,14 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/resource/default/sunset.jpg
+ sunset.jpg
+ 2026-10-10T12:30:00Z
+
+
+
+
+ 2026-10-10T12:30:00Z
+
+ no
+
+
diff --git a/docs/atompub/examples/service.xml b/docs/atompub/examples/service.xml
new file mode 100644
index 000000000..f14322575
--- /dev/null
+++ b/docs/atompub/examples/service.xml
@@ -0,0 +1,34 @@
+
+
+
+ My Weblog
+
+ Weblog Entries
+ application/atom+xml;type=entry
+
+
+
+
+
+
+
+
+
+ Media Files: default
+ image/*
+ application/pdf
+
+
+ Templates
+ application/atom+xml;type=entry
+
+
+ Categories
+ application/atom+xml;type=entry
+
+
+ Comments
+
+
+
+
diff --git a/docs/atompub/examples/template-entry.xml b/docs/atompub/examples/template-entry.xml
new file mode 100644
index 000000000..b340626fe
--- /dev/null
+++ b/docs/atompub/examples/template-entry.xml
@@ -0,0 +1,19 @@
+
+
+ https://example.com/roller/roller-services/app/myblog/template/0e5d8b62
+ sidebar
+ 2026-10-10T12:30:00Z
+ The sidebar
+ <div class="sidebar">$model.weblog.name</div>
+
+ <div class="sidebar-mobile">$model.weblog.name</div>
+ custom
+ sidebar
+ true
+ false
+ false
+ 2026-10-10T12:30:00Z
+
+ no
+
+
diff --git a/docs/atompub/examples/template-request.xml b/docs/atompub/examples/template-request.xml
new file mode 100644
index 000000000..5ba17d71e
--- /dev/null
+++ b/docs/atompub/examples/template-request.xml
@@ -0,0 +1,14 @@
+
+
+ urn:uuid:9e0c4f8a-6d3b-4a51-b2f7-0c8e1d5a7f20
+ sidebar
+ 2026-10-10T12:00:00Z
+
+ dave
+
+ The sidebar
+ <div class="sidebar">$model.weblog.name</div>
+ <div class="sidebar-mobile">$model.weblog.name</div>
+ custom
+ true
+
diff --git a/docs/atompub/roller-atompub.rnc b/docs/atompub/roller-atompub.rnc
new file mode 100644
index 000000000..2c7bb6f5e
--- /dev/null
+++ b/docs/atompub/roller-atompub.rnc
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. The ASF licenses this file to You
+# under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License. For additional information regarding
+# copyright in this work, please see the NOTICE file in the top level
+# directory of this distribution.
+
+# RELAX NG Compact grammar for the extension elements that Roller's AtomPub
+# service adds to atom:entry: Roller's own elements and the thr:in-reply-to
+# element of the Atom Threading Extensions (RFC 4685). Atom allows these as
+# foreign elements, so an entry that uses them is still valid against the
+# RFC 4287 grammar. This grammar checks the elements themselves; its start
+# pattern matches one extension element.
+
+namespace roller = "http://roller.apache.org/ns/atompub/1.0"
+namespace thr = "http://purl.org/syndication/thread/1.0"
+
+start = rollerElement | thrInReplyTo
+
+boolean = "true" | "false"
+
+rollerElement =
+ # Templates
+ element roller:rendition { attribute type { "mobile" }, text }
+ | element roller:action {
+ "weblog" | "permalink" | "search" | "tagsindex" | "stylesheet" | "custom"
+ }
+ | element roller:link { text }
+ | element roller:navbar { boolean }
+ | element roller:hidden { boolean }
+ | element roller:required { boolean }
+ | element roller:contentType { text }
+ # Categories
+ | element roller:image { text }
+ | element roller:position { xsd:nonNegativeInteger }
+ | element roller:inUse { boolean }
+ # Comments
+ | element roller:status { "approved" | "disapproved" | "spam" | "pending" }
+ | element roller:remoteHost { text }
+ | element roller:referrer { text }
+
+thrInReplyTo =
+ element thr:in-reply-to {
+ attribute ref { text },
+ attribute href { text }?,
+ attribute type { text }?,
+ attribute source { text }?,
+ empty
+ }
diff --git a/docs/roller-atompub-guide.adoc b/docs/roller-atompub-guide.adoc
new file mode 100644
index 000000000..bc7a04284
--- /dev/null
+++ b/docs/roller-atompub-guide.adoc
@@ -0,0 +1,508 @@
+= AtomPub Guide
+Apache Roller Weblogger
+:toc:
+:sectnums:
+:imagesdir: ./images
+
+== Overview
+
+This is the reference for the Atom Publishing Protocol (AtomPub) interface of
+the Apache Roller Weblogger. With it, a program can create and edit weblog
+entries, upload media files, and manage a weblog's categories, templates and
+comments over HTTP.
+
+Roller implements AtomPub as defined in
+https://www.rfc-editor.org/rfc/rfc5023[RFC 5023], using the Atom format of
+https://www.rfc-editor.org/rfc/rfc4287[RFC 4287]. Categories, templates and
+comments are Roller extensions; they use ordinary Atom entries plus a few
+extension elements, so any AtomPub client library can read and write them.
+
+=== Copyright and trademark information
+
+The contents of this document are subject to the terms of the Apache
+Software License.
+
+All trademarks within this document belong to legitimate owners.
+
+=== Conventions
+
+* All URIs in this guide are relative to the AtomPub base URL,
+`https:////roller-services/app`. In the examples the base URL is
+`https://example.com/roller/roller-services/app`.
+* `{h}` stands for a weblog handle, for example `myblog`.
+* Namespaces and the prefixes used in this guide:
++
+[cols="1,3",options="header"]
+|===
+|Prefix |Namespace
+|(default) |`http://www.w3.org/2005/Atom` (Atom, RFC 4287)
+|`app` |`http://www.w3.org/2007/app` (AtomPub, RFC 5023)
+|`roller` |`http://roller.apache.org/ns/atompub/1.0` (Roller extensions)
+|`thr` |`http://purl.org/syndication/thread/1.0` (Atom Threading, RFC 4685)
+|===
+* Media types:
++
+[cols="2,3",options="header"]
+|===
+|Media type |Used for
+|`application/atomsvc+xml` |The service document
+|`application/atom+xml;type=feed` |Collections (feeds)
+|`application/atom+xml;type=entry` |Entries, in requests and responses
+|`application/atomcat+xml` |Category documents
+|===
+
+The XML examples in this guide are generated from Roller's own code by its
+test suite, so they show exactly what the server writes.
+
+== Enabling AtomPub
+
+A Roller administrator controls AtomPub in *Server Administration > Configuration
+> Web Services Settings*:
+
+[cols="2,1,4",options="header"]
+|===
+|Setting |Default |Meaning
+|`webservices.enableAtomPub` |`false`
+|Turns AtomPub on. While it is off, every AtomPub URL answers `404 Not Found`.
+|`webservices.atomPubAuth` |`basic`
+|Authentication method: `basic` or `oauth`. Any other value refuses all
+requests (see <>).
+|`webservices.atomPubMaxEntrySize` |`1048576`
+|Largest Atom entry body accepted, in bytes. A larger entry gets
+`413 Request Entity Too Large`. Media uploads are not affected; they use the
+file upload limits.
+|`themes.customtheme.allowed` |`false`
+|Must be on for the templates collection to exist.
+|===
+
+Media uploads also need *File Upload Settings*: uploads enabled, the allowed
+file types, and the size and quota limits.
+
+== Authentication
+
+Every AtomPub request must be authenticated. Roller checks the credentials
+before it reads the request body. A request that fails authentication gets
+`401 Unauthorized`.
+
+Always use HTTPS: BASIC authentication sends the password with every request.
+
+=== BASIC
+
+With `webservices.atomPubAuth` set to `basic`, send the Roller user name and
+password in an HTTP `Authorization` header:
+
+----
+Authorization: Basic ZGF2ZTpzZWNyZXQ=
+----
+
+The user must be enabled. Without valid credentials Roller answers `401` with
+`WWW-Authenticate: Basic realm="Roller"`.
+
+=== OAuth
+
+With `webservices.atomPubAuth` set to `oauth`, clients use OAuth 1.0a. Each
+user finds their consumer key and secret, and the OAuth URLs, through the
+*OAuth Credentials* link on the *Your Weblogs* page (shown while OAuth is on).
+Administrators also see site-wide keys.
+
+[cols="2,3",options="header"]
+|===
+|Step |URL
+|Get a request token |`//roller-services/oauth/requestToken`
+|User authorizes the token |`//roller-services/oauth/authorize`
+|Exchange for an access token |`//roller-services/oauth/accessToken`
+|===
+
+Sign each AtomPub request with the access token, as OAuth 1.0a describes.
+
+== Discovery: the service document
+
+`GET /` returns the service document. It has one workspace for each weblog the
+user is a member of, and lists only the collections the user may use:
+
+[cols="2,3",options="header"]
+|===
+|Collection |Listed when
+|Weblog Entries, Media Files (one per media directory)
+|The user is a member of the weblog
+|Categories, Comments
+|The user may post to the weblog (permission `POST` or higher)
+|Templates
+|The user is an administrator of the weblog (`ADMIN`), the weblog uses a custom
+theme, and the site allows custom themes
+|===
+
+The entries collection describes the weblog's categories twice: inline, for
+older clients, and as a link to the weblog's category document. A third,
+empty `app:categories` element says that any other term is accepted as a tag.
+The comments collection has an empty `app:accept`: it does not accept `POST`.
+
+.Service document
+[source,xml]
+----
+include::atompub/examples/service.xml[]
+----
+
+== Requests and responses
+
+* Send entries with `Content-Type: application/atom+xml;type=entry`.
+* Roller refuses an entry that is not well-formed XML or that has a `DOCTYPE`
+(`400 Bad Request`).
+* A successful `POST` answers `201 Created` with the new member as the body and
+its edit URI in the `Location` and `Content-Location` headers.
+* A successful `PUT` or `DELETE` answers `200 OK` without a body.
+* The entries, media and comments collections return at most 20 members. When
+there are more, the feed has `next` and `previous` links; follow them to page
+through. The categories and templates collections return all members.
+
+== Weblog entries
+
+[cols="2,1,4",options="header"]
+|===
+|URI |Method |Action
+|`/{h}/entries`, `/{h}/entries/{offset}` |GET |List entries, newest first
+|`/{h}/entries` |POST |Create an entry
+|`/{h}/entry/{id}` |GET |Read an entry
+|`/{h}/entry/{id}` |PUT |Replace an entry
+|`/{h}/entry/{id}` |DELETE |Delete an entry
+|===
+
+*Permission:* to list or create entries, `POST` on the weblog. To read, change
+or delete an entry, the user must be allowed to edit that entry.
+
+[cols="2,1,4",options="header"]
+|===
+|Element |Access |Meaning
+|`atom:title` |read/write |Entry title
+|`atom:content` |read/write |Entry text, as HTML (`type="html"`)
+|`atom:summary` |read/write |Entry summary, as HTML
+|`atom:published` |read/write |Publication time; now when missing
+|`atom:updated` |read/write |Update time; now when missing
+|`atom:category` with the weblog's scheme |read/write
+|The weblog category. The scheme is the weblog's URL. Roller keeps the first
+one that names an existing category; otherwise it uses the weblog's default
+category.
+|`atom:category` without a scheme |read/write |A tag
+|`app:control/app:draft` |read/write |`yes` saves a draft; `no` publishes
+|`atom:author` |read |The user who created the entry; ignored on input
+|`atom:link rel="alternate"` |read |The entry's permalink
+|`app:edited` |read |Last change
+|===
+
+.Creating an entry (request)
+[source,xml]
+----
+include::atompub/examples/entry-request.xml[]
+----
+
+.Created entry (response)
+[source,xml]
+----
+include::atompub/examples/entry-response.xml[]
+----
+
+.Entries collection
+[source,xml]
+----
+include::atompub/examples/entries-feed.xml[]
+----
+
+== Media files
+
+[cols="2,1,4",options="header"]
+|===
+|URI |Method |Action
+|`/{h}/resources/{dir}`, `/{h}/resources/{dir}/{offset}` |GET
+|List the files in a media directory
+|`/{h}/resources`, `/{h}/resources/{dir}` |POST
+|Upload a file to the default or the named directory
+|`/{h}/resource/{dir}/{name}.media-link` |GET |Read the media link entry
+|`/{h}/resource/{dir}/{name}` |GET |Download the file
+|`/{h}/resource/{dir}/{name}` |PUT |Replace the file's contents
+|`/{h}/resource/{dir}/{name}` or `....media-link` |DELETE |Delete the file
+|===
+
+*Permission:* `POST` on the weblog.
+
+To upload, `POST` the file's bytes with its media type in `Content-Type` and a
+file name in the `Slug` header:
+
+----
+POST /myblog/resources/default HTTP/1.1
+Content-Type: image/jpeg
+Slug: sunset
+----
+
+* Without `Slug`, Roller names the file from the weblog handle and the date.
+The file name extension comes from the media type.
+* The media type must be one the site allows; the service document lists them
+in each media collection's `app:accept`. Roller stores the type it derives
+from the file name and contents, not only the one the client sends.
+* A `POST` without `Content-Type` gets `415 Unsupported Media Type`.
+* A file that breaks the upload rules (type, size, quota) or that already
+exists is refused.
+
+Downloads of types that a browser could run, such as HTML, are served as
+attachments with `X-Content-Type-Options: nosniff`.
+
+.Media link entry
+[source,xml]
+----
+include::atompub/examples/media-link-entry.xml[]
+----
+
+== Categories
+
+=== Category document
+
+`GET /{h}/categories.atomcat` returns the weblog's categories as an RFC 5023
+category document. Clients use it to offer category choices for new entries.
+
+*Permission:* `POST` on the weblog.
+
+[source,xml]
+----
+include::atompub/examples/categories.xml[]
+----
+
+=== Categories collection
+
+[cols="2,1,4",options="header"]
+|===
+|URI |Method |Action
+|`/{h}/categories` |GET |List the weblog's categories
+|`/{h}/categories` |POST |Create a category
+|`/{h}/category/{id}` |GET |Read a category
+|`/{h}/category/{id}` |PUT |Change a category
+|`/{h}/category/{id}`, `/{h}/category/{id}?moveTo={otherId}` |DELETE
+|Delete a category
+|===
+
+*Permission:* `POST` on the weblog.
+
+[cols="2,1,4",options="header"]
+|===
+|Element |Access |Meaning
+|`atom:title` |read/write
+|Name; required on `POST`; at most 255 characters. It must be unique in the
+weblog and may not contain `<`, `>`, `&` or `"`.
+|`atom:summary` |read/write |Description, at most 255 characters
+|`roller:image` |read/write |Image URL (`http` or `https`); empty removes it
+|`roller:position` |read |Order in the weblog's category list
+|`roller:inUse` |read |`true` when entries use the category, or it is the
+weblog's default category
+|===
+
+A duplicate name gets `409 Conflict`. Deleting a category is refused with
+`409 Conflict` when:
+
+* entries use it, or it is the weblog's default category, and the request has no
+`moveTo`. With `moveTo={otherId}`, Roller first moves the entries to that
+category, which also becomes the default if needed;
+* it is the weblog's only category.
+
+.Creating a category (request)
+[source,xml]
+----
+include::atompub/examples/category-request.xml[]
+----
+
+.Category entry
+[source,xml]
+----
+include::atompub/examples/category-entry.xml[]
+----
+
+== Templates
+
+The templates collection lets a client edit the templates of a weblog that uses
+a custom theme. Weblogs that use a shared theme have no templates collection:
+its URIs answer `404 Not Found`, even for administrators.
+
+[cols="2,1,4",options="header"]
+|===
+|URI |Method |Action
+|`/{h}/templates` |GET |List the weblog's templates
+|`/{h}/templates` |POST |Create a template
+|`/{h}/template/{id}` |GET |Read a template
+|`/{h}/template/{id}` |PUT |Change a template
+|`/{h}/template/{id}` |DELETE |Delete a template
+|===
+
+*Permission:* `ADMIN` on the weblog. The weblog must use a custom theme and
+`themes.customtheme.allowed` must be on.
+
+[cols="2,1,4",options="header"]
+|===
+|Element |Access |Meaning
+|`atom:title` |read/write |Name; required on `POST`; unique in the weblog; at
+most 255 characters
+|`atom:summary` |read/write |Description, at most 255 characters
+|`atom:content type="text"` |read/write |The standard template (Velocity)
+|`roller:rendition type="mobile"` |read/write
+|The mobile template. Missing on `PUT`: unchanged. Empty: removed, so mobile
+visitors see the standard template.
+|`roller:action` |read/write
+|`weblog`, `permalink`, `search`, `tagsindex`, `stylesheet` or `custom`.
+Set on `POST` only; `custom` when missing.
+|`roller:link` |read/write |The template's URL path; unique in the weblog; at
+most 255 characters
+|`roller:navbar` |read/write |`true` to list the template in the navigation bar
+|`roller:hidden` |read/write |`true` to hide the template from visitors
+|`roller:contentType` |read/write |Output media type; empty for automatic
+|`roller:required` |read |`true` for templates the theme needs
+|===
+
+Rules, the same as on the *Templates* pages:
+
+* Each action other than `custom` may have one template. Adding a second one, or
+a `stylesheet` template, gets `409 Conflict`.
+* A `weblog` template is always named `Weblog`.
+* The name, description, link, navigation bar and hidden settings change only
+for custom templates. For the others Roller changes only the template text and
+the content type.
+* Required templates (`Weblog` and `_day`) cannot be deleted (`409 Conflict`).
+
+Template text is Velocity markup. Send it as escaped text (`<div>`), not
+as XML elements, or use a CDATA section.
+
+.Creating a template (request)
+[source,xml]
+----
+include::atompub/examples/template-request.xml[]
+----
+
+.Template entry
+[source,xml]
+----
+include::atompub/examples/template-entry.xml[]
+----
+
+== Comments
+
+The comments collection is for comment moderation. It does not accept new
+comments.
+
+[cols="2,1,4",options="header"]
+|===
+|URI |Method |Action
+|`/{h}/comments`, `/{h}/comments/{offset}` |GET
+|List comments, newest first. Filter with `?status=` (`approved`,
+`disapproved`, `spam`, `pending`) and `?entry={entryId}`.
+|`/{h}/comments` |POST |Not allowed (`405 Method Not Allowed`)
+|`/{h}/comment/{id}` |GET |Read a comment
+|`/{h}/comment/{id}` |PUT |Change the comment's status
+|`/{h}/comment/{id}` |DELETE |Delete a comment
+|===
+
+*Permission:* `POST` on the weblog.
+
+[cols="2,1,4",options="header"]
+|===
+|Element |Access |Meaning
+|`roller:status` |read/write
+|`approved`, `disapproved`, `spam` or `pending`. The only element a `PUT` uses.
+|`atom:author` |read |The commenter's name, email address and web site
+|`atom:content` |read |The comment text
+|`atom:published` |read |When the comment was posted
+|`thr:in-reply-to` |read |The weblog entry: its id (`ref`) and edit URI
+(`href`)
+|`roller:remoteHost`, `roller:referrer` |read |Where the comment came from
+|===
+
+When a `pending` comment is approved, Roller sends the same notification email
+as the *Comments* page, if mail is configured.
+
+.Changing a comment's status (request)
+[source,xml]
+----
+include::atompub/examples/comment-status-request.xml[]
+----
+
+.Comments collection
+[source,xml]
+----
+include::atompub/examples/comments-feed.xml[]
+----
+
+== Errors
+
+Errors carry a short message. Server errors (`5xx`) carry no detail; the cause
+is in Roller's log.
+
+[cols="1,4",options="header"]
+|===
+|Status |Meaning
+|`400 Bad Request` |The entry is not well-formed, has a `DOCTYPE`, or has an
+invalid value (unknown action or status, invalid category name or image URL,
+a value longer than 255 characters, bad `moveTo`)
+|`401 Unauthorized` |Authentication failed, or the user lacks the permission the
+resource needs
+|`404 Not Found` |AtomPub is off, the resource does not exist or belongs to
+another weblog, or the collection is not available (for example templates of a
+shared theme)
+|`405 Method Not Allowed` |The method is not supported, for example `POST` to
+the comments collection
+|`409 Conflict` |The change conflicts with the weblog: duplicate name or link,
+a template action that is taken, a required template, a category in use or the
+last category
+|`413 Request Entity Too Large` |The entry is larger than
+`webservices.atomPubMaxEntrySize`
+|`415 Unsupported Media Type` |A `POST` without `Content-Type`, or media sent to
+a collection that accepts only entries
+|`500 Internal Server Error` |An unexpected error
+|===
+
+== Example session
+
+These `curl` commands use BASIC authentication. Replace the host, user and IDs
+with your own.
+
+[source,bash]
+----
+APP=https://example.com/roller/roller-services/app
+AUTH="dave:secret"
+
+# 1. Discover the weblogs and collections
+curl -u "$AUTH" "$APP/"
+
+# 2. Create an entry
+curl -u "$AUTH" -X POST "$APP/myblog/entries" \
+ -H "Content-Type: application/atom+xml;type=entry" \
+ --data-binary @entry-request.xml
+
+# 3. Upload an image
+curl -u "$AUTH" -X POST "$APP/myblog/resources/default" \
+ -H "Content-Type: image/jpeg" -H "Slug: sunset" \
+ --data-binary @sunset.jpg
+
+# 4. Create a category
+curl -u "$AUTH" -X POST "$APP/myblog/categories" \
+ -H "Content-Type: application/atom+xml;type=entry" \
+ --data-binary @category-request.xml
+
+# 5. Change a template's mobile version (edit URI from GET $APP/myblog/templates)
+curl -u "$AUTH" -X PUT "$APP/myblog/template/0e5d8b62" \
+ -H "Content-Type: application/atom+xml;type=entry" \
+ --data-binary @template-request.xml
+
+# 6. List pending comments, then approve one
+curl -u "$AUTH" "$APP/myblog/comments?status=pending"
+curl -u "$AUTH" -X PUT "$APP/myblog/comment/8f0e2a6c" \
+ -H "Content-Type: application/atom+xml;type=entry" \
+ --data-binary @comment-status-request.xml
+----
+
+== Schemas
+
+* Atom entries and feeds: the RELAX NG grammar in
+https://www.rfc-editor.org/rfc/rfc4287#appendix-B[RFC 4287, Appendix B].
+* Service and category documents: the grammar in
+https://www.rfc-editor.org/rfc/rfc5023#appendix-B[RFC 5023, Appendix B].
+* Roller's extension elements and `thr:in-reply-to`: `atompub/roller-atompub.rnc`
+in Roller's `docs` directory:
++
+[source]
+----
+include::atompub/roller-atompub.rnc[lines=17..-1]
+----
diff --git a/docs/roller-user-guide.adoc b/docs/roller-user-guide.adoc
index 87bdd7942..cb35bbb90 100644
--- a/docs/roller-user-guide.adoc
+++ b/docs/roller-user-guide.adoc
@@ -632,45 +632,9 @@ URL is _http://jroller.com/fredsblog_), then your parameters would be:
==== Managing templates, categories and comments with AtomPub
-Roller's AtomPub service (`/roller-services/app`) also lets a client manage
-a weblog's templates, categories and comments. The service document lists
-these collections in each weblog's workspace when you may use them. Roller's
-own elements use the namespace `http://roller.apache.org/ns/atompub/1.0`
-(prefix `roller` below).
-
-[cols="2,3,4",options="header"]
-|===
-|URI (under `/roller-services/app`) |Who may use it |Methods
-|`/{handle}/templates`, `/{handle}/template/{id}`
-|Weblog administrators, when the weblog uses a custom theme and the site
-allows custom themes
-|GET, POST, PUT, DELETE
-|`/{handle}/categories`, `/{handle}/category/{id}`
-|Users who may post to the weblog
-|GET, POST, PUT, DELETE (`?moveTo={id}` for a category in use)
-|`/{handle}/categories.atomcat`
-|Users who may post to the weblog
-|GET (an RFC 5023 category document)
-|`/{handle}/comments`, `/{handle}/comment/{id}`
-|Users who may post to the weblog
-|GET (`?status=`, `?entry=`), PUT (status only), DELETE
-|===
-
-* *Templates*: `atom:title` is the name, `atom:summary` the description and
-`atom:content type="text"` the standard template. `roller:rendition
-type="mobile"` holds the mobile template. `roller:action`, `roller:link`,
-`roller:navbar`, `roller:hidden` and `roller:contentType` hold the other
-settings; only custom templates can change their name, link and flags. A
-PUT without a mobile rendition leaves the mobile template as it is; an
-empty one removes it, so mobile visitors see the standard template.
-Escape template markup as text.
-* *Categories*: `atom:title` is the name, `atom:summary` the description and
-`roller:image` the image URL. Deleting a category that entries use answers
-409 unless `moveTo` names the category to move them to.
-* *Comments*: `roller:status` is `approved`, `disapproved`, `spam` or
-`pending`; a PUT changes only the status. `thr:in-reply-to` (RFC 4685)
-points to the weblog entry. Approving a pending comment sends the same
-notification as the Comments page.
+Roller's AtomPub service can also manage a weblog's templates, categories and
+comments. The AtomPub Guide (`roller-atompub-guide`) documents every URI,
+permission and XML element, with examples.
== Working with comments
diff --git a/pom.xml b/pom.xml
index 076721c56..396e9de79 100644
--- a/pom.xml
+++ b/pom.xml
@@ -111,6 +111,8 @@ limitations under the License.
**/*.adoc
/docs/examples/plugins/pluginmodel/README.html
/docs/examples/configs/wbloggar/wbloggar.txt
+
+ /docs/atompub/examples/**
/.asf.yaml
/testing/roller-load-test.jmx