From 9eba1ecfad04adb36d6ac64f4888819cc1346948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Schiettecatte?= Date: Tue, 27 Oct 2020 19:16:44 -0700 Subject: [PATCH] Added support for JSON Feed version 1.1 --- build.gradle | 2 +- .../java/software/tinlion/pertwee/Feed.java | 91 ++++++++--------- .../java/software/tinlion/pertwee/Item.java | 3 + .../tinlion/pertwee/feed/DefaultFeed.java | 99 +++++++++++-------- .../tinlion/pertwee/feed/DefaultItem.java | 75 +++++++++----- .../software/tinlion/pertwee/FeedTest.java | 97 +++++++++++------- .../software/tinlion/pertwee/ItemTest.java | 75 +++++++++----- 7 files changed, 274 insertions(+), 168 deletions(-) diff --git a/build.gradle b/build.gradle index b2392bd..1a01665 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ plugins { } sourceCompatibility = 1.8 -version = '1.0.5' +version = '1.1.0' jar { manifest { diff --git a/src/main/java/software/tinlion/pertwee/Feed.java b/src/main/java/software/tinlion/pertwee/Feed.java index ec351e2..fff2a3c 100644 --- a/src/main/java/software/tinlion/pertwee/Feed.java +++ b/src/main/java/software/tinlion/pertwee/Feed.java @@ -1,5 +1,5 @@ /** - * + * */ package software.tinlion.pertwee; @@ -10,26 +10,26 @@ import software.tinlion.pertwee.feed.DefaultFeed; /** - * The main interface for the Pertwee JSON Feed parser. See + * The main interface for the Pertwee JSON Feed parser. See * the JSON Feed spec. - * + * * To get an instance use one of the factory methods in {@link DefaultFeed}. - * - * Once you have an instance you can use the various methods to get the + * + * Once you have an instance you can use the various methods to get the * various elements of the feed. - * - * This implementation makes a fairly strict interpretation of the spec. - * Where an element is marked as "required", such as - * version, for example, a {@code RequiredElementNotPresentException} - * will be thrown. This is a RuntimeException, but methods for handling + * + * This implementation makes a fairly strict interpretation of the spec. + * Where an element is marked as "required", such as + * version, for example, a {@code RequiredElementNotPresentException} + * will be thrown. This is a RuntimeException, but methods for handling * required elements declare it thrown, to allow client classes to decide * how to handle it. - * - * Most of the methods here simply return the comparably-named element from - * the provided JSON (for example, feedUrl returns the element called - * "feed_url"). See the spec for detailed explanations of what each + * + * Most of the methods here simply return the comparably-named element from + * the provided JSON (for example, feedUrl returns the element called + * "feed_url"). See the spec for detailed explanations of what each * element means. - * + * * @author Martin McCallion (martin@devilgate.org) * @version 1.0.1 * @@ -38,87 +38,90 @@ public interface Feed { /** * The version is required. - * + * * @return the version as a string * @throws RequiredElementNotPresentException for obvious reasons */ String version() throws RequiredElementNotPresentException; - - /** + + /** * Title is required. - * + * * @return the title * @throws RequiredElementNotPresentException for obvious reasons */ String title() throws RequiredElementNotPresentException; - + /** * This is described as "optional but strongly recommended." - * + * * @return the URL */ String homePageUrl(); - + /** * This is described as "optional but strongly recommended." - * + * * @return the URL */ String feedUrl(); - + String description(); - + + String language(); + String userComment(); - + String nextUrl(); - + /** * A convenience method to get the contents of nextUrl as a Feed. - * + * * @return the next feed * @throws IOException on problems */ Feed nextFeed() throws IOException; - + String icon(); - + String favicon(); - + /** * Optional, but if present certain elements within it must be present. - * + * * @return The author details of the feed; see {@link Author} - * @throws RequiredElementNotPresentException if the contents of the + * @throws RequiredElementNotPresentException if the contents of the * "author" element do not meet their requirements - * + * */ Author author() throws RequiredElementNotPresentException; - + List authors() throws RequiredElementNotPresentException; + boolean hasExpired(); - + List items() throws RequiredElementNotPresentException; - + /** * Returns the next {@code Item} from the feed. - * + * * @return the Item */ Item nextItem(); - + /** * Tells us whether the feed has another item. - * + * * @return true if there is a next Item */ boolean hasNextItem(); - + List hubs(); - + boolean hasExtensions(); - + /** * Prints the feed; - * + * * @return the feed as a string */ public String print(); diff --git a/src/main/java/software/tinlion/pertwee/Item.java b/src/main/java/software/tinlion/pertwee/Item.java index b8dbf19..a9d5aac 100644 --- a/src/main/java/software/tinlion/pertwee/Item.java +++ b/src/main/java/software/tinlion/pertwee/Item.java @@ -32,9 +32,12 @@ public interface Item { public String dateModified(); public Author author(); + List authors() throws RequiredElementNotPresentException; public List tags(); + public String language(); + boolean hasAttachments(); List attachments(); diff --git a/src/main/java/software/tinlion/pertwee/feed/DefaultFeed.java b/src/main/java/software/tinlion/pertwee/feed/DefaultFeed.java index 8a38829..8c050b8 100644 --- a/src/main/java/software/tinlion/pertwee/feed/DefaultFeed.java +++ b/src/main/java/software/tinlion/pertwee/feed/DefaultFeed.java @@ -20,24 +20,24 @@ import software.tinlion.pertwee.exception.RequiredElementNotPresentException; public class DefaultFeed implements Feed { - + private JSONObject feedObject; private GetIfPresent feedGet; private List itemsInFeed; private int itemsIndex; public static Feed fromString(final String jsonString) throws IOException { - + return new DefaultFeed(jsonString); } - + public static Feed fromUrl(URL url) throws IOException { - + return new DefaultFeed(url); } - + private DefaultFeed(final Reader reader) throws IOException { - + char[] buffer = new char[4096]; int numChars; StringBuilder builder = new StringBuilder(); @@ -49,36 +49,36 @@ private DefaultFeed(final Reader reader) throws IOException { feedObject = new JSONObject(builder.toString()); feedGet = new GetIfPresent(feedObject); } - + private DefaultFeed(final URL url) throws IOException { - + this(new InputStreamReader(url.openStream())); } - + private DefaultFeed(final String jsonString) throws IOException { - + this(new StringReader(jsonString)); } @Override public boolean hasNextItem() { - + // Initialise first time through if (itemsInFeed == null) { itemsInFeed = items(); itemsIndex = 0; if (itemsInFeed == null) { - + // Not sure if this is possible, but maybe if there are no items return false; } } return itemsInFeed.size() > 0 && itemsIndex < itemsInFeed.size(); } - + @Override public Item nextItem() { - + // Initialise first time through if (itemsInFeed == null) { itemsInFeed = items(); @@ -95,19 +95,19 @@ public String version() throws RequiredElementNotPresentException { @Override public String title() throws RequiredElementNotPresentException { - + return feedGet.getString("title", true); } @Override public String homePageUrl() { - + return feedGet.getString("home_page_url", false); } @Override public String feedUrl() { - + return feedGet.getString("feed_url", false); } @@ -117,75 +117,92 @@ public String description() { return feedGet.getString("description", false); } + @Override + public String language() { + + return feedGet.getString("language", false); + } + @Override public String userComment() { - + return feedGet.getString("user_comment", false); } @Override public String nextUrl() { - + return feedGet.getString("next_url", false); } @Override public Feed nextFeed() throws IOException { - + return DefaultFeed.fromString(nextUrl()); } @Override public String icon() { - + return feedGet.getString("icon", false); } @Override public String favicon() { - + return feedGet.getString("favicon", false); } @Override public Author author() throws RequiredElementNotPresentException { - - + if (feedObject.optJSONObject("author") != null) { return FeedAuthor.fromJson(feedObject.getJSONObject("author")); - + } else { return FeedAuthor.nullAuthor(); } } + @Override + public List authors() throws RequiredElementNotPresentException { + + List authors = new ArrayList<>(); + if (feedObject.optJSONArray("authors") != null) { + for (Object val : feedObject.optJSONArray("authors")) { + authors.add(FeedAuthor.fromJson((JSONObject) val)); + } + } + return authors; + } + @Override public boolean hasExpired() { - + return feedObject.getBoolean("expired"); } @Override public List items() throws RequiredElementNotPresentException { - + if (feedObject.optJSONArray("items") == null) { - + throw new RequiredElementNotPresentException("Element 'items' is" + " required, but was not found in the feed."); } List items = new ArrayList<>(); for (Object val : feedObject.getJSONArray("items")) { - - items.add(DefaultItem.parseItem((JSONObject) val, author())); + + items.add(DefaultItem.parseItem((JSONObject) val, author(), authors())); } return items; } @Override public List hubs() { - + if (feedObject.optJSONArray("hubs") != null) { - + return SubHub.parseHubsFromJson(feedObject.getJSONArray("hubs")); } return null; @@ -194,7 +211,7 @@ public List hubs() { @Override public boolean hasExtensions() { Iterator iterator = feedObject.keys(); - + while (iterator.hasNext()) { if (iterator.next().startsWith("_")) { return true; @@ -205,7 +222,7 @@ public boolean hasExtensions() { } public String print() { - + StringBuilder output = new StringBuilder(); output .append(version()).append("\n") @@ -214,21 +231,25 @@ public String print() { .append(feedUrl()).append("\n") .append(description()).append("\n") .append(userComment()).append("\n") + .append(language()).append("\n") .append(nextUrl()).append("\n") .append(icon()).append("\n") .append(favicon()).append("\n") .append(author()).append("\n").append("\n") + .append(authors()).append("\n").append("\n") ; - + for (Item i : items()) { - + output.append(i.title()).append("\n") .append(i.id()).append("\n") .append(i.dateModified()).append("\n") .append(i.datePublished()).append("\n") .append(i.summary()).append("\n") .append(i.author()).append("\n") + .append(i.authors()).append("\n") .append(i.url()).append("\n") + .append(i.language()).append("\n") .append(i.contentHtml()).append("\n") .append(i.contentText()).append("\n"); if (!i.tags().isEmpty()) { @@ -240,9 +261,9 @@ public String print() { output.append("]\n"); } } - - // TODO: more - + + // TODO: more + return output.toString(); } diff --git a/src/main/java/software/tinlion/pertwee/feed/DefaultItem.java b/src/main/java/software/tinlion/pertwee/feed/DefaultItem.java index 513a412..248801b 100644 --- a/src/main/java/software/tinlion/pertwee/feed/DefaultItem.java +++ b/src/main/java/software/tinlion/pertwee/feed/DefaultItem.java @@ -1,6 +1,7 @@ package software.tinlion.pertwee.feed; import java.util.List; +import java.util.ArrayList; import org.json.JSONObject; @@ -11,92 +12,94 @@ import software.tinlion.pertwee.exception.RequiredElementNotPresentException; public class DefaultItem implements Item { - + private JSONObject itemObject; private GetIfPresent feedGet; private final Author feedAuthor; - - public static Item parseItem(JSONObject value, Author feedAuthor) { - - return new DefaultItem(value, feedAuthor); - } - - private DefaultItem(JSONObject value, Author feedAuthor) { - + private final List feedAuthors; + + public static Item parseItem(JSONObject value, Author feedAuthor, List feedAuthors) { + + return new DefaultItem(value, feedAuthor, feedAuthors); + } + + private DefaultItem(JSONObject value, Author feedAuthor, List feedAuthors) { + if (!(value instanceof JSONObject)) { - + throw new IllegalStateException("Received a JsonValue which " + "is not a JsonObject. Value is " + value); } - + itemObject = (JSONObject)value; this.feedAuthor = feedAuthor; + this.feedAuthors = feedAuthors; feedGet = new GetIfPresent(itemObject); } @Override public String id() throws RequiredElementNotPresentException { - + return feedGet.getString("id", true); } @Override public String contentText() { - + return feedGet.getString("content_text", false); } @Override public String contentHtml() { - + return feedGet.getString("content_html", false); } @Override public String url() { - + return feedGet.getString("url", false); } @Override public String externalUrl() { - + return feedGet.getString("external_url", false); } @Override public String title() { - + return feedGet.getString("title", false); } @Override public String summary() { - + return feedGet.getString("summary", false); } @Override public String image() { - + return feedGet.getString("image", false); } @Override public String bannerImage() { - + return feedGet.getString("banner_image", false); } @Override public String datePublished() { - + return feedGet.getString("date_published", false); } @Override public String dateModified() { - + return feedGet.getString("date_modified", false); } @@ -106,20 +109,42 @@ public Author author() { if (itemObject.optJSONObject("author") != null) { return FeedAuthor.fromJson(itemObject.getJSONObject("author")); } else { - return feedAuthor; } } + + @Override + public List authors() { + + if (itemObject.optJSONArray("authors") != null) { + List authors = new ArrayList<>(); + for (Object val : itemObject.getJSONArray("authors")) { + authors.add(FeedAuthor.fromJson((JSONObject) val)); + } + return authors; + } else { + return feedAuthors; + } + } + + + @Override public List tags() { - + return feedGet.getStringList("tags"); } + @Override + public String language() { + + return feedGet.getString("language", false); + } + @Override public String toString() { - + return itemObject.toString(); } diff --git a/src/test/java/software/tinlion/pertwee/FeedTest.java b/src/test/java/software/tinlion/pertwee/FeedTest.java index 538075c..8242fb5 100644 --- a/src/test/java/software/tinlion/pertwee/FeedTest.java +++ b/src/test/java/software/tinlion/pertwee/FeedTest.java @@ -12,81 +12,110 @@ import software.tinlion.pertwee.feed.DefaultFeed; public class FeedTest { - - private static final String SIMPLE_EXAMPLE = "{" + - "\"version\": \"https://jsonfeed.org/version/1\"," + - "\"title\": \"My Example Feed\"," + - "\"home_page_url\": \"https://example.org/\"," + + + private static final String SIMPLE_EXAMPLE = "{" + + "\"version\": \"https://jsonfeed.org/version/1.1\"," + + "\"title\": \"My Example Feed\"," + + "\"home_page_url\": \"https://example.org/\"," + "\"feed_url\": \"https://example.org/feed.json\"," + "\"author\": " + "{\"name\": \"Martin McCallion\"}," + - "\"items\": [ "+ - "{" + - "\"id\": \"2\"," + - "\"content_text\": \"This is a second item.\"," + - "\"url\": \"https://example.org/second-item\" " + - "}, " + - "{ "+ - "\"id\": \"1\", " + - "\"content_html\": \"

Hello, world!

\", " + - "\"url\": \"https://example.org/initial-post\" " + - "} " + - "] " + + "\"authors\": " + + "[ {\"name\": \"Silurians\"}, {\"name\": \"Daleks\"} ]," + + "\"language\": \"en-GB\"," + + "\"items\": [ "+ + "{" + + "\"id\": \"2\"," + + "\"content_text\": \"This is a second item.\"," + + "\"url\": \"https://example.org/second-item\"," + + "\"language\": \"en-IE\"," + + "}, " + + "{ "+ + "\"id\": \"1\", " + + "\"content_html\": \"

Hello, world!

\", " + + "\"url\": \"https://example.org/initial-post\"," + + "\"language\": \"en-US\"," + + "} " + + "] " + "}"; private Feed SIMPLE_FEED; - + @Before public void setup() throws IOException { - + SIMPLE_FEED = DefaultFeed.fromString(SIMPLE_EXAMPLE); - + } @Test public void testVersionInitialised() { - - assertEquals("https://jsonfeed.org/version/1", SIMPLE_FEED.version()); + + assertEquals("https://jsonfeed.org/version/1.1", SIMPLE_FEED.version()); } - + @Test public void testTitleInitialised() { - + assertEquals("My Example Feed", SIMPLE_FEED.title()); } - + @Test public void testHomePageInitialised() { - + assertEquals("https://example.org/", SIMPLE_FEED.homePageUrl()); } - + @Test public void testHFeedUrlInitialised() { - + assertEquals("https://example.org/feed.json", SIMPLE_FEED.feedUrl()); } - + + @Test + public void testLanguageInitialised() { + + assertEquals("en-GB", SIMPLE_FEED.language()); + } + + @Test + public void authorsArrayLengthIs2() { + + assertEquals(2, SIMPLE_FEED.authors().size()); + } + + @Test + public void authorsAreCorrect() { + + Author a = SIMPLE_FEED.authors().get(0); + assertEquals("Silurians", a.name()); + + Author a2 = SIMPLE_FEED.authors().get(1); + assertEquals("Daleks", a2.name()); + } + @Test public void itemsArrayLengthIs2() { - + assertEquals(2, SIMPLE_FEED.items().size()); } - + @Test public void itemsAreCorrect() { - + assertTrue(SIMPLE_FEED.hasNextItem()); Item i = SIMPLE_FEED.nextItem(); assertEquals("2", i.id()); assertEquals("This is a second item.", i.contentText()); - + assertEquals("en-IE", i.language()); + assertTrue(SIMPLE_FEED.hasNextItem()); Item i2 = SIMPLE_FEED.nextItem(); assertEquals("1", i2.id()); assertEquals("

Hello, world!

", i2.contentHtml()); assertEquals("https://example.org/initial-post", i2.url()); - + assertEquals("en-US", i2.language()); + assertFalse(SIMPLE_FEED.hasNextItem()); } diff --git a/src/test/java/software/tinlion/pertwee/ItemTest.java b/src/test/java/software/tinlion/pertwee/ItemTest.java index 6a5d474..28e0f4f 100644 --- a/src/test/java/software/tinlion/pertwee/ItemTest.java +++ b/src/test/java/software/tinlion/pertwee/ItemTest.java @@ -15,7 +15,7 @@ import software.tinlion.pertwee.feed.FeedAuthor; public class ItemTest { - + private static final String TAG3 = "tag3"; private static final String TAG2 = "tag2"; @@ -30,130 +30,155 @@ public class ItemTest { private static final String THE_URL = "https://example.org/second-item"; + private static final String LANGUAGE = "en-GB"; + private static final String HTML_THIS_IS_A_SECOND_ITEM = "

This is a second item.

"; private static final String THIS_IS_A_SECOND_ITEM = "This is a second item."; // UnitOfWork_StateUnderTest_ExpectedBehavior - + private Item item; private Item itemWithAuthor; + private Item itemWithAuthors; private Author feedAuthor; @Before public void setUp() throws Exception { - + JSONObject objFeedAuthor = new JSONObject(); objFeedAuthor.put("name", "Martin McCallion"); objFeedAuthor.put("url", "http://devilgate.org/blog/"); objFeedAuthor.put("avatar", "http://devilgate.org/pic.jpg"); feedAuthor = FeedAuthor.fromJson(objFeedAuthor); - + JSONObject objItem = new JSONObject(); objItem.put("id", "001"); objItem.put("content_text", THIS_IS_A_SECOND_ITEM); objItem.put("content_html", HTML_THIS_IS_A_SECOND_ITEM); objItem.put("url", THE_URL); + objItem.put("language", LANGUAGE); objItem.put("external_url", ""); objItem.put("summary", NOT_MUCH); objItem.put("date_modified", AN_ARBITRARY_TIME); - item = DefaultItem.parseItem(objItem, feedAuthor); - + item = DefaultItem.parseItem(objItem, feedAuthor, null); + // Second item is first but with its own Author and tags JSONObject objItem2 = new JSONObject(objItem); JSONObject objAuthor = new JSONObject(); objAuthor.put("name", MR_BRIGHTSIDE); objItem2.put("author", objAuthor); objItem2.put("tags", new JSONArray(Arrays.asList(TAG1, TAG2, TAG3))); - itemWithAuthor = DefaultItem.parseItem(objItem2, feedAuthor); + itemWithAuthor = DefaultItem.parseItem(objItem2, feedAuthor, null); + + // Third item is first but with its own Author and tags + JSONObject objItem3 = new JSONObject(objItem); + JSONObject objAuthor3 = new JSONObject(); + objAuthor3.put("name", MR_BRIGHTSIDE); + JSONArray objAuthors = new JSONArray(new Object[] {objAuthor3}); + objItem3.put("authors", objAuthors); + objItem3.put("tags", new JSONArray(Arrays.asList(TAG1, TAG2, TAG3))); + itemWithAuthors = DefaultItem.parseItem(objItem3, feedAuthor, null); } @Test public final void id_created_returnsCorrectValue() { - + assertEquals("001", item.id()); } @Test public final void contentText_created_returnsCorrectValue() { - + assertEquals(THIS_IS_A_SECOND_ITEM, item.contentText()); } @Test public final void contentHtml_created_returnsCorrectValue() { - + assertEquals(HTML_THIS_IS_A_SECOND_ITEM, item.contentHtml()); } @Test public final void url_created_returnsCorrectValue() { - + assertEquals(THE_URL, item.url()); } + @Test + public final void language_created_returnsCorrectValue() { + + assertEquals(LANGUAGE, item.language()); + } + @Test public final void externalUrl_created_returnsEmptyString() { - + assertEquals("", item.externalUrl()); } @Test public final void title_notSet_returnsEmptyString() { - + assertEquals("", item.title()); } @Test public final void summary_created_returnsCorrectValue() { - + assertEquals(NOT_MUCH, item.summary()); } @Test public final void image_notSet_returnsEmptyString() { - + assertEquals("", item.image()); } @Test public final void bannerImage_notSet_returnsEmptyString() { - + assertEquals("", item.bannerImage()); } @Test public final void datePublished_notSet_returnsEmptyString() { - + assertEquals("", item.datePublished()); } @Test public final void DateModified_created_returnsCorrectValue() { - + assertEquals(AN_ARBITRARY_TIME, item.dateModified()); } @Test public final void author_notSetInItem_returnsFeedAuthor() { - + assertEquals(feedAuthor, item.author()); } - + @Test public final void author_setInItem_returnsItemAuthor() { - + assertEquals(MR_BRIGHTSIDE, itemWithAuthor.author().name()); } + @Test + public final void author_setInItem_returnsItemAuthors() { + + assertEquals(MR_BRIGHTSIDE, itemWithAuthors.authors().get(0).name()); + } + @Test public final void tags_notSet_returnsEmptyList() { - + assertTrue(item.tags().isEmpty()); } - - @Test + + @Test public final void tags_set_returnsListOfStrings() { - + List tags = itemWithAuthor.tags(); assertEquals(TAG1, tags.get(0)); assertEquals(TAG2, tags.get(1));