This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26000-jsonapi-collections in repository https://gitbox.apache.org/repos/asf/camel.git
commit 20649e05947119c8e69f02e0d62c5f8c926e136b Author: Claus Ibsen <[email protected]> AuthorDate: Thu Nov 12 21:21:56 2026 +0100 CAMEL-26000: camel-jsonapi - support JSON:API collection documents, whose data is an array Unmarshal looks at the top level data member of the document: when it is an array, the document is read with readDocumentCollection and the body is a List of mainFormatType objects; otherwise it is read as one resource object, as before. Marshal writes a Collection or an array body with writeDocumentCollection, as a document whose data is an array. Both cases failed before. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/dataformats/jsonApi.json | 4 +- .../camel/catalog/docs/jsonApi-dataformat.adoc | 9 ++ components/camel-jsonapi/pom.xml | 5 + .../apache/camel/component/jsonapi/jsonApi.json | 4 +- .../src/main/docs/jsonApi-dataformat.adoc | 9 ++ .../camel/component/jsonapi/JsonApiDataFormat.java | 63 +++++++++-- .../component/jsonapi/JsonApiDataFormatTest.java | 122 +++++++++++++++++++++ 7 files changed, 203 insertions(+), 13 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dataformats/jsonApi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dataformats/jsonApi.json index 2b731f971055..270ff45c9c43 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dataformats/jsonApi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dataformats/jsonApi.json @@ -16,10 +16,10 @@ "modelJavaType": "org.apache.camel.model.dataformat.JsonApiDataFormat" }, "bodyTypes": { - "marshalIn": { "carriers": [ "pojo" ], "description": "One resource object of a class dataFormatTypes names, annotated with @Type and @Id of JSONAPI-Converter. A Map or a List fails" }, + "marshalIn": { "carriers": [ "pojo", "list" ], "description": "One resource object of a class dataFormatTypes names, annotated with @Type and @Id of JSONAPI-Converter, or a Collection or an array of them, written as a collection document whose data is an array. A Map fails" }, "marshalOut": { "carriers": [ "stream" ], "format": "json", "description": "The JSON:API document in the stream cache, or a byte array when stream caching is off" }, "unmarshalIn": { "carriers": [ "text", "bytes", "stream", "file" ], "format": "json" }, - "unmarshalOut": { "carriers": [ "pojo", "null" ], "javaTypeOption": "mainFormatType", "description": "The primary data as an object of the class mainFormatType names, or no body when the data is null. A document whose data is an array fails" } + "unmarshalOut": { "carriers": [ "pojo", "list", "null" ], "javaTypeOption": "mainFormatType", "description": "The primary data as an object of the class mainFormatType names, or no body when the data is null. A collection document, whose data is an array, gives a List of them" } }, "properties": { "id": { "index": 0, "kind": "attribute", "displayName": "Id", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The id of this node" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jsonApi-dataformat.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jsonApi-dataformat.adoc index ef5d8b56573a..969bdc84e75d 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jsonApi-dataformat.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jsonApi-dataformat.adoc @@ -42,6 +42,15 @@ On unmarshal, `mainFormatType` is the class of the primary data of the document: uri: mock:unmarshal ---- +=== Collection documents + +A JSON:API document whose primary data is an array, such as the answer to `GET /books`, is a collection document. + +* On unmarshal, a document whose `data` is an array gives a `java.util.List` of `mainFormatType` objects (an empty + list for `"data": []`). A document whose `data` is one resource object gives that object, as before. +* On marshal, a `java.util.Collection` or an array of resource objects is written as a collection document, whose + `data` is an array. Any other body is written as one resource object. + == Dependencies To use JsonAPI in your Camel routes, you need to add the dependency diff --git a/components/camel-jsonapi/pom.xml b/components/camel-jsonapi/pom.xml index 7cc1aac60386..8f6e54aab3ea 100644 --- a/components/camel-jsonapi/pom.xml +++ b/components/camel-jsonapi/pom.xml @@ -47,6 +47,11 @@ <artifactId>jsonapi-converter</artifactId> <version>${jasminb-jsonapi-version}</version> </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-core</artifactId> + <version>${jackson2-version}</version> + </dependency> <!-- testing --> <dependency> diff --git a/components/camel-jsonapi/src/generated/resources/META-INF/org/apache/camel/component/jsonapi/jsonApi.json b/components/camel-jsonapi/src/generated/resources/META-INF/org/apache/camel/component/jsonapi/jsonApi.json index 2b731f971055..270ff45c9c43 100644 --- a/components/camel-jsonapi/src/generated/resources/META-INF/org/apache/camel/component/jsonapi/jsonApi.json +++ b/components/camel-jsonapi/src/generated/resources/META-INF/org/apache/camel/component/jsonapi/jsonApi.json @@ -16,10 +16,10 @@ "modelJavaType": "org.apache.camel.model.dataformat.JsonApiDataFormat" }, "bodyTypes": { - "marshalIn": { "carriers": [ "pojo" ], "description": "One resource object of a class dataFormatTypes names, annotated with @Type and @Id of JSONAPI-Converter. A Map or a List fails" }, + "marshalIn": { "carriers": [ "pojo", "list" ], "description": "One resource object of a class dataFormatTypes names, annotated with @Type and @Id of JSONAPI-Converter, or a Collection or an array of them, written as a collection document whose data is an array. A Map fails" }, "marshalOut": { "carriers": [ "stream" ], "format": "json", "description": "The JSON:API document in the stream cache, or a byte array when stream caching is off" }, "unmarshalIn": { "carriers": [ "text", "bytes", "stream", "file" ], "format": "json" }, - "unmarshalOut": { "carriers": [ "pojo", "null" ], "javaTypeOption": "mainFormatType", "description": "The primary data as an object of the class mainFormatType names, or no body when the data is null. A document whose data is an array fails" } + "unmarshalOut": { "carriers": [ "pojo", "list", "null" ], "javaTypeOption": "mainFormatType", "description": "The primary data as an object of the class mainFormatType names, or no body when the data is null. A collection document, whose data is an array, gives a List of them" } }, "properties": { "id": { "index": 0, "kind": "attribute", "displayName": "Id", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The id of this node" }, diff --git a/components/camel-jsonapi/src/main/docs/jsonApi-dataformat.adoc b/components/camel-jsonapi/src/main/docs/jsonApi-dataformat.adoc index ef5d8b56573a..969bdc84e75d 100644 --- a/components/camel-jsonapi/src/main/docs/jsonApi-dataformat.adoc +++ b/components/camel-jsonapi/src/main/docs/jsonApi-dataformat.adoc @@ -42,6 +42,15 @@ On unmarshal, `mainFormatType` is the class of the primary data of the document: uri: mock:unmarshal ---- +=== Collection documents + +A JSON:API document whose primary data is an array, such as the answer to `GET /books`, is a collection document. + +* On unmarshal, a document whose `data` is an array gives a `java.util.List` of `mainFormatType` objects (an empty + list for `"data": []`). A document whose `data` is one resource object gives that object, as before. +* On marshal, a `java.util.Collection` or an array of resource objects is written as a collection document, whose + `data` is an array. Any other body is written as one resource object. + == Dependencies To use JsonAPI in your Camel routes, you need to add the dependency diff --git a/components/camel-jsonapi/src/main/java/org/apache/camel/component/jsonapi/JsonApiDataFormat.java b/components/camel-jsonapi/src/main/java/org/apache/camel/component/jsonapi/JsonApiDataFormat.java index 8b0bf1adb41c..8d741b88a0de 100644 --- a/components/camel-jsonapi/src/main/java/org/apache/camel/component/jsonapi/JsonApiDataFormat.java +++ b/components/camel-jsonapi/src/main/java/org/apache/camel/component/jsonapi/JsonApiDataFormat.java @@ -16,11 +16,17 @@ */ package org.apache.camel.component.jsonapi; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.List; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.github.jasminb.jsonapi.JSONAPIDocument; import com.github.jasminb.jsonapi.ResourceConverter; import org.apache.camel.CamelContext; @@ -38,18 +44,22 @@ import org.apache.camel.support.service.ServiceSupport; * JSonApi data format for marshal/unmarshal */ @Dataformat("jsonApi") -@BodyTypes(marshalIn = @BodyType(value = Carrier.POJO, +@BodyTypes(marshalIn = @BodyType(value = { Carrier.POJO, Carrier.LIST }, description = "One resource object of a class dataFormatTypes names, annotated with @Type and" - + " @Id of JSONAPI-Converter. A Map or a List fails"), + + " @Id of JSONAPI-Converter, or a Collection or an array of them, written as a" + + " collection document whose data is an array. A Map fails"), marshalOut = @BodyType(value = Carrier.STREAM, format = "json", description = "The JSON:API document in the stream cache, or a byte array when stream" + " caching is off"), unmarshalIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES, Carrier.STREAM, Carrier.FILE }, format = "json"), - unmarshalOut = @BodyType(value = { Carrier.POJO, Carrier.NULL }, javaTypeOption = "mainFormatType", + unmarshalOut = @BodyType(value = { Carrier.POJO, Carrier.LIST, Carrier.NULL }, javaTypeOption = "mainFormatType", description = "The primary data as an object of the class mainFormatType names, or no body" - + " when the data is null. A document whose data is an array fails")) + + " when the data is null. A collection document, whose data is an array, gives" + + " a List of them")) public class JsonApiDataFormat extends ServiceSupport implements DataFormat, DataFormatName, CamelContextAware { + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + private CamelContext camelContext; private String dataFormatTypes; @@ -87,7 +97,15 @@ public class JsonApiDataFormat extends ServiceSupport implements DataFormat, Dat @Override public void marshal(Exchange exchange, Object graph, OutputStream stream) throws Exception { ResourceConverter converter = new ResourceConverter(dataFormatTypeClasses); - byte[] objectAsBytes = converter.writeDocument(new JSONAPIDocument<>(graph)); + byte[] objectAsBytes; + if (graph instanceof Collection<?> collection) { + // a collection document: its data is an array of resource objects + objectAsBytes = converter.writeDocumentCollection(new JSONAPIDocument<>(collection)); + } else if (graph instanceof Object[] array) { + objectAsBytes = converter.writeDocumentCollection(new JSONAPIDocument<>(Arrays.asList(array))); + } else { + objectAsBytes = converter.writeDocument(new JSONAPIDocument<>(graph)); + } stream.write(objectAsBytes); } @@ -100,16 +118,43 @@ public class JsonApiDataFormat extends ServiceSupport implements DataFormat, Dat public Object unmarshal(Exchange exchange, Object body) throws Exception { ResourceConverter converter = new ResourceConverter(dataFormatTypeClasses); + // the converter reads the whole document into a tree anyway, so read it once as bytes and look at its data + byte[] data = body instanceof byte[] arr + ? arr : exchange.getContext().getTypeConverter().mandatoryConvertTo(byte[].class, exchange, body); + JSONAPIDocument<?> doc; - if (body instanceof byte[] arr) { - doc = converter.readDocument(arr, mainFormatTypeClass); + if (isCollectionDocument(data)) { + doc = converter.readDocumentCollection(data, mainFormatTypeClass); } else { - InputStream is = exchange.getContext().getTypeConverter().mandatoryConvertTo(InputStream.class, exchange, body); - doc = converter.readDocument(is, mainFormatTypeClass); + doc = converter.readDocument(data, mainFormatTypeClass); } return doc.get(); } + /** + * Whether the primary data of the document (the top level data member) is an array, which makes it a collection + * document. Only the tokens before the data member are read. Input that is not a JSON object answers false, so the + * converter reports it as before. + */ + private static boolean isCollectionDocument(byte[] data) { + try (JsonParser parser = JSON_FACTORY.createParser(data)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + return false; + } + while (parser.nextToken() == JsonToken.FIELD_NAME) { + String name = parser.currentName(); + JsonToken value = parser.nextToken(); + if ("data".equals(name)) { + return value == JsonToken.START_ARRAY; + } + parser.skipChildren(); + } + return false; + } catch (IOException e) { + return false; + } + } + public String getDataFormatTypes() { return dataFormatTypes; } diff --git a/components/camel-jsonapi/src/test/java/org/apache/camel/component/jsonapi/JsonApiDataFormatTest.java b/components/camel-jsonapi/src/test/java/org/apache/camel/component/jsonapi/JsonApiDataFormatTest.java index 2a72d8cea8fe..493cb08c3574 100644 --- a/components/camel-jsonapi/src/test/java/org/apache/camel/component/jsonapi/JsonApiDataFormatTest.java +++ b/components/camel-jsonapi/src/test/java/org/apache/camel/component/jsonapi/JsonApiDataFormatTest.java @@ -18,6 +18,8 @@ package org.apache.camel.component.jsonapi; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.util.Collections; +import java.util.List; import com.github.jasminb.jsonapi.exceptions.DocumentSerializationException; import com.github.jasminb.jsonapi.exceptions.UnregisteredTypeException; @@ -29,8 +31,10 @@ import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class JsonApiDataFormatTest extends CamelTestSupport { @@ -104,6 +108,109 @@ public class JsonApiDataFormatTest extends CamelTestSupport { () -> jsonApiDataFormat.unmarshal(exchange, stream)); } + @Test + public void testJsonApiUnmarshalCollection() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(MyBook.class, formats); + + Exchange exchange = new DefaultExchange(context); + Object outputObj = jsonApiDataFormat.unmarshal(exchange, + new ByteArrayInputStream(generateBooksAsString().getBytes())); + + List<?> books = assertInstanceOf(List.class, outputObj); + assertEquals(2, books.size()); + MyBook first = assertInstanceOf(MyBook.class, books.get(0)); + assertEquals("1617292931", first.getIsbn()); + assertEquals("Camel in Action", first.getTitle()); + MyBook second = assertInstanceOf(MyBook.class, books.get(1)); + assertEquals("1935182366", second.getIsbn()); + assertEquals("Camel in Action, first edition", second.getTitle()); + } + + @Test + public void testJsonApiUnmarshalCollectionDataNotFirst() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(MyBook.class, formats); + + // the data member comes after other members, one of them an object with a data member of its own + String jsonApiInput = "{\"meta\":{\"data\":{\"total\":1}},\"links\":{\"self\":\"/books\"}," + + "\"data\":[{\"type\":\"book\",\"id\":\"1617292931\"," + + "\"attributes\":{\"title\":\"Camel in Action\"}}]}"; + + Exchange exchange = new DefaultExchange(context); + Object outputObj = jsonApiDataFormat.unmarshal(exchange, jsonApiInput.getBytes()); + + List<?> books = assertInstanceOf(List.class, outputObj); + assertEquals(1, books.size()); + assertEquals("Camel in Action", assertInstanceOf(MyBook.class, books.get(0)).getTitle()); + } + + @Test + public void testJsonApiUnmarshalEmptyCollection() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(MyBook.class, formats); + + Exchange exchange = new DefaultExchange(context); + Object outputObj = jsonApiDataFormat.unmarshal(exchange, "{\"data\":[]}".getBytes()); + + List<?> books = assertInstanceOf(List.class, outputObj); + assertTrue(books.isEmpty()); + } + + @Test + public void testJsonApiMarshalCollection() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(formats); + + Exchange exchange = new DefaultExchange(context); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + jsonApiDataFormat.marshal(exchange, generateBooksAsObjects(), baos); + + assertEquals(generateBooksAsString(), baos.toString()); + } + + @Test + public void testJsonApiMarshalArray() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(formats); + + Exchange exchange = new DefaultExchange(context); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + jsonApiDataFormat.marshal(exchange, generateBooksAsObjects().toArray(new MyBook[0]), baos); + + assertEquals(generateBooksAsString(), baos.toString()); + } + + @Test + public void testJsonApiMarshalEmptyCollection() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(formats); + + Exchange exchange = new DefaultExchange(context); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + jsonApiDataFormat.marshal(exchange, Collections.emptyList(), baos); + + assertEquals("{\"data\":[]}", baos.toString()); + } + + @Test + public void testJsonApiCollectionRoundTrip() throws Exception { + Class<?>[] formats = { MyBook.class, MyAuthor.class }; + JsonApiDataFormat jsonApiDataFormat = new JsonApiDataFormat(MyBook.class, formats); + + Exchange exchange = new DefaultExchange(context); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + jsonApiDataFormat.marshal(exchange, generateBooksAsObjects(), baos); + Object outputObj = jsonApiDataFormat.unmarshal(exchange, baos.toByteArray()); + + List<?> books = assertInstanceOf(List.class, outputObj); + assertEquals(2, books.size()); + MyBook first = assertInstanceOf(MyBook.class, books.get(0)); + assertEquals("Camel in Action", first.getTitle()); + assertEquals("1", first.getAuthor().getAuthorId()); + assertEquals("Camel in Action, first edition", assertInstanceOf(MyBook.class, books.get(1)).getTitle()); + } + @Test public void testCamelJsonApiMarshal() throws InterruptedException { MockEndpoint mock = getMockEndpoint("mock:resultMarshal"); @@ -164,6 +271,21 @@ public class JsonApiDataFormatTest extends CamelTestSupport { return "{\"data\":{\"type\":\"book\",\"id\":\"1617292931\",\"attributes\":{\"title\":\"Camel in Action\"},\"relationships\":{\"author\":{\"data\":{\"type\":\"author\",\"id\":\"1\"}}}}}"; } + private String generateBooksAsString() { + return "{\"data\":[{\"type\":\"book\",\"id\":\"1617292931\",\"attributes\":{\"title\":\"Camel in Action\"}," + + "\"relationships\":{\"author\":{\"data\":{\"type\":\"author\",\"id\":\"1\"}}}}," + + "{\"type\":\"book\",\"id\":\"1935182366\",\"attributes\":{\"title\":\"Camel in Action, first edition\"}," + + "\"relationships\":{\"author\":{\"data\":{\"type\":\"author\",\"id\":\"1\"}}}}]}"; + } + + private List<MyBook> generateBooksAsObjects() { + MyBook second = new MyBook(); + second.setIsbn("1935182366"); + second.setTitle("Camel in Action, first edition"); + second.setAuthor(generateTestDataAsObject().getAuthor()); + return List.of(generateTestDataAsObject(), second); + } + private MyBook generateTestDataAsObject() { MyAuthor author = new MyAuthor(); author.setAuthorId("1");
