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");