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 065646714889 CAMEL-26221: camel-ibm-watsonx-data - getAllColumns 
parses its response with the managed com.ibm.cloud:sdk-core, and updateTable 
takes the table patch as JSON text as well as a Map (#28975)
065646714889 is described below

commit 065646714889df4fad2995bf7845dceb32f1182e
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Nov 2 23:21:39 2026 +0100

    CAMEL-26221: camel-ibm-watsonx-data - getAllColumns parses its response 
with the managed com.ibm.cloud:sdk-core, and updateTable takes the table patch 
as JSON text as well as a Map (#28975)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/components/ibm-watsonx-data.json |   2 +-
 .../catalog/docs/ibm-watsonx-data-component.adoc   |  63 ++++-
 .../camel-ibm/camel-ibm-watsonx-data/pom.xml       |   6 +
 .../ibm/watsonx/data/ibm-watsonx-data.json         |   2 +-
 .../src/main/docs/ibm-watsonx-data-component.adoc  |  63 ++++-
 .../ibm/watsonx/data/WatsonxDataEndpoint.java      |   9 +-
 .../ibm/watsonx/data/WatsonxDataProducer.java      |  50 +++-
 .../ibm/watsonx/data/WatsonxDataProducerTest.java  | 255 +++++++++++++++++++++
 8 files changed, 438 insertions(+), 12 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ibm-watsonx-data.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ibm-watsonx-data.json
index 17fe9eb88fa9..ecd044adede6 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ibm-watsonx-data.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ibm-watsonx-data.json
@@ -53,7 +53,7 @@
     "CamelIBMWatsonxDataAuthInstanceId": { "index": 13, "kind": "header", 
"displayName": "", "group": "producer", "label": "", "required": false, 
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "The auth instance ID for watsonx.data 
API calls", "constantName": 
"org.apache.camel.component.ibm.watsonx.data.WatsonxDataConstants#AUTH_INSTANCE_ID"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": 
"operation", "value": "updateTable", "carriers": [ "map" ] } ], "description": 
"Only updateTable reads the body: the patch of the table as a Map, sent as 
JSON. JSON text, bytes or no body fail: unmarshal the JSON to a Map first. The 
other operations take their input from the options and headers" },
+    "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": 
"operation", "value": "updateTable", "carriers": [ "map", "text", "bytes", 
"stream", "file" ] } ], "description": "Only updateTable reads the body: the 
patch of the table, as a Map or as a JSON object in text, bytes, a stream or a 
file, sent as a JSON merge patch. No body, an empty patch or a body that is not 
a JSON object fails. The other operations take their input from the options and 
headers" },
     "producerOut": { "carriers": [ "pojo" ], "byOption": [ { "option": 
"operation", "value": "deleteCatalog", "carriers": [ "null" ] }, { "option": 
"operation", "value": "deleteSchema", "carriers": [ "null" ] }, { "option": 
"operation", "value": "deleteTable", "carriers": [ "null" ] } ], "description": 
"The response of the IBM watsonx.data SDK, such as a CatalogCollection for 
listCatalogs or a Table for getTable and updateTable; converted to text, it is 
its JSON. The delete operations se [...]
   },
   "properties": {
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ibm-watsonx-data-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ibm-watsonx-data-component.adoc
index 4aa04ac90f44..00d3f65c8156 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ibm-watsonx-data-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ibm-watsonx-data-component.adoc
@@ -169,7 +169,7 @@ The component supports the following operations:
 | `listTables` | List tables in a schema. Requires `catalogName`, 
`schemaName`, and `engineId`.
 | `getTable` | Get table details. Requires `catalogName`, `schemaName`, 
`engineId`, and `CamelIBMWatsonxDataTableName` header.
 | `deleteTable` | Delete a table. Requires `catalogName`, `schemaName`, 
`engineId`, and `CamelIBMWatsonxDataTableName` header.
-| `updateTable` | Update table properties. Requires `catalogName`, 
`schemaName`, `engineId`, `CamelIBMWatsonxDataTableName` header, and a `Map` 
body with patch data.
+| `updateTable` | Update table properties. Requires `catalogName`, 
`schemaName`, `engineId`, `CamelIBMWatsonxDataTableName` header, and the patch 
of the table in the body: a `Map`, or a JSON object as text (a `String`, 
`byte[]`, `InputStream` or file), which is sent as a JSON merge patch.
 | `registerTable` | Register an external Iceberg table. Requires 
`CamelIBMWatsonxDataCatalogId`, `CamelIBMWatsonxDataSchemaId`, 
`CamelIBMWatsonxDataMetadataLocation`, and `CamelIBMWatsonxDataTableName` 
headers.
 | `getAllColumns` | Get all columns for a catalog. Requires `catalogName`. 
Optionally filter by `schemaName` and `CamelIBMWatsonxDataTableName`.
 |===
@@ -346,6 +346,67 @@ YAML::
 ----
 ====
 
+=== Rename a Table
+
+The patch of the table can be a JSON object as text, for example from a file 
or an HTTP request:
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:renameTable")
+    .setHeader("CamelIBMWatsonxDataTableName", constant("orders"))
+    .setBody(constant("{\"name\": \"orders_v2\"}"))
+    
.to("ibm-watsonx-data:lakehouse?apiKey={{ibm.apiKey}}&serviceUrl={{ibm.watsonxdata.url}}&catalogName=my-catalog&schemaName=my-schema&engineId=my-presto-engine&operation=updateTable")
+    .log("Table updated: ${body}");
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+  <from uri="direct:renameTable"/>
+  <setHeader name="CamelIBMWatsonxDataTableName">
+    <constant>orders</constant>
+  </setHeader>
+  <setBody>
+    <constant>{"name": "orders_v2"}</constant>
+  </setBody>
+  <to 
uri="ibm-watsonx-data:lakehouse?apiKey={{ibm.apiKey}}&amp;serviceUrl={{ibm.watsonxdata.url}}&amp;catalogName=my-catalog&amp;schemaName=my-schema&amp;engineId=my-presto-engine&amp;operation=updateTable"/>
+  <log message="Table updated: ${body}"/>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: direct:renameTable
+      steps:
+        - setHeader:
+            name: CamelIBMWatsonxDataTableName
+            constant: orders
+        - setBody:
+            constant: '{"name": "orders_v2"}'
+        - to:
+            uri: ibm-watsonx-data:lakehouse
+            parameters:
+              apiKey: "{{ibm.apiKey}}"
+              serviceUrl: "{{ibm.watsonxdata.url}}"
+              catalogName: my-catalog
+              schemaName: my-schema
+              engineId: my-presto-engine
+              operation: updateTable
+        - log:
+            message: "Table updated: ${body}"
+----
+====
+
 === Using Dynamic Operations via Headers
 
 ._Java-only: requires Java enum WatsonxDataOperations constant_
diff --git a/components/camel-ibm/camel-ibm-watsonx-data/pom.xml 
b/components/camel-ibm/camel-ibm-watsonx-data/pom.xml
index 00f508856a27..b8e59a9d7170 100644
--- a/components/camel-ibm/camel-ibm-watsonx-data/pom.xml
+++ b/components/camel-ibm/camel-ibm-watsonx-data/pom.xml
@@ -44,6 +44,12 @@
             <artifactId>watson-x-data</artifactId>
             <version>${ibm-watsonx-data-sdk-version}</version>
         </dependency>
+        <!-- the IBM SDK core at the version managed in camel-ibm-parent, 
declared here so that applications
+             get it too, instead of the older one of the watsonx.data SDK that 
fails with Gson 2.13 or later -->
+        <dependency>
+            <groupId>com.ibm.cloud</groupId>
+            <artifactId>sdk-core</artifactId>
+        </dependency>
 
         <!-- for testing -->
         <dependency>
diff --git 
a/components/camel-ibm/camel-ibm-watsonx-data/src/generated/resources/META-INF/org/apache/camel/component/ibm/watsonx/data/ibm-watsonx-data.json
 
b/components/camel-ibm/camel-ibm-watsonx-data/src/generated/resources/META-INF/org/apache/camel/component/ibm/watsonx/data/ibm-watsonx-data.json
index 17fe9eb88fa9..ecd044adede6 100644
--- 
a/components/camel-ibm/camel-ibm-watsonx-data/src/generated/resources/META-INF/org/apache/camel/component/ibm/watsonx/data/ibm-watsonx-data.json
+++ 
b/components/camel-ibm/camel-ibm-watsonx-data/src/generated/resources/META-INF/org/apache/camel/component/ibm/watsonx/data/ibm-watsonx-data.json
@@ -53,7 +53,7 @@
     "CamelIBMWatsonxDataAuthInstanceId": { "index": 13, "kind": "header", 
"displayName": "", "group": "producer", "label": "", "required": false, 
"javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "description": "The auth instance ID for watsonx.data 
API calls", "constantName": 
"org.apache.camel.component.ibm.watsonx.data.WatsonxDataConstants#AUTH_INSTANCE_ID"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": 
"operation", "value": "updateTable", "carriers": [ "map" ] } ], "description": 
"Only updateTable reads the body: the patch of the table as a Map, sent as 
JSON. JSON text, bytes or no body fail: unmarshal the JSON to a Map first. The 
other operations take their input from the options and headers" },
+    "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": 
"operation", "value": "updateTable", "carriers": [ "map", "text", "bytes", 
"stream", "file" ] } ], "description": "Only updateTable reads the body: the 
patch of the table, as a Map or as a JSON object in text, bytes, a stream or a 
file, sent as a JSON merge patch. No body, an empty patch or a body that is not 
a JSON object fails. The other operations take their input from the options and 
headers" },
     "producerOut": { "carriers": [ "pojo" ], "byOption": [ { "option": 
"operation", "value": "deleteCatalog", "carriers": [ "null" ] }, { "option": 
"operation", "value": "deleteSchema", "carriers": [ "null" ] }, { "option": 
"operation", "value": "deleteTable", "carriers": [ "null" ] } ], "description": 
"The response of the IBM watsonx.data SDK, such as a CatalogCollection for 
listCatalogs or a Table for getTable and updateTable; converted to text, it is 
its JSON. The delete operations se [...]
   },
   "properties": {
diff --git 
a/components/camel-ibm/camel-ibm-watsonx-data/src/main/docs/ibm-watsonx-data-component.adoc
 
b/components/camel-ibm/camel-ibm-watsonx-data/src/main/docs/ibm-watsonx-data-component.adoc
index 4aa04ac90f44..00d3f65c8156 100644
--- 
a/components/camel-ibm/camel-ibm-watsonx-data/src/main/docs/ibm-watsonx-data-component.adoc
+++ 
b/components/camel-ibm/camel-ibm-watsonx-data/src/main/docs/ibm-watsonx-data-component.adoc
@@ -169,7 +169,7 @@ The component supports the following operations:
 | `listTables` | List tables in a schema. Requires `catalogName`, 
`schemaName`, and `engineId`.
 | `getTable` | Get table details. Requires `catalogName`, `schemaName`, 
`engineId`, and `CamelIBMWatsonxDataTableName` header.
 | `deleteTable` | Delete a table. Requires `catalogName`, `schemaName`, 
`engineId`, and `CamelIBMWatsonxDataTableName` header.
-| `updateTable` | Update table properties. Requires `catalogName`, 
`schemaName`, `engineId`, `CamelIBMWatsonxDataTableName` header, and a `Map` 
body with patch data.
+| `updateTable` | Update table properties. Requires `catalogName`, 
`schemaName`, `engineId`, `CamelIBMWatsonxDataTableName` header, and the patch 
of the table in the body: a `Map`, or a JSON object as text (a `String`, 
`byte[]`, `InputStream` or file), which is sent as a JSON merge patch.
 | `registerTable` | Register an external Iceberg table. Requires 
`CamelIBMWatsonxDataCatalogId`, `CamelIBMWatsonxDataSchemaId`, 
`CamelIBMWatsonxDataMetadataLocation`, and `CamelIBMWatsonxDataTableName` 
headers.
 | `getAllColumns` | Get all columns for a catalog. Requires `catalogName`. 
Optionally filter by `schemaName` and `CamelIBMWatsonxDataTableName`.
 |===
@@ -346,6 +346,67 @@ YAML::
 ----
 ====
 
+=== Rename a Table
+
+The patch of the table can be a JSON object as text, for example from a file 
or an HTTP request:
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:renameTable")
+    .setHeader("CamelIBMWatsonxDataTableName", constant("orders"))
+    .setBody(constant("{\"name\": \"orders_v2\"}"))
+    
.to("ibm-watsonx-data:lakehouse?apiKey={{ibm.apiKey}}&serviceUrl={{ibm.watsonxdata.url}}&catalogName=my-catalog&schemaName=my-schema&engineId=my-presto-engine&operation=updateTable")
+    .log("Table updated: ${body}");
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+  <from uri="direct:renameTable"/>
+  <setHeader name="CamelIBMWatsonxDataTableName">
+    <constant>orders</constant>
+  </setHeader>
+  <setBody>
+    <constant>{"name": "orders_v2"}</constant>
+  </setBody>
+  <to 
uri="ibm-watsonx-data:lakehouse?apiKey={{ibm.apiKey}}&amp;serviceUrl={{ibm.watsonxdata.url}}&amp;catalogName=my-catalog&amp;schemaName=my-schema&amp;engineId=my-presto-engine&amp;operation=updateTable"/>
+  <log message="Table updated: ${body}"/>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: direct:renameTable
+      steps:
+        - setHeader:
+            name: CamelIBMWatsonxDataTableName
+            constant: orders
+        - setBody:
+            constant: '{"name": "orders_v2"}'
+        - to:
+            uri: ibm-watsonx-data:lakehouse
+            parameters:
+              apiKey: "{{ibm.apiKey}}"
+              serviceUrl: "{{ibm.watsonxdata.url}}"
+              catalogName: my-catalog
+              schemaName: my-schema
+              engineId: my-presto-engine
+              operation: updateTable
+        - log:
+            message: "Table updated: ${body}"
+----
+====
+
 === Using Dynamic Operations via Headers
 
 ._Java-only: requires Java enum WatsonxDataOperations constant_
diff --git 
a/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataEndpoint.java
 
b/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataEndpoint.java
index 1b29bd6872ba..dd0e2176cb95 100644
--- 
a/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataEndpoint.java
+++ 
b/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataEndpoint.java
@@ -46,9 +46,12 @@ import org.apache.camel.util.ObjectHelper;
              headersClass = WatsonxDataConstants.class)
 @BodyTypes(producerIn = @BodyType(value = Carrier.ANY,
                                   byOption = @BodyType.When(option = 
"operation", value = "updateTable",
-                                                            carriers = 
Carrier.MAP),
-                                  description = "Only updateTable reads the 
body: the patch of the table as a Map, sent as JSON."
-                                                + " JSON text, bytes or no 
body fail: unmarshal the JSON to a Map first. The other"
+                                                            carriers = {
+                                                                    
Carrier.MAP, Carrier.TEXT, Carrier.BYTES, Carrier.STREAM,
+                                                                    
Carrier.FILE }),
+                                  description = "Only updateTable reads the 
body: the patch of the table, as a Map or as a JSON"
+                                                + " object in text, bytes, a 
stream or a file, sent as a JSON merge patch. No"
+                                                + " body, an empty patch or a 
body that is not a JSON object fails. The other"
                                                 + " operations take their 
input from the options and headers"),
            producerOut = @BodyType(value = Carrier.POJO,
                                    byOption = {
diff --git 
a/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducer.java
 
b/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducer.java
index 5ff9ad02d1a9..baa379c9d94f 100644
--- 
a/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducer.java
+++ 
b/components/camel-ibm/camel-ibm-watsonx-data/src/main/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducer.java
@@ -18,7 +18,12 @@ package org.apache.camel.component.ibm.watsonx.data;
 
 import java.util.Map;
 
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
 import com.ibm.cloud.sdk.core.http.Response;
+import com.ibm.cloud.sdk.core.util.GsonSingleton;
 import com.ibm.cloud.watsonxdata.watsonx_data.v3.WatsonxData;
 import com.ibm.cloud.watsonxdata.watsonx_data.v3.model.Catalog;
 import com.ibm.cloud.watsonxdata.watsonx_data.v3.model.CatalogCollection;
@@ -64,6 +69,14 @@ public class WatsonxDataProducer extends DefaultProducer {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(WatsonxDataProducer.class);
 
+    // the bodies updateTable takes, for its error messages
+    private static final String TABLE_PATCH_BODIES
+            = "a java.util.Map, or a JSON object as text (a String, byte[], 
InputStream or file)";
+    private static final String TABLE_PATCH_NOT_JSON_OBJECT
+            = "The body of the updateTable operation is not a JSON object: it 
must be " + TABLE_PATCH_BODIES;
+    private static final TypeToken<Map<String, Object>> TABLE_PATCH_TYPE = new 
TypeToken<>() {
+    };
+
     public WatsonxDataProducer(WatsonxDataEndpoint endpoint) {
         super(endpoint);
     }
@@ -261,7 +274,6 @@ public class WatsonxDataProducer extends DefaultProducer {
         exchange.getMessage().setBody(null);
     }
 
-    @SuppressWarnings("unchecked")
     private void handleUpdateTable(Exchange exchange, WatsonxData service, 
WatsonxDataConfiguration config) {
         String catalogName = resolveHeader(exchange, 
WatsonxDataConstants.CATALOG_NAME, config.getCatalogName());
         String schemaName = resolveHeader(exchange, 
WatsonxDataConstants.SCHEMA_NAME, config.getSchemaName());
@@ -272,10 +284,7 @@ public class WatsonxDataProducer extends DefaultProducer {
         requireNonEmpty(tableName, "Table name");
         requireNonEmpty(engineId, "Engine ID");
 
-        Map<String, Object> body = exchange.getIn().getBody(Map.class);
-        if (body == null || body.isEmpty()) {
-            throw new IllegalArgumentException("Request body with table patch 
data is required for updateTable operation");
-        }
+        Map<String, Object> body = getTablePatch(exchange);
 
         UpdateTableOptions.Builder builder
                 = new UpdateTableOptions.Builder(catalogName, schemaName, 
tableName, engineId, body);
@@ -285,6 +294,37 @@ public class WatsonxDataProducer extends DefaultProducer {
         exchange.getMessage().setBody(response.getResult());
     }
 
+    /**
+     * The patch of the table for updateTable: a Map, or a JSON object as 
text, such as a String from HTTP or a file.
+     * The JSON is parsed with the Gson of the IBM SDK, which writes the patch 
back to JSON for the request, so its
+     * numbers and nulls are sent as they are written.
+     */
+    private static Map<String, Object> getTablePatch(Exchange exchange) {
+        @SuppressWarnings("unchecked")
+        Map<String, Object> patch = exchange.getIn().getBody(Map.class);
+        if (patch == null && exchange.getIn().getBody() != null) {
+            String json = exchange.getIn().getBody(String.class);
+            if (json != null) {
+                JsonElement element;
+                try {
+                    element = JsonParser.parseString(json);
+                } catch (JsonParseException e) {
+                    throw new 
IllegalArgumentException(TABLE_PATCH_NOT_JSON_OBJECT, e);
+                }
+                if (element.isJsonObject()) {
+                    patch = 
GsonSingleton.getGsonWithoutPrettyPrinting().fromJson(element, 
TABLE_PATCH_TYPE);
+                } else if (!element.isJsonNull()) {
+                    throw new 
IllegalArgumentException(TABLE_PATCH_NOT_JSON_OBJECT);
+                }
+            }
+        }
+        if (patch == null || patch.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "Request body with table patch data is required for 
updateTable operation: " + TABLE_PATCH_BODIES);
+        }
+        return patch;
+    }
+
     private void handleRegisterTable(Exchange exchange, WatsonxData service, 
WatsonxDataConfiguration config) {
         String catalogId = 
exchange.getIn().getHeader(WatsonxDataConstants.CATALOG_ID, String.class);
         String schemaId = 
exchange.getIn().getHeader(WatsonxDataConstants.SCHEMA_ID, String.class);
diff --git 
a/components/camel-ibm/camel-ibm-watsonx-data/src/test/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducerTest.java
 
b/components/camel-ibm/camel-ibm-watsonx-data/src/test/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducerTest.java
new file mode 100644
index 000000000000..a58459461011
--- /dev/null
+++ 
b/components/camel-ibm/camel-ibm-watsonx-data/src/test/java/org/apache/camel/component/ibm/watsonx/data/WatsonxDataProducerTest.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ibm.watsonx.data;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import com.google.gson.JsonParser;
+import com.ibm.cloud.sdk.core.security.IamAuthenticator;
+import com.ibm.cloud.watsonxdata.watsonx_data.v3.model.ColumnsCollection;
+import com.ibm.cloud.watsonxdata.watsonx_data.v3.model.Table;
+import com.ibm.cloud.watsonxdata.watsonx_data.v3.model.TableColumDetail;
+import 
com.ibm.cloud.watsonxdata.watsonx_data.v3.model.TableColumDetailColumnsItems;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The getAllColumns and updateTable operations, with a JDK HttpServer as the 
watsonx.data service and as the IAM token
+ * service.
+ */
+class WatsonxDataProducerTest extends CamelTestSupport {
+
+    private static final String TABLE_PATH = 
"/catalogs/iceberg_data/schemas/sales/tables/orders";
+    private static final String TABLE_PATCH = "{\"name\":\"orders_v2\"}";
+    private static final String ACCEPTED_BODIES
+            = "a java.util.Map, or a JSON object as text (a String, byte[], 
InputStream or file)";
+
+    @TempDir
+    Path dir;
+
+    private HttpServer server;
+    private final AtomicInteger patchRequests = new AtomicInteger();
+    private volatile String columnsRequestUri;
+    private volatile String patchRequestUri;
+    private volatile String patchContentType;
+    private volatile String patchBody;
+
+    @Override
+    protected void setupResources() throws Exception {
+        server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+        // the IAM token service, which the IamAuthenticator of the endpoint 
asks for a token for the API key
+        server.createContext("/identity/token", exchange -> {
+            exchange.getRequestBody().readAllBytes();
+            respond(exchange, 
"{\"access_token\":\"test-access-token\",\"refresh_token\":\"test-refresh-token\","
+                              + 
"\"token_type\":\"Bearer\",\"expires_in\":3600,\"expiration\":"
+                              + (Instant.now().getEpochSecond() + 3600) + "}");
+        });
+        server.createContext("/columns", exchange -> {
+            columnsRequestUri = exchange.getRequestURI().toString();
+            respond(exchange, 
"{\"columns\":[{\"catalog\":\"iceberg_data\",\"schema\":\"sales\",\"table\":\"orders\","
+                              + 
"\"columns\":[{\"column\":\"id\",\"type\":\"bigint\",\"nullable\":false,\"index\":1,"
+                              + "\"comment\":\"the order 
id\"}]}],\"count\":1}");
+        });
+        server.createContext(TABLE_PATH, exchange -> {
+            patchRequests.incrementAndGet();
+            patchRequestUri = exchange.getRequestMethod() + " " + 
exchange.getRequestURI();
+            patchContentType = 
exchange.getRequestHeaders().getFirst("Content-Type");
+            patchBody = new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8);
+            respond(exchange, 
"{\"name\":\"orders_v2\",\"columns\":[{\"name\":\"id\",\"type\":\"bigint\"}]}");
+        });
+        server.start();
+    }
+
+    @Override
+    protected void cleanupResources() throws Exception {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @BeforeEach
+    void useStubTokenService() {
+        // the endpoint authenticates with an IAM API key: take the token from 
the stub instead of IBM Cloud
+        WatsonxDataEndpoint endpoint = context.getEndpoint(watsonxDataUri(), 
WatsonxDataEndpoint.class);
+        ((IamAuthenticator) 
endpoint.getWatsonxDataService().getAuthenticator()).setURL(serverUrl());
+    }
+
+    @Test
+    void getAllColumnsParsesColumnsWithDynamicProperties() {
+        Object body = template.requestBodyAndHeader("direct:start", null, 
WatsonxDataConstants.OPERATION,
+                WatsonxDataOperations.getAllColumns);
+
+        ColumnsCollection columns = assertInstanceOf(ColumnsCollection.class, 
body);
+        assertEquals(1L, columns.getCount());
+        TableColumDetail table = columns.getColumns().get(0);
+        assertEquals("orders", table.getTable());
+        TableColumDetailColumnsItems column = table.getColumns().get(0);
+        assertEquals("id", column.getColumn());
+        assertEquals("bigint", column.getType());
+        assertFalse(column.isNullable());
+        // a property the model does not declare is kept as a dynamic property 
of the column
+        assertEquals("the order id", column.get("comment"));
+
+        assertTrue(columnsRequestUri.startsWith("/columns?"), 
columnsRequestUri);
+        assertEquals(Map.of("catalog_name", "iceberg_data", "schema_name", 
"sales"), queryParameters(columnsRequestUri));
+    }
+
+    @Test
+    void updateTableSendsMapBody() {
+        Table table = updateTable(Map.of("name", "orders_v2"));
+
+        assertEquals("orders_v2", table.getName());
+        assertPatchSent();
+    }
+
+    static Stream<Named<Object>> jsonTextBodies() {
+        return Stream.of(
+                Named.of("String", TABLE_PATCH),
+                Named.of("byte[]", 
TABLE_PATCH.getBytes(StandardCharsets.UTF_8)),
+                Named.of("InputStream", new 
ByteArrayInputStream(TABLE_PATCH.getBytes(StandardCharsets.UTF_8))));
+    }
+
+    @ParameterizedTest
+    @MethodSource("jsonTextBodies")
+    void updateTableSendsJsonTextBody(Object body) {
+        Table table = updateTable(body);
+
+        assertEquals("orders_v2", table.getName());
+        assertPatchSent();
+    }
+
+    @Test
+    void updateTableSendsJsonFileBody() throws IOException {
+        Path file = Files.writeString(dir.resolve("patch.json"), TABLE_PATCH);
+
+        Table table = updateTable(file.toFile());
+
+        assertEquals("orders_v2", table.getName());
+        assertPatchSent();
+    }
+
+    @Test
+    void updateTableWithoutBodyFails() {
+        CamelExecutionException e = 
assertThrows(CamelExecutionException.class, () -> updateTable(null));
+
+        IllegalArgumentException cause = 
assertInstanceOf(IllegalArgumentException.class, e.getCause());
+        assertEquals("Request body with table patch data is required for 
updateTable operation: " + ACCEPTED_BODIES,
+                cause.getMessage());
+        assertEquals(0, patchRequests.get());
+    }
+
+    static Stream<Named<Object>> bodiesThatAreNotJsonObjects() {
+        return Stream.of(
+                Named.of("JSON array", "[\"orders_v2\"]"),
+                Named.of("text that is not JSON", "name=orders_v2"),
+                Named.of("bytes that are not UTF-8", new byte[] { (byte) 0xff, 
(byte) 0xfe }),
+                Named.of("POJO", new TableRename("orders_v2")));
+    }
+
+    @ParameterizedTest
+    @MethodSource("bodiesThatAreNotJsonObjects")
+    void updateTableWithBodyThatIsNotJsonObjectFails(Object body) {
+        CamelExecutionException e = 
assertThrows(CamelExecutionException.class, () -> updateTable(body));
+
+        IllegalArgumentException cause = 
assertInstanceOf(IllegalArgumentException.class, e.getCause());
+        assertEquals("The body of the updateTable operation is not a JSON 
object: it must be " + ACCEPTED_BODIES,
+                cause.getMessage());
+        assertEquals(0, patchRequests.get());
+    }
+
+    private Table updateTable(Object body) {
+        Map<String, Object> headers = new HashMap<>();
+        headers.put(WatsonxDataConstants.OPERATION, 
WatsonxDataOperations.updateTable);
+        headers.put(WatsonxDataConstants.TABLE_NAME, "orders");
+        return template.requestBodyAndHeaders("direct:start", body, headers, 
Table.class);
+    }
+
+    private void assertPatchSent() {
+        assertEquals("PATCH " + TABLE_PATH + "?engine_id=presto-01", 
patchRequestUri);
+        
assertTrue(patchContentType.startsWith("application/merge-patch+json"), 
patchContentType);
+        assertEquals(JsonParser.parseString(TABLE_PATCH), 
JsonParser.parseString(patchBody));
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start").to(watsonxDataUri());
+            }
+        };
+    }
+
+    private String serverUrl() {
+        return "http://localhost:"; + server.getAddress().getPort();
+    }
+
+    private String watsonxDataUri() {
+        return 
"ibm-watsonx-data:test?apiKey=test-api-key&catalogName=iceberg_data&schemaName=sales&engineId=presto-01"
+               + "&serviceUrl=" + serverUrl();
+    }
+
+    private static Map<String, String> queryParameters(String uri) {
+        Map<String, String> answer = new HashMap<>();
+        for (String pair : uri.substring(uri.indexOf('?') + 1).split("&")) {
+            String[] nameAndValue = pair.split("=", 2);
+            answer.put(URLDecoder.decode(nameAndValue[0], 
StandardCharsets.UTF_8),
+                    URLDecoder.decode(nameAndValue[1], 
StandardCharsets.UTF_8));
+        }
+        return answer;
+    }
+
+    // a POJO the type converters cannot turn into a Map: its text is not JSON
+    record TableRename(String name) {
+    }
+
+    private static void respond(HttpExchange exchange, String json) throws 
IOException {
+        byte[] body = json.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().set("Content-Type", "application/json");
+        exchange.sendResponseHeaders(200, body.length);
+        try (OutputStream out = exchange.getResponseBody()) {
+            out.write(body);
+        }
+    }
+}

Reply via email to