This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new e141942950b1 CAMEL-26000: camel-jsonapi - support JSON:API collection 
documents, whose data is an array (#29759)
e141942950b1 is described below

commit e141942950b128bcd882fa5ee1b473dc60d20a3d
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 13 08:04:19 2026 +0100

    CAMEL-26000: camel-jsonapi - support JSON:API collection documents, whose 
data is an array (#29759)
    
    The jsonApi data format failed on collection documents, whose data is an 
array.
    
    - unmarshal peeks at the top-level data member with a Jackson streaming
      parser; an array is read with readDocumentCollection into a List of
      mainFormatType, anything else as before
    - marshal writes a Collection or array body with writeDocumentCollection
    - jackson-core is declared, @BodyTypes include list, and the data format 
page
      has a "Collection documents" section
    
    Co-Authored-By: Claude Opus 5.5 <[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");

Reply via email to