From 5981fac06aa2b986eab694f18c737f16b02d12fd Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 15:32:04 -0400 Subject: [PATCH 1/2] Add an AtomPub reference guide docs/roller-atompub-guide.adoc documents Roller's whole AtomPub interface: enabling and limits, BASIC and OAuth authentication, the service document, and the entries, media, category, template and comment resources (URIs, permissions, elements, status codes), with a curl session and an error table. It is built to PDF and shipped with the other guides. - docs/atompub/examples: XML examples included by the guide. Response examples are generated from AtomWriter by AtomPubDocExamplesTest, which fails when they no longer match (-Datompub.docs.update=true rewrites them). Request examples are hand-written and must parse. - docs/atompub/roller-atompub.rnc: RELAX NG grammar for the roller:* and thr:in-reply-to elements; the test validates every example against it and against the RFC 4287/5023 grammars. - Move the Jing helper into RelaxNgValidator for both schema tests. --- CHANGES.md | 4 + .../atomprotocol/AtomPubDocExamplesTest.java | 487 +++++++++++++++++ .../AtomSchemaValidationTest.java | 29 +- .../atomprotocol/RelaxNgValidator.java | 72 +++ assembly-release/src/main/assembly/binary.xml | 4 + docs/README.md | 1 + docs/atompub/examples/README.txt | 4 + docs/atompub/examples/categories.xml | 6 + docs/atompub/examples/category-entry.xml | 14 + docs/atompub/examples/category-request.xml | 11 + docs/atompub/examples/comment-entry.xml | 21 + .../examples/comment-status-request.xml | 10 + docs/atompub/examples/comments-feed.xml | 27 + docs/atompub/examples/entries-feed.xml | 27 + docs/atompub/examples/entry-request.xml | 15 + docs/atompub/examples/entry-response.xml | 20 + docs/atompub/examples/media-link-entry.xml | 14 + docs/atompub/examples/service.xml | 34 ++ docs/atompub/examples/template-entry.xml | 19 + docs/atompub/examples/template-request.xml | 14 + docs/atompub/roller-atompub.rnc | 58 ++ docs/roller-atompub-guide.adoc | 505 ++++++++++++++++++ docs/roller-user-guide.adoc | 42 +- pom.xml | 2 + 24 files changed, 1373 insertions(+), 67 deletions(-) create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java create mode 100644 docs/atompub/examples/README.txt create mode 100644 docs/atompub/examples/categories.xml create mode 100644 docs/atompub/examples/category-entry.xml create mode 100644 docs/atompub/examples/category-request.xml create mode 100644 docs/atompub/examples/comment-entry.xml create mode 100644 docs/atompub/examples/comment-status-request.xml create mode 100644 docs/atompub/examples/comments-feed.xml create mode 100644 docs/atompub/examples/entries-feed.xml create mode 100644 docs/atompub/examples/entry-request.xml create mode 100644 docs/atompub/examples/entry-response.xml create mode 100644 docs/atompub/examples/media-link-entry.xml create mode 100644 docs/atompub/examples/service.xml create mode 100644 docs/atompub/examples/template-entry.xml create mode 100644 docs/atompub/examples/template-request.xml create mode 100644 docs/atompub/roller-atompub.rnc create mode 100644 docs/roller-atompub-guide.adoc diff --git a/CHANGES.md b/CHANGES.md index 2077b26dcc..26460a850a 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 0000000000..796f3a1740 --- /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. + * + * + */ +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", + "
$model.weblog.name
")); + entry.setMobileRendition("
$model.weblog.name
"); + 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("\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("\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("'); + 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 3d4ca17c66..c51f3cdde8 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 0000000000..4d8968ef07 --- /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 ae75c95c5f..85ea353fb1 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 6991392fa9..6fb2052a2b 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 0000000000..bbca405017 --- /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 0000000000..67ef325423 --- /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 0000000000..f3cfac6c0d --- /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 0000000000..3f1b86ccb7 --- /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 0000000000..e169b70948 --- /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 0000000000..d74140ef2d --- /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 0000000000..4b4e5ba4eb --- /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 0000000000..a9211a9aae --- /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 0000000000..4fb2779ac3 --- /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 0000000000..b513e743c7 --- /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 0000000000..5427acd06d --- /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 0000000000..f14322575b --- /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 0000000000..b340626fec --- /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 0000000000..5ba17d71eb --- /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 0000000000..2c7bb6f5e6 --- /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 0000000000..1d9cdcf83a --- /dev/null +++ b/docs/roller-atompub-guide.adoc @@ -0,0 +1,505 @@ += 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`. It must be unique in the weblog and may not contain +`<`, `>`, `&` or `"`. +|`atom:summary` |read/write |Description +|`roller:image` |read/write |Image URL; 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 +|`atom:summary` |read/write |Description +|`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 +|`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, 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 87bdd7942b..cb35bbb901 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 076721c560..396e9de79d 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 From cbef7eef370a48d3f271bca51a7eb05c472b649b Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 15:50:22 -0400 Subject: [PATCH 2/2] Document the category image and length rules in the AtomPub guide --- docs/roller-atompub-guide.adoc | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/roller-atompub-guide.adoc b/docs/roller-atompub-guide.adoc index 1d9cdcf83a..bc7a042846 100644 --- a/docs/roller-atompub-guide.adoc +++ b/docs/roller-atompub-guide.adoc @@ -284,10 +284,10 @@ include::atompub/examples/categories.xml[] |=== |Element |Access |Meaning |`atom:title` |read/write -|Name; required on `POST`. It must be unique in the weblog and may not contain -`<`, `>`, `&` or `"`. -|`atom:summary` |read/write |Description -|`roller:image` |read/write |Image URL; empty removes it +|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 @@ -335,8 +335,9 @@ its URIs answer `404 Not Found`, even for administrators. [cols="2,1,4",options="header"] |=== |Element |Access |Meaning -|`atom:title` |read/write |Name; required on `POST`; unique in the weblog -|`atom:summary` |read/write |Description +|`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 @@ -344,7 +345,8 @@ 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 +|`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 @@ -432,7 +434,8 @@ is in Roller's log. |=== |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, bad `moveTo`) +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