diff --git a/src/main/java/com/github/jasminb/jsonapi/JSONAPISpecConstants.java b/src/main/java/com/github/jasminb/jsonapi/JSONAPISpecConstants.java index ee42f13..7c0d4e0 100644 --- a/src/main/java/com/github/jasminb/jsonapi/JSONAPISpecConstants.java +++ b/src/main/java/com/github/jasminb/jsonapi/JSONAPISpecConstants.java @@ -18,4 +18,8 @@ public interface JSONAPISpecConstants { String ERRORS = "errors"; String META = "meta"; String HREF = "href"; + String PREV = "prev"; + String NEXT = "next"; + String FIRST = "first"; + String LAST = "last"; } diff --git a/src/main/java/com/github/jasminb/jsonapi/Link.java b/src/main/java/com/github/jasminb/jsonapi/Link.java new file mode 100644 index 0000000..c0217ae --- /dev/null +++ b/src/main/java/com/github/jasminb/jsonapi/Link.java @@ -0,0 +1,44 @@ +package com.github.jasminb.jsonapi; + +import java.util.Collections; +import java.util.Map; + +/** + * Models a JSON API Link object. + */ +public class Link { + + private String href; + + private Map meta = Collections.emptyMap(); + + public Link() { + + } + + public Link(String href) { + this.href = href; + } + + public Link(String href, Map meta) { + this.href = href; + this.meta = meta; + } + + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + + public Map getMeta() { + return meta; + } + + public void setMeta(Map meta) { + this.meta = meta; + } + +} diff --git a/src/main/java/com/github/jasminb/jsonapi/ResourceConverter.java b/src/main/java/com/github/jasminb/jsonapi/ResourceConverter.java index dfdab28..a89560b 100644 --- a/src/main/java/com/github/jasminb/jsonapi/ResourceConverter.java +++ b/src/main/java/com/github/jasminb/jsonapi/ResourceConverter.java @@ -1,11 +1,16 @@ package com.github.jasminb.jsonapi; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.type.MapType; +import com.fasterxml.jackson.databind.type.TypeFactory; import com.github.jasminb.jsonapi.annotations.Id; import com.github.jasminb.jsonapi.annotations.Meta; import com.github.jasminb.jsonapi.annotations.Relationship; @@ -198,7 +203,7 @@ public T readObject(byte [] data, Class clazz) { * @return collection of converted elements * @throws RuntimeException in case conversion fails */ - public List readObjectCollection(byte [] data, Class clazz) { + public ResourceList readObjectCollection(byte [] data, Class clazz) { try { JsonNode rootNode = objectMapper.readTree(data); @@ -216,14 +221,25 @@ public List readObjectCollection(byte [] data, Class clazz) { result.add(pojo); } - return result; + ResourceList wrapper = new ResourceList<>(result); + + if (rootNode.has(LINKS)) { + Map links = mapLinks(rootNode.get(LINKS)); + wrapper.setLinks(links); + } + + if (rootNode.has(META)) { + Map meta = mapMeta(rootNode.get(META)); + wrapper.setMeta(meta); + } + + return wrapper; } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException(e); } - } /** @@ -394,6 +410,62 @@ private void handleRelationships(JsonNode source, Object object, MapJSON-API links object to a {@code Map} + * keyed by the link name. + *

+ * The {@code linksObject} may represent links in string form or object form; both are supported by this method. + *

+ *

+ * E.g. + *

+	 * "links": {
+	 *   "self": "http://example.com/posts"
+	 * }
+	 * 
+ *

+ *

+ * or + *

+	 * "links": {
+	 *   "related": {
+	 *     "href": "http://example.com/articles/1/comments",
+	 *     "meta": {
+	 *       "count": 10
+	 *     }
+	 *   }
+	 * }
+	 * 
+ *

+ * + * @param linksObject a {@code JsonNode} representing a links object + * @return a {@code Map} keyed by link name + */ + private Map mapLinks(JsonNode linksObject) { + Map result = new HashMap<>(); + + Iterator> linkItr = linksObject.fields(); + + while (linkItr.hasNext()) { + Map.Entry linkNode = linkItr.next(); + Link linkObj = new Link(); + + linkObj.setHref( + getLink( + linkNode.getValue())); + + if (linkNode.getValue().has(META)) { + linkObj.setMeta( + mapMeta( + linkNode.getValue().get(META))); + } + + result.put(linkNode.getKey(), linkObj); + } + + return result; + } + /** * Accepts a JsonNode which encapsulates a link. The link may be represented as a simple string or as * link object. This method introspects on the @@ -413,6 +485,27 @@ private String getLink(JsonNode linkNode) { return linkNode.asText(); } + /** + * Deserializes a JSON-API meta object to a {@code Map} + * keyed by the member names. Because {@code meta} objects contain arbitrary information, the values in the + * map are of unknown type. + * + * @param metaNode a JsonNode representing a meta object + * @return a Map of the meta information, keyed by member name. + */ + private Map mapMeta(JsonNode metaNode) { + JsonParser p = objectMapper.treeAsTokens(metaNode); + MapType mapType = TypeFactory.defaultInstance() + .constructMapType(HashMap.class, String.class, Object.class); + try { + return objectMapper.readValue(p, mapType); + } catch (IOException e) { + // TODO: log? No recovery. + } + + return null; + } + /** * Creates relationship object by consuming provided 'data' node. * @param relationshipDataNode relationship data node diff --git a/src/main/java/com/github/jasminb/jsonapi/ResourceList.java b/src/main/java/com/github/jasminb/jsonapi/ResourceList.java new file mode 100644 index 0000000..24226dd --- /dev/null +++ b/src/main/java/com/github/jasminb/jsonapi/ResourceList.java @@ -0,0 +1,292 @@ +package com.github.jasminb.jsonapi; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; + +import static com.github.jasminb.jsonapi.JSONAPISpecConstants.FIRST; +import static com.github.jasminb.jsonapi.JSONAPISpecConstants.LAST; +import static com.github.jasminb.jsonapi.JSONAPISpecConstants.NEXT; +import static com.github.jasminb.jsonapi.JSONAPISpecConstants.PREV; +import static com.github.jasminb.jsonapi.JSONAPISpecConstants.RELATED; +import static com.github.jasminb.jsonapi.JSONAPISpecConstants.SELF; + +/** + * Encapsulates a JSON API response that includes a collection of resource objects, with optional links and meta + * objects. + *

+ * JSON API calls that return a collection of resource objects may be paginated or may contain meta information + * describing the response. This implementation exposes this information by providing access to a {@code Map} of + * {@link #getLinks() links}, and a {@code Map} of {@link #getMeta() meta information}. Convenience methods exist for + * common use cases like pagination links, and obtaining + * the {@link #getRelated() related} and {@link #getSelf() self} relation types. + *

+ *

+ * Implementation note: any {@code List} operations are forwarded to the enclosed + * {@link #ResourceList(List) resource list} supplied on construction. + *

+ */ +public class ResourceList implements List { + + /** + * A map of link objects keyed by link name. + */ + private Map links = Collections.emptyMap(); + + /** + * A map of meta fields, keyed by the meta field name + */ + private Map meta = Collections.emptyMap(); + + /** + * A list of resource objects, the primary data of JSON API response + */ + private List resources = Collections.emptyList(); + + /** + * Constructs a new resource list composed of the underlying list of resources. + * + * @param resources a list of resource objects representing the primary data in the JSON API response; + * must not be {@code null} + */ + public ResourceList(List resources) { + if (resources == null) { + throw new IllegalArgumentException("Resources list must not be null."); + } + this.resources = resources; + } + + /** + * Convenience method for returning the value of the {@code prev} link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getPrevious() { + return getLink(PREV); + } + + /** + * Convenience method for returning the value of the {@code first} link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getFirst() { + return getLink(FIRST); + } + + /** + * Convenience method for returning the value of the {@code next} link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getNext() { + return getLink(NEXT); + } + + /** + * Convenience method for returning the value of the {@code last} link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getLast() { + return getLink(LAST); + } + + /** + * Convenience method for returning the value of the {@code self} link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getSelf() { + return getLink(SELF); + } + + /** + * Convenience method for returning the value of the {@code related} link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getRelated() { + return getLink(RELATED); + } + + /** + * Returns the JSON API links present in the + * response. + * + * @return the links in the response keyed by link name; may be empty but never {@code null} + */ + public Map getLinks() { + return links; + } + + /** + * Convenience method for returning the value of the named link. + * + * @return the link value, or {@code null} if the named link does not exist or has no value + */ + public String getLink(String linkName) { + if (links.containsKey(linkName)) { + return links.get(linkName).getHref(); + } + + return null; + } + + /** + * Returns the JSON API meta information present in the + * response. Because meta information can contain arbitrary data, the values in the returned {@code Map} are of + * unknown type. + * + * @return the meta information in the response keyed by field name; may be empty but never {@code null} + */ + public Map getMeta() { + return meta; + } + + /** + * Package-private method for setting the meta information. + * + * @param meta the meta information, must not be {@code null} + */ + void setMeta(Map meta) { + if (meta == null) { + throw new IllegalArgumentException("Meta information must not be null."); + } + this.meta = meta; + } + + /** + * Package-private method for setting link information. + * + * @param links the links information, must not be {@code null} + */ + void setLinks(Map links) { + if (links == null) { + throw new IllegalArgumentException("Links map must not be null."); + } + this.links = links; + } + + /* + * List implementation: all methods for the List interface forward to the member {@link #resources resources list}. + */ + + @Override + public int size() { + return resources.size(); + } + + @Override + public boolean isEmpty() { + return resources.isEmpty(); + } + + @Override + public boolean contains(Object o) { + return resources.contains(o); + } + + @Override + public Iterator iterator() { + return resources.iterator(); + } + + @Override + public Object[] toArray() { + return resources.toArray(); + } + + @Override + public T[] toArray(T[] a) { + return resources.toArray(a); + } + + @Override + public boolean add(E e) { + return resources.add(e); + } + + @Override + public boolean remove(Object o) { + return resources.remove(o); + } + + @Override + public boolean containsAll(Collection c) { + return resources.containsAll(c); + } + + @Override + public boolean addAll(Collection c) { + return resources.addAll(c); + } + + @Override + public boolean addAll(int index, Collection c) { + return resources.addAll(index, c); + } + + @Override + public boolean removeAll(Collection c) { + return resources.removeAll(c); + } + + @Override + public boolean retainAll(Collection c) { + return resources.retainAll(c); + } + + @Override + public void clear() { + resources.clear(); + } + + @Override + public E get(int index) { + return resources.get(index); + } + + @Override + public E set(int index, E element) { + return resources.set(index, element); + } + + @Override + public void add(int index, E element) { + resources.add(index, element); + } + + @Override + public E remove(int index) { + return resources.remove(index); + } + + @Override + public int indexOf(Object o) { + return resources.indexOf(o); + } + + @Override + public int lastIndexOf(Object o) { + return resources.lastIndexOf(o); + } + + @Override + public ListIterator listIterator() { + return resources.listIterator(); + } + + @Override + public ListIterator listIterator(int index) { + return resources.listIterator(index); + } + + @Override + public List subList(int fromIndex, int toIndex) { + return resources.subList(fromIndex, toIndex); + } +} diff --git a/src/test/java/com/github/jasminb/jsonapi/ResourceListTest.java b/src/test/java/com/github/jasminb/jsonapi/ResourceListTest.java new file mode 100644 index 0000000..8f3d163 --- /dev/null +++ b/src/test/java/com/github/jasminb/jsonapi/ResourceListTest.java @@ -0,0 +1,148 @@ +package com.github.jasminb.jsonapi; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; + +/** + * Insures that the {@code ResourceList} behavior is in accordance with its Javadoc. + */ +public class ResourceListTest { + + private final ResourceList EMPTY_RESOURCE_LIST = new ResourceList<>(Collections.emptyList()); + + private final Link LINK_NO_META = new Link("http://example.com/link/rel"); + + @Before + public void setUp() throws Exception { + Assert.assertTrue(EMPTY_RESOURCE_LIST.size() == 0); + Assert.assertTrue(EMPTY_RESOURCE_LIST.getLinks().isEmpty()); + Assert.assertTrue(EMPTY_RESOURCE_LIST.getMeta().isEmpty()); + + Assert.assertTrue(LINK_NO_META.getMeta().size() == 0); + Assert.assertEquals("http://example.com/link/rel", LINK_NO_META.getHref()); + } + + @Test(expected = IllegalArgumentException.class) + public void testNullityLinksSetter() throws Exception { + EMPTY_RESOURCE_LIST.setLinks(null); + } + + @SuppressWarnings("unchecked") + @Test(expected = IllegalArgumentException.class) + public void testNullityMetaSetter() throws Exception { + EMPTY_RESOURCE_LIST.setMeta(null); + } + + @Test(expected = IllegalArgumentException.class) + public void testNullityConstructor() throws Exception { + new ResourceList<>(null); + } + + @Test + public void testGetLink() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getLink("foo")); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put("foo", LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getLink("foo")); + } + + @Test + public void testGetPrev() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getPrevious()); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put(JSONAPISpecConstants.PREV, LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getPrevious()); + } + + @Test + public void testGetNext() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getNext()); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put(JSONAPISpecConstants.NEXT, LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getNext()); + } + + @Test + public void testGetFirst() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getFirst()); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put(JSONAPISpecConstants.FIRST, LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getFirst()); + } + + @Test + public void testGetLast() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getLast()); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put(JSONAPISpecConstants.LAST, LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getLast()); + } + + @Test + public void testGetSelf() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getSelf()); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put(JSONAPISpecConstants.SELF, LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getSelf()); + } + + @Test + public void testGetRelated() throws Exception { + Assert.assertNull(EMPTY_RESOURCE_LIST.getRelated()); + + EMPTY_RESOURCE_LIST.setLinks(new HashMap() { + { + put(JSONAPISpecConstants.RELATED, LINK_NO_META); + } + }); + + Assert.assertNotNull(EMPTY_RESOURCE_LIST.getRelated()); + } + + @Test + public void testGetMeta() throws Exception { + Assert.assertEquals(0, EMPTY_RESOURCE_LIST.getMeta().size()); + + EMPTY_RESOURCE_LIST.setMeta(new HashMap() { + { + put("total_results", 10); + } + }); + + Assert.assertEquals(1, EMPTY_RESOURCE_LIST.getMeta().size()); + } +} \ No newline at end of file