diff --git a/sdk-gen/subprojects/java/api/build.gradle b/sdk-gen/subprojects/java/api/build.gradle
index 4583d223..7063b9f2 100644
--- a/sdk-gen/subprojects/java/api/build.gradle
+++ b/sdk-gen/subprojects/java/api/build.gradle
@@ -19,6 +19,7 @@ dependencies {
runtimeOnly group: 'org.apache.arrow', name: 'arrow-memory-netty', version: project['arrow.version'], transitive: false
runtimeOnly group: 'org.apache.arrow', name: 'arrow-memory-netty-buffer-patch', version: project['arrow.version'], transitive: false
// End of flight-core
+ api project(':wdp-connect-sdk-gen-java-sdk-connector-api')
api project(':wdp-connect-sdk-gen-java-api-models')
api group: 'org.apache.commons', name: 'commons-pool2', version: project['commons.pool2.version']
api group: 'org.apache.commons', name: 'commons-text', version: project['commons.text.version']
diff --git a/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/AbstractSdkConnectorFlightProducer.java b/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/AbstractSdkConnectorFlightProducer.java
new file mode 100644
index 00000000..695bc90a
--- /dev/null
+++ b/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/AbstractSdkConnectorFlightProducer.java
@@ -0,0 +1,93 @@
+/* *************************************************** */
+/* */
+/* (C) Copyright IBM Corp. 2026 */
+/* */
+/* *************************************************** */
+package com.ibm.connect.sdk.api;
+
+import java.util.Collections;
+
+import com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties;
+import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightDatasourceTypes;
+import com.ibm.wdp.connect.sdk.connector.SdkConnectorFactory;
+
+/**
+ * Convenience base class for Flight producers that use {@link SdkConnectorFactory}.
+ *
+ *
Subclasses implement only {@link #getSdkConnectorFactory()} — the old
+ * {@link ConnectorFactory} path is satisfied by a no-op factory whose datasource types
+ * are derived from the SDK factory. The SDK connector path is active whenever
+ * {@link #getSdkConnectorFactory()} returns non-null.
+ *
+ *
Usage:
+ *
+ * public class MyFlightProducer extends AbstractSdkConnectorFlightProducer {
+ * {@literal @}Override
+ * protected SdkConnectorFactory getSdkConnectorFactory() {
+ * return MyConnectorFactory.getInstance();
+ * }
+ * }
+ *
+ */
+public abstract class AbstractSdkConnectorFlightProducer extends ConnectorFlightProducer
+{
+ /**
+ * {@inheritDoc}
+ *
+ * Returns a no-op {@link ConnectorFactory} whose datasource types are derived from
+ * the SDK connector factory. The old connector path is never invoked when
+ * {@link #getSdkConnectorFactory()} returns non-null.
+ */
+ @Override
+ protected ConnectorFactory getConnectorFactory()
+ {
+ return new NoOpConnectorFactory(getSdkConnectorFactory());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected abstract SdkConnectorFactory getSdkConnectorFactory();
+
+ // ---- inner class ----
+
+ /**
+ * A no-op {@link ConnectorFactory} that exposes the datasource types from the SDK factory
+ * but throws {@link UnsupportedOperationException} on {@link #createConnector}.
+ *
+ *
This satisfies the {@link ConnectorFlightProducer} constructor requirement.
+ * It is never actually called because the SDK connector path short-circuits all operations
+ * when {@code sdkConnectorFactory != null}.
+ */
+ private static final class NoOpConnectorFactory extends PooledConnectorFactory
+ {
+ private final SdkConnectorFactory sdkFactory;
+
+ NoOpConnectorFactory(SdkConnectorFactory sdkFactory)
+ {
+ super();
+ this.sdkFactory = sdkFactory;
+ }
+
+ @Override
+ public CustomFlightDatasourceTypes getDatasourceTypes()
+ {
+ final CustomFlightDatasourceTypes types = new CustomFlightDatasourceTypes();
+ // Return empty list — datasource type listing for SDK producers goes through
+ // doAction(ACTION_LIST_DATASOURCE_TYPES) which uses connectorFactory.getDatasourceTypes().
+ // SDK producers that want to expose types should override doAction or provide
+ // their own CustomFlightDatasourceTypes mapping.
+ types.setDatasourceTypes(Collections.emptyList());
+ return types;
+ }
+
+ @Override
+ protected Connector, ?> createNewConnector(String datasourceTypeName, ConnectionProperties properties)
+ {
+ throw new UnsupportedOperationException(
+ "NoOpConnectorFactory.createNewConnector should never be called when sdkConnectorFactory is set. "
+ + "datasourceTypeName=" + datasourceTypeName);
+ }
+ }
+}
diff --git a/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/ConnectorFlightProducer.java b/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/ConnectorFlightProducer.java
index b5ee712f..ae207236 100644
--- a/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/ConnectorFlightProducer.java
+++ b/sdk-gen/subprojects/java/api/src/main/java/com/ibm/connect/sdk/api/ConnectorFlightProducer.java
@@ -9,6 +9,7 @@
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -49,6 +50,20 @@
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetsCriteria;
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightDatasourceType;
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightDatasourceTypes;
+import com.ibm.wdp.connect.sdk.connector.ArrowBatchReader;
+import com.ibm.wdp.connect.sdk.connector.ArrowBatchWriter;
+import com.ibm.wdp.connect.sdk.connector.AssetDescriptor;
+import com.ibm.wdp.connect.sdk.connector.ColumnarArrowBatchReader;
+import com.ibm.wdp.connect.sdk.connector.ColumnarArrowBatchWriter;
+import com.ibm.wdp.connect.sdk.connector.ConnectionProperties;
+import com.ibm.wdp.connect.sdk.connector.DiscoveryCriteria;
+import com.ibm.wdp.connect.sdk.connector.SdkColumnarSourceInteraction;
+import com.ibm.wdp.connect.sdk.connector.SdkColumnarTargetInteraction;
+import com.ibm.wdp.connect.sdk.connector.SdkConnector;
+import com.ibm.wdp.connect.sdk.connector.SdkConnectorFactory;
+import com.ibm.wdp.connect.sdk.connector.SdkDiscoveryInteraction;
+import com.ibm.wdp.connect.sdk.connector.SdkSourceInteraction;
+import com.ibm.wdp.connect.sdk.connector.SdkTargetInteraction;
/**
* An abstract Flight producer for connectors.
@@ -58,6 +73,7 @@ public abstract class ConnectorFlightProducer implements FlightProducer
private static final Logger LOGGER = getLogger(ConnectorFlightProducer.class);
private static final String UNKNOWN_VERSION = "unknown";
+ private static final int DEFAULT_BATCH_SIZE = 1000;
/**
* Action type to check the health of the service and return its version.
@@ -103,10 +119,15 @@ public abstract class ConnectorFlightProducer implements FlightProducer
private final CustomFlightDatasourceTypes datasourceTypes;
/**
- * A factory for creating connectors.
+ * A factory for creating connectors (old path).
*/
private final ConnectorFactory connectorFactory;
+ /**
+ * A factory for creating SDK-style connectors (new path); null when not overridden.
+ */
+ private final SdkConnectorFactory sdkConnectorFactory;
+
private final ModelMapper modelMapper;
private final FlightDescriptorCache descriptorCache;
private final BufferAllocator rootAllocator;
@@ -117,6 +138,7 @@ public abstract class ConnectorFlightProducer implements FlightProducer
public ConnectorFlightProducer()
{
connectorFactory = getConnectorFactory();
+ sdkConnectorFactory = getSdkConnectorFactory();
datasourceTypes = connectorFactory.getDatasourceTypes();
modelMapper = new ModelMapper();
descriptorCache = new FlightDescriptorCache();
@@ -130,6 +152,21 @@ public ConnectorFlightProducer()
*/
abstract protected ConnectorFactory getConnectorFactory();
+ /**
+ * Returns a factory for creating SDK-style connectors, or null if not used.
+ *
+ *
Subclasses that want to use the SDK connector path should override this method
+ * to return their {@link SdkConnectorFactory}. When non-null, all Flight operations
+ * (getStream, getFlightInfo, acceptPut, listFlights) will use the SDK path instead of
+ * the legacy {@link ConnectorFactory} path.
+ *
+ * @return an {@link SdkConnectorFactory}, or null (default)
+ */
+ protected SdkConnectorFactory getSdkConnectorFactory()
+ {
+ return null;
+ }
+
/**
* {@inheritDoc}
*/
@@ -145,6 +182,34 @@ public void getStream(CallContext context, Ticket ticket, ServerStreamListener l
throw new IllegalArgumentException(ApiMsgs.NO_FLIGHT_DESCRIPTOR_FOR_TICKET.format());
}
final CustomFlightAssetDescriptor asset = modelMapper.fromBytes(descriptor.getCommand(), CustomFlightAssetDescriptor.class);
+
+ // --- SDK connector path ---
+ if (sdkConnectorFactory != null) {
+ final AssetDescriptor assetDescriptor = toAssetDescriptor(asset);
+ try (SdkConnector, ?, ?> connector = sdkConnectorFactory.createConnector(
+ asset.getDatasourceTypeName(), toConnectionProperties(asset.getConnectionProperties()))) {
+ connector.connect();
+ try (SdkSourceInteraction interaction = connector.getSourceInteraction(assetDescriptor, ticket)) {
+ final Schema schema = interaction.getSchema();
+ if (interaction instanceof SdkColumnarSourceInteraction) {
+ final int batchSize = getBatchSize(asset);
+ try (ColumnarArrowBatchWriter writer = new ColumnarArrowBatchWriter(schema, rootAllocator, batchSize)) {
+ ((SdkColumnarSourceInteraction) interaction).stream(writer);
+ streamBatches(writer.batches(), schema, listener, bpStrategy);
+ }
+ } else {
+ final int batchSize = getBatchSize(asset);
+ try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, rootAllocator, batchSize)) {
+ interaction.stream(writer);
+ streamBatches(writer.batches(), schema, listener, bpStrategy);
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ // --- Legacy connector path ---
try (Connector, ?> connector
= connectorFactory.createConnector(asset.getDatasourceTypeName(), asset.getConnectionProperties())) {
connector.connect();
@@ -201,6 +266,33 @@ public void listFlights(CallContext context, Criteria criteria, StreamListener connector = sdkConnectorFactory.createConnector(
+ assetsCriteria.getDatasourceTypeName(), toConnectionProperties(assetsCriteria.getConnectionProperties()))) {
+ connector.connect();
+ try (SdkDiscoveryInteraction discovery = connector.getDiscoveryInteraction(discoveryCriteria)) {
+ final List sdkAssets
+ = discovery.discoverAssets(discoveryCriteria);
+ for (final AssetDescriptor sdkAsset : sdkAssets) {
+ final CustomFlightAssetDescriptor asset = fromAssetDescriptor(sdkAsset,
+ assetsCriteria.getDatasourceTypeName(),
+ assetsCriteria.getConnectionProperties());
+ completeAsset(asset);
+ final FlightDescriptor flightDescriptor = FlightDescriptor.command(modelMapper.toBytes(asset));
+ final Schema schema = connector.getSchema(sdkAsset);
+ final FlightInfo flightInfo = createFlightInfo(flightDescriptor, schema, Collections.emptyList());
+ listener.onNext(flightInfo);
+ }
+ }
+ }
+ listener.onCompleted();
+ return;
+ }
+
+ // --- Legacy connector path ---
try (Connector, ?> connector
= connectorFactory.createConnector(assetsCriteria.getDatasourceTypeName(), assetsCriteria.getConnectionProperties())) {
connector.connect();
@@ -245,6 +337,23 @@ public FlightInfo getFlightInfo(CallContext context, FlightDescriptor descriptor
try {
ThreadLocale.setLocale(context);
final CustomFlightAssetDescriptor asset = modelMapper.fromBytes(descriptor.getCommand(), CustomFlightAssetDescriptor.class);
+
+ // --- SDK connector path ---
+ if (sdkConnectorFactory != null) {
+ final AssetDescriptor assetDescriptor = toAssetDescriptor(asset);
+ try (SdkConnector, ?, ?> connector = sdkConnectorFactory.createConnector(
+ asset.getDatasourceTypeName(), toConnectionProperties(asset.getConnectionProperties()))) {
+ connector.connect();
+ try (SdkSourceInteraction interaction = connector.getSourceInteraction(assetDescriptor, null)) {
+ final Schema schema = interaction.getSchema();
+ asset.setFields(Utils.getAssetFields(schema));
+ final List tickets = interaction.getTickets();
+ return createFlightInfo(FlightDescriptor.command(modelMapper.toBytes(asset)), schema, tickets);
+ }
+ }
+ }
+
+ // --- Legacy connector path ---
try (Connector, ?> connector
= connectorFactory.createConnector(asset.getDatasourceTypeName(), asset.getConnectionProperties())) {
connector.connect();
@@ -289,6 +398,41 @@ public Runnable acceptPut(CallContext context, FlightStream flightStream, Stream
throw new IllegalArgumentException(ApiMsgs.MISSING_PARTITION_INDEX.format());
}
asset.setFields(Utils.getAssetFields(flightStream.getSchema()));
+
+ // --- SDK connector path ---
+ if (sdkConnectorFactory != null) {
+ final AssetDescriptor assetDescriptor = toAssetDescriptor(asset);
+ try (SdkConnector, ?, ?> connector = sdkConnectorFactory.createConnector(
+ asset.getDatasourceTypeName(), toConnectionProperties(asset.getConnectionProperties()))) {
+ connector.connect();
+ try (SdkTargetInteraction interaction = connector.getTargetInteraction(assetDescriptor)) {
+ if (asset.getPartitionCount() == null || asset.getPartitionCount() == 1) {
+ interaction.setup();
+ }
+ // Collect all incoming batches from the FlightStream
+ final List batches = new ArrayList<>();
+ while (flightStream.next()) {
+ batches.add(flightStream.getRoot());
+ }
+ if (interaction instanceof SdkColumnarTargetInteraction) {
+ try (ColumnarArrowBatchReader reader = new ColumnarArrowBatchReader(batches)) {
+ ((SdkColumnarTargetInteraction) interaction).consume(reader);
+ }
+ } else {
+ try (ArrowBatchReader reader = new ArrowBatchReader(batches)) {
+ interaction.consume(reader);
+ }
+ }
+ if (asset.getPartitionCount() == null || asset.getPartitionCount() == 1) {
+ interaction.wrapup();
+ }
+ }
+ }
+ ackStream.onCompleted();
+ return;
+ }
+
+ // --- Legacy connector path ---
try (Connector, ?> connector
= connectorFactory.createConnector(asset.getDatasourceTypeName(), asset.getConnectionProperties())) {
connector.connect();
@@ -446,4 +590,98 @@ public void listActions(CallContext context, StreamListener listener
}
}
+ // ---- private helpers ----
+
+ /**
+ * Streams batches from the given iterator to the Flight listener, handling backpressure.
+ */
+ private void streamBatches(Iterator batches, Schema schema,
+ ServerStreamListener listener, BackpressureStrategy bpStrategy) throws Exception
+ {
+ try (VectorSchemaRoot vectorSchemaRoot = VectorSchemaRoot.create(schema, rootAllocator)) {
+ final VectorLoader loader = new VectorLoader(vectorSchemaRoot);
+ listener.start(vectorSchemaRoot);
+ while (batches.hasNext()) {
+ final VectorSchemaRoot batch = batches.next();
+ final VectorUnloader unloader = new VectorUnloader(batch);
+ loader.load(unloader.getRecordBatch());
+ WaitResult wr;
+ while ((wr = bpStrategy.waitForListener(5000)) == WaitResult.TIMEOUT) {
+ LOGGER.info("Waiting for ready from client");
+ }
+ if (wr == WaitResult.CANCELLED) {
+ break;
+ }
+ listener.putNext();
+ vectorSchemaRoot.clear();
+ }
+ if (listener.isCancelled()) {
+ LOGGER.info("Stream has been cancelled");
+ listener.error(CallStatus.CANCELLED.withDescription("Stream cancelled.").toRuntimeException());
+ } else {
+ listener.completed();
+ }
+ }
+ }
+
+ /**
+ * Translates a {@link CustomFlightAssetDescriptor} to an {@link AssetDescriptor}.
+ */
+ private static AssetDescriptor toAssetDescriptor(CustomFlightAssetDescriptor src)
+ {
+ final int batchSize = src.getBatchSize() != null ? src.getBatchSize() : DEFAULT_BATCH_SIZE;
+ return new AssetDescriptor(
+ src.getId(),
+ src.getName(),
+ src.getPath(),
+ src.getDatasourceTypeName(),
+ src.getConnectionProperties(), // ConnectionProperties extends HashMap
+ Boolean.TRUE.equals(src.isHasChildren()),
+ batchSize);
+ }
+
+ /**
+ * Translates a {@link CustomFlightAssetsCriteria} to a {@link DiscoveryCriteria}.
+ */
+ private static DiscoveryCriteria toDiscoveryCriteria(CustomFlightAssetsCriteria src)
+ {
+ final ConnectionProperties connProps = toConnectionProperties(src.getConnectionProperties());
+ return new DiscoveryCriteria(src.getPath(), src.getDatasourceTypeName(), connProps);
+ }
+
+ /**
+ * Translates a model SDK-API {@code ConnectionProperties} to the connector-SDK
+ * {@link ConnectionProperties}.
+ *
+ * The model type extends {@code HashMap} and is passed directly as the
+ * backing map. The parameter must be fully-qualified because both packages expose a class
+ * named {@code ConnectionProperties}.
+ */
+ private static ConnectionProperties toConnectionProperties(
+ com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties src)
+ {
+ return new ConnectionProperties(src);
+ }
+
+ /**
+ * Translates an {@link AssetDescriptor} back to a {@link CustomFlightAssetDescriptor}.
+ */
+ private static CustomFlightAssetDescriptor fromAssetDescriptor(
+ AssetDescriptor src, String datasourceTypeName,
+ com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties connectionProperties)
+ {
+ final CustomFlightAssetDescriptor descriptor = new CustomFlightAssetDescriptor();
+ descriptor.setId(src.getId());
+ descriptor.setName(src.getName());
+ descriptor.setPath(src.getPath());
+ descriptor.setDatasourceTypeName(datasourceTypeName);
+ descriptor.setConnectionProperties(connectionProperties);
+ descriptor.setHasChildren(src.hasChildren());
+ return descriptor;
+ }
+
+ private static int getBatchSize(CustomFlightAssetDescriptor asset)
+ {
+ return (asset.getBatchSize() != null && asset.getBatchSize() > 0) ? asset.getBatchSize() : DEFAULT_BATCH_SIZE;
+ }
}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/build.gradle b/sdk-gen/subprojects/java/connectors_forge_rest/impl/build.gradle
index d8686879..b394d848 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/build.gradle
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/build.gradle
@@ -80,6 +80,7 @@ dependencies {
api project(':' + rootProject.name + '-java-api-models')
api project(':' + rootProject.name + '-java-api')
implementation project(':' + rootProject.name + '-java-util')
+ implementation project(':wdp-connect-sdk-gen-java-sdk-connector-api')
// JSON processing for REST API calls (Java 11+ HttpClient is used, no need for Apache HttpClient)
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: project['jackson.version']
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/AuthenticationType.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/AuthenticationType.java
index f35c123c..44c7e1ac 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/AuthenticationType.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/AuthenticationType.java
@@ -21,31 +21,21 @@ public enum AuthenticationType
private final String value;
- AuthenticationType(String value)
- {
- this.value = value;
- }
-
- public String getValue()
- {
- return value;
- }
+ AuthenticationType(String value) { this.value = value; }
+ public String getValue() { return value; }
public static AuthenticationType fromValue(String value)
{
if (value == null) {
throw new IllegalArgumentException(
- "Authentication type cannot be null. Valid values are: " + validValues());
+ "Authentication type cannot be null. Valid values are: " + validValues());
}
-
final String normalizedValue = value.toLowerCase(Locale.ENGLISH);
for (final AuthenticationType type : values()) {
- if (type.value.equals(normalizedValue)) {
- return type;
- }
+ if (type.value.equals(normalizedValue)) { return type; }
}
throw new IllegalArgumentException(
- "Invalid authentication type: '" + value + "'. Valid values are: " + validValues());
+ "Invalid authentication type: '" + value + "'. Valid values are: " + validValues());
}
public static String validValues()
@@ -55,5 +45,3 @@ public static String validValues()
.collect(Collectors.joining(", "));
}
}
-
-// Made with Bob
\ No newline at end of file
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/ForgeSchemaBuilder.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/ForgeSchemaBuilder.java
new file mode 100644
index 00000000..1e3f796a
--- /dev/null
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/ForgeSchemaBuilder.java
@@ -0,0 +1,168 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.connect.restconnector;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.arrow.vector.types.DateUnit;
+import org.apache.arrow.vector.types.FloatingPointPrecision;
+import org.apache.arrow.vector.types.TimeUnit;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+
+/**
+ * Builds an Arrow {@link Schema} from a list of {@link RestFieldDefinition} objects.
+ *
+ * Replaces the old {@code RestFieldTypeMapper} which produced
+ * {@code CustomFlightAssetField} instances (from the SDK model framework). This class produces
+ * Arrow types directly, with no dependency on the SDK model framework.
+ *
+ *
Supported type strings (case-insensitive):
+ *
+ * - {@code VarChar(N)} → {@code Utf8} (Arrow), nullable based on $notnull modifier
+ * - {@code LongVarChar(N)} → {@code Utf8}
+ * - {@code Integer} / {@code Int} → {@code Int(32, signed)}
+ * - {@code BigInt} → {@code Int(64, signed)}
+ * - {@code SmallInt} → {@code Int(16, signed)}
+ * - {@code TinyInt} → {@code Int(8, signed)}
+ * - {@code Boolean} → {@code Bool}
+ * - {@code Date} → {@code Date(DAY)}
+ * - {@code Timestamp} / {@code DateTime} → {@code Timestamp(MICROSECOND, UTC)}
+ * - {@code Time} → {@code Time(MILLISECOND, 32)}
+ * - {@code Double} / {@code Float8} → {@code FloatingPoint(DOUBLE)}
+ * - {@code Float} / {@code Real} / {@code Float4} → {@code FloatingPoint(SINGLE)}
+ * - {@code Decimal} / {@code Numeric} → {@code Decimal(38, 10)}
+ * - {@code JSON} / {@code JSONB} / {@code Array} / {@code Object} → {@code Utf8}
+ * - All other / unknown → {@code Utf8}
+ *
+ */
+public class ForgeSchemaBuilder
+{
+ /** Matches types with a length parameter, e.g. VarChar(50) or LongVarChar(2000). */
+ private static final Pattern TYPE_WITH_LENGTH = Pattern.compile("^(\\w+)\\((\\d+)\\)$");
+
+ private ForgeSchemaBuilder()
+ {
+ // utility class
+ }
+
+ /**
+ * Builds an Arrow {@link Schema} from the given list of field definitions.
+ *
+ * @param fieldDefs
+ * the field definitions from the JSON configuration file
+ * @return the Arrow schema
+ */
+ public static Schema buildSchema(List fieldDefs)
+ {
+ final List fields = new ArrayList<>(fieldDefs.size());
+ for (final RestFieldDefinition fieldDef : fieldDefs) {
+ fields.add(toArrowField(fieldDef));
+ }
+ return new Schema(fields);
+ }
+
+ /**
+ * Converts a single {@link RestFieldDefinition} to an Arrow {@link Field}.
+ *
+ * @param fieldDef
+ * the field definition
+ * @return the Arrow field
+ */
+ public static Field toArrowField(RestFieldDefinition fieldDef)
+ {
+ final boolean nullable = !fieldDef.isNotNull();
+ final ArrowType arrowType = toArrowType(fieldDef.getTypeString());
+ return new Field(fieldDef.getName(), new FieldType(nullable, arrowType, null), null);
+ }
+
+ // ---- private helpers ----
+
+ private static ArrowType toArrowType(String typeString)
+ {
+ final String trimmed = typeString.trim();
+
+ // Check for types with length parameter: VarChar(N), LongVarChar(N), etc.
+ final Matcher lengthMatcher = TYPE_WITH_LENGTH.matcher(trimmed);
+ if (lengthMatcher.matches()) {
+ final String baseType = lengthMatcher.group(1).toLowerCase(Locale.ENGLISH);
+ switch (baseType) {
+ case "varchar":
+ case "nvarchar":
+ case "char":
+ case "nchar":
+ case "longvarchar":
+ case "longnvarchar":
+ case "clob":
+ case "nclob":
+ return ArrowType.Utf8.INSTANCE;
+ case "varbinary":
+ case "binary":
+ case "blob":
+ return ArrowType.Binary.INSTANCE;
+ default:
+ return ArrowType.Utf8.INSTANCE;
+ }
+ }
+
+ // Simple types without length
+ final String typeLower = trimmed.toLowerCase(Locale.ENGLISH);
+ switch (typeLower) {
+ case "integer":
+ case "int":
+ return new ArrowType.Int(32, true);
+ case "bigint":
+ return new ArrowType.Int(64, true);
+ case "smallint":
+ return new ArrowType.Int(16, true);
+ case "tinyint":
+ return new ArrowType.Int(8, true);
+ case "boolean":
+ case "bool":
+ case "bit":
+ return ArrowType.Bool.INSTANCE;
+ case "date":
+ return new ArrowType.Date(DateUnit.DAY);
+ case "timestamp":
+ case "datetime":
+ return new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC");
+ case "time":
+ return new ArrowType.Time(TimeUnit.MILLISECOND, 32);
+ case "double":
+ case "float8":
+ return new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE);
+ case "float":
+ case "real":
+ case "float4":
+ return new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE);
+ case "decimal":
+ case "numeric":
+ return new ArrowType.Decimal(38, 10, 128);
+ case "json":
+ case "jsonb":
+ case "array":
+ case "object":
+ case "varchar":
+ case "nvarchar":
+ case "char":
+ case "nchar":
+ case "longvarchar":
+ case "longnvarchar":
+ case "clob":
+ case "text":
+ return ArrowType.Utf8.INSTANCE;
+ default:
+ // Unknown type — default to Utf8
+ return ArrowType.Utf8.INSTANCE;
+ }
+ }
+}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/JsonToArrowStream.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/JsonToArrowStream.java
new file mode 100644
index 00000000..dee4eec1
--- /dev/null
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/JsonToArrowStream.java
@@ -0,0 +1,564 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.connect.restconnector;
+
+import static org.slf4j.LoggerFactory.getLogger;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.sql.Date;
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.ibm.wdp.connect.sdk.connector.RowWriter;
+
+/**
+ * Fetches data from a REST API endpoint and streams each JSON object to a {@link RowWriter}.
+ *
+ * This is the forge REST engine's JSON-to-Arrow streaming component. It replaces the old
+ * {@code JsonToRecordStream} — the only change is that instead of creating {@code Record} objects
+ * it calls {@link RowWriter#startRow()}, {@link RowWriter#set(String, Object)},
+ * and {@link RowWriter#endRow()} for each JSON object.
+ *
+ *
The HTTP connection is opened lazily. The JSON response is parsed using Jackson's streaming
+ * API to avoid loading the entire response into memory.
+ *
+ *
Supports all pagination strategies in {@link PaginationType}.
+ */
+public class JsonToArrowStream implements Closeable
+{
+ private static final Logger LOGGER = getLogger(JsonToArrowStream.class);
+
+ private static final int HTTP_TIMEOUT_SECONDS = 60;
+ private static final int HTTP_OK = 200;
+ private static final int MAX_PAGES = 10000;
+
+ private final String baseUrl;
+ private final String dataPath;
+ private final List fieldDefs;
+ private final Map authHeaders;
+ private final PaginationConfig paginationConfig;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Creates a streaming instance for the given URL and field definitions.
+ *
+ * @param url
+ * the full URL to fetch (e.g. "https://api.spacexdata.com/v4/rockets")
+ * @param fieldDefs
+ * the field definitions that define the expected fields and their types
+ */
+ public JsonToArrowStream(String url, List fieldDefs)
+ {
+ this(url, null, fieldDefs, null, null);
+ }
+
+ /**
+ * Creates a streaming instance with authentication headers.
+ *
+ * @param url
+ * the full URL to fetch
+ * @param fieldDefs
+ * the field definitions
+ * @param authHeaders
+ * optional authentication headers (may be null)
+ */
+ public JsonToArrowStream(String url, List fieldDefs, Map authHeaders)
+ {
+ this(url, null, fieldDefs, authHeaders, null);
+ }
+
+ /**
+ * Creates a streaming instance with data path and authentication headers.
+ *
+ * @param url
+ * the full URL to fetch
+ * @param dataPath
+ * optional JSON path to the data array (e.g. "result" for {"result": [...]})
+ * @param fieldDefs
+ * the field definitions
+ * @param authHeaders
+ * optional authentication headers (may be null)
+ */
+ public JsonToArrowStream(String url, String dataPath, List fieldDefs,
+ Map authHeaders)
+ {
+ this(url, dataPath, fieldDefs, authHeaders, null);
+ }
+
+ /**
+ * Creates a streaming instance with full configuration including pagination support.
+ *
+ * @param url
+ * the base URL to fetch
+ * @param dataPath
+ * optional JSON path to the data array
+ * @param fieldDefs
+ * the field definitions
+ * @param authHeaders
+ * optional authentication headers (may be null)
+ * @param paginationConfig
+ * optional pagination configuration (may be null for non-paginated APIs)
+ */
+ public JsonToArrowStream(String url, String dataPath, List fieldDefs,
+ Map authHeaders, PaginationConfig paginationConfig)
+ {
+ this.baseUrl = url;
+ this.dataPath = dataPath;
+ this.fieldDefs = fieldDefs;
+ this.authHeaders = authHeaders;
+ this.paginationConfig = paginationConfig;
+ this.objectMapper = new ObjectMapper();
+ }
+
+ /**
+ * Fetches all data from the configured URL and streams it into the provided {@link RowWriter}.
+ *
+ * For each JSON object in the response, calls:
+ *
+ * - {@link RowWriter#startRow()}
+ * - {@link RowWriter#set(String, Object)} for each field
+ * - {@link RowWriter#endRow()}
+ *
+ *
+ * @param writer
+ * the row writer to receive the data
+ * @throws IOException
+ * if an HTTP or JSON error occurs
+ * @throws InterruptedException
+ * if the thread is interrupted during an HTTP request
+ */
+ public void streamTo(RowWriter writer) throws IOException, InterruptedException
+ {
+ final HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
+ .build();
+
+ // Pagination state
+ int currentOffset = paginationConfig != null ? paginationConfig.getInitialOffset() : 0;
+ int currentPage = paginationConfig != null ? paginationConfig.getInitialPage() : 1;
+ String nextCursor = null;
+ String nextPageUrl = null;
+ int totalPagesFetched = 0;
+
+ do {
+ if (totalPagesFetched >= MAX_PAGES) {
+ LOGGER.warn("Reached maximum page limit of {}", MAX_PAGES);
+ break;
+ }
+
+ final String url = buildCurrentPageUrl(currentOffset, currentPage, nextCursor, nextPageUrl);
+ LOGGER.debug("Fetching page {}: {}", totalPagesFetched + 1, url);
+
+ final HttpRequest request = buildRequest(url);
+ final HttpResponse response = httpClient.send(request, BodyHandlers.ofInputStream());
+
+ if (response.statusCode() != HTTP_OK) {
+ throw new IOException("HTTP request failed with status " + response.statusCode());
+ }
+
+ final java.net.http.HttpHeaders headers = response.headers();
+
+ // For link_header pagination, extract next URL from response headers
+ if (paginationConfig != null && "link_header".equals(paginationConfig.getType())) {
+ nextPageUrl = extractLinkHeader(headers);
+ }
+
+ totalPagesFetched++;
+ int recordsInPage = 0;
+
+ // Cursor and next_url need full parse to extract metadata
+ if (paginationConfig != null &&
+ ("cursor".equals(paginationConfig.getType()) || "next_url".equals(paginationConfig.getType()))) {
+
+ final JsonNode rootNode = objectMapper.readTree(response.body());
+
+ if ("cursor".equals(paginationConfig.getType())) {
+ final JsonNode cursorNode = extractJsonPath(rootNode, paginationConfig.getNextCursorPath());
+ nextCursor = (cursorNode != null && !cursorNode.isNull()) ? cursorNode.asText() : null;
+ } else {
+ final JsonNode urlNode = extractJsonPath(rootNode, paginationConfig.getNextUrlPath());
+ nextPageUrl = (urlNode != null && !urlNode.isNull()) ? urlNode.asText() : null;
+ }
+
+ JsonNode dataNode = rootNode;
+ if (dataPath != null && !dataPath.isEmpty()) {
+ dataNode = extractJsonPath(rootNode, dataPath);
+ if (dataNode == null) {
+ throw new IOException("Data path '" + dataPath + "' not found in JSON response");
+ }
+ }
+
+ recordsInPage = streamNodeToWriter(dataNode, writer);
+
+ } else {
+ // Streaming parse for offset/page/link_header/no-pagination
+ final JsonFactory factory = new JsonFactory();
+ try (JsonParser jsonParser = factory.createParser(response.body())) {
+ recordsInPage = streamParserToWriter(jsonParser, writer);
+ }
+ }
+
+ // Advance pagination state
+ if (paginationConfig != null) {
+ if ("offset".equals(paginationConfig.getType())) {
+ currentOffset += paginationConfig.getPageSize();
+ } else if ("page".equals(paginationConfig.getType())) {
+ currentPage++;
+ }
+ }
+
+ // Determine if there are more pages
+ if (!hasMorePages(paginationConfig, recordsInPage, nextCursor, nextPageUrl, totalPagesFetched)) {
+ break;
+ }
+
+ } while (true);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void close()
+ {
+ // No persistent resources to close in this stateless implementation
+ }
+
+ // ---- private helpers ----
+
+ private int streamNodeToWriter(JsonNode dataNode, RowWriter writer) throws IOException
+ {
+ int count = 0;
+ if (dataNode.isArray()) {
+ for (final JsonNode item : dataNode) {
+ if (item.isObject()) {
+ writeObjectToWriter((ObjectNode) item, writer);
+ count++;
+ }
+ }
+ } else if (dataNode.isObject()) {
+ writeObjectToWriter((ObjectNode) dataNode, writer);
+ count = 1;
+ }
+ return count;
+ }
+
+ private int streamParserToWriter(JsonParser jsonParser, RowWriter writer) throws IOException
+ {
+ int count = 0;
+ JsonToken firstToken = jsonParser.nextToken();
+
+ if (dataPath != null && !dataPath.isEmpty()) {
+ if (firstToken == JsonToken.START_ARRAY) {
+ // Root is an array (e.g. NBP API returns [{...}]) — fall back to tree-mode
+ // so that extractJsonPath can handle numeric index segments like "0.rates".
+ // JsonParser is already positioned AT START_ARRAY so readTree reads the full array.
+ final JsonNode rootNode = objectMapper.readTree(jsonParser);
+ final JsonNode dataNode = extractJsonPath(rootNode, dataPath);
+ if (dataNode == null) {
+ throw new IOException("Data path '" + dataPath + "' not found in JSON response");
+ }
+ return streamNodeToWriter(dataNode, writer);
+ }
+ if (firstToken != JsonToken.START_OBJECT) {
+ throw new IOException("Expected START_OBJECT or START_ARRAY at root when dataPath is specified, got: " + firstToken);
+ }
+ boolean found = false;
+ while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
+ if (dataPath.equals(jsonParser.currentName())) {
+ firstToken = jsonParser.nextToken();
+ found = true;
+ break;
+ }
+ jsonParser.skipChildren();
+ }
+ if (!found) {
+ throw new IOException("Data path '" + dataPath + "' not found in JSON response");
+ }
+ }
+
+ if (firstToken == JsonToken.START_ARRAY) {
+ JsonToken token;
+ while ((token = jsonParser.nextToken()) != JsonToken.END_ARRAY && token != null) {
+ if (token == JsonToken.START_OBJECT) {
+ final ObjectNode objectNode = objectMapper.readTree(jsonParser);
+ writeObjectToWriter(objectNode, writer);
+ count++;
+ }
+ }
+ } else if (firstToken == JsonToken.START_OBJECT) {
+ final ObjectNode objectNode = objectMapper.readTree(jsonParser);
+ writeObjectToWriter(objectNode, writer);
+ count = 1;
+ }
+
+ return count;
+ }
+
+ private void writeObjectToWriter(ObjectNode objectNode, RowWriter writer) throws IOException
+ {
+ writer.startRow();
+ for (final RestFieldDefinition fieldDef : fieldDefs) {
+ final String fieldName = fieldDef.getName();
+ final JsonNode valueNode = fieldName.contains(".")
+ ? getNestedValue(objectNode, fieldName)
+ : objectNode.get(fieldName);
+ writer.set(fieldName, convertValue(valueNode, fieldDef));
+ }
+ writer.endRow();
+ }
+
+ private static JsonNode getNestedValue(ObjectNode objectNode, String flattenedName)
+ {
+ final int dotIndex = flattenedName.indexOf('.');
+ if (dotIndex < 0) {
+ return objectNode.get(flattenedName);
+ }
+ final String parentKey = flattenedName.substring(0, dotIndex);
+ final String remainingPath = flattenedName.substring(dotIndex + 1);
+ final JsonNode parentNode = objectNode.get(parentKey);
+ if (parentNode == null || !parentNode.isObject()) {
+ return null;
+ }
+ if (remainingPath.contains(".")) {
+ return getNestedValue((ObjectNode) parentNode, remainingPath);
+ }
+ return parentNode.get(remainingPath);
+ }
+
+ private static Object convertValue(JsonNode node, RestFieldDefinition fieldDef)
+ {
+ if (node == null || node.isNull()) {
+ return null;
+ }
+ if (node.isObject() || node.isArray()) {
+ return node.toString();
+ }
+
+ final String baseType = extractBaseType(fieldDef.getTypeString().toLowerCase(Locale.ENGLISH));
+
+ switch (baseType) {
+ case "integer":
+ case "int":
+ case "smallint":
+ case "tinyint":
+ return node.isNumber() ? node.intValue() : parseIntSafe(node.asText());
+ case "bigint":
+ return node.isNumber() ? node.longValue() : parseLongSafe(node.asText());
+ case "boolean":
+ case "bool":
+ case "bit":
+ return node.isBoolean() ? node.booleanValue() : Boolean.parseBoolean(node.asText());
+ case "double":
+ case "float8":
+ case "float":
+ case "real":
+ case "float4":
+ return node.isNumber() ? node.doubleValue() : parseDoubleSafe(node.asText());
+ case "date":
+ return parseDateSafe(node.asText());
+ case "timestamp":
+ case "datetime":
+ return parseTimestampSafe(node.asText());
+ case "json":
+ case "jsonb":
+ case "array":
+ case "object":
+ return node.toString();
+ default:
+ return node.asText();
+ }
+ }
+
+ private static String extractBaseType(String typeLower)
+ {
+ final int parenIdx = typeLower.indexOf('(');
+ return parenIdx >= 0 ? typeLower.substring(0, parenIdx).trim() : typeLower.trim();
+ }
+
+ private static Integer parseIntSafe(String text)
+ {
+ if (text == null || text.isEmpty()) return null;
+ try { return Integer.parseInt(text.trim()); }
+ catch (NumberFormatException e) { LOGGER.warn("Cannot parse integer: '{}'", text); return null; }
+ }
+
+ private static Long parseLongSafe(String text)
+ {
+ if (text == null || text.isEmpty()) return null;
+ try { return Long.parseLong(text.trim()); }
+ catch (NumberFormatException e) { LOGGER.warn("Cannot parse long: '{}'", text); return null; }
+ }
+
+ private static Double parseDoubleSafe(String text)
+ {
+ if (text == null || text.isEmpty()) return null;
+ try { return Double.parseDouble(text.trim()); }
+ catch (NumberFormatException e) { LOGGER.warn("Cannot parse double: '{}'", text); return null; }
+ }
+
+ private static Date parseDateSafe(String text)
+ {
+ if (text == null || text.isEmpty()) return null;
+ try { return Date.valueOf(LocalDate.parse(text.trim())); }
+ catch (DateTimeParseException e) { LOGGER.warn("Cannot parse date: '{}'", text); return null; }
+ }
+
+ private static Timestamp parseTimestampSafe(String text)
+ {
+ if (text == null || text.isEmpty()) return null;
+ try { return Timestamp.from(Instant.parse(text.trim())); }
+ catch (DateTimeParseException e) {
+ try { return Timestamp.valueOf(text.trim().replace("T", " ").replaceAll("\\.\\d+Z?$", "")); }
+ catch (Exception ex) { LOGGER.warn("Cannot parse timestamp: '{}'", text); return null; }
+ }
+ }
+
+ private HttpRequest buildRequest(String url)
+ {
+ final HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
+ .header("Accept", "application/json")
+ .header("User-Agent", "CP4D-REST-Connector/1.0")
+ .GET();
+ if (authHeaders != null) {
+ authHeaders.forEach(builder::header);
+ }
+ return builder.build();
+ }
+
+ private String buildCurrentPageUrl(int currentOffset, int currentPage, String nextCursor, String nextPageUrl)
+ {
+ if (paginationConfig == null) {
+ return baseUrl;
+ }
+
+ final String type = paginationConfig.getType();
+ final boolean hasParams = baseUrl.contains("?");
+ final String sep = hasParams ? "&" : "?";
+ final StringBuilder sb = new StringBuilder(baseUrl);
+
+ switch (type) {
+ case "offset":
+ sb.append(sep).append(paginationConfig.getOffsetParam()).append('=').append(currentOffset);
+ if (paginationConfig.getLimitParam() != null) {
+ sb.append('&').append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
+ }
+ break;
+ case "page":
+ sb.append(sep).append(paginationConfig.getPageParam()).append('=').append(currentPage);
+ if (paginationConfig.getLimitParam() != null) {
+ sb.append('&').append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
+ }
+ break;
+ case "cursor":
+ if (nextCursor != null && !nextCursor.isEmpty()) {
+ sb.append(sep).append(paginationConfig.getCursorParam()).append('=').append(nextCursor);
+ if (paginationConfig.getLimitParam() != null) {
+ sb.append('&').append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
+ }
+ } else if (paginationConfig.getLimitParam() != null) {
+ sb.append(sep).append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
+ }
+ break;
+ case "link_header":
+ case "next_url":
+ if (nextPageUrl != null && !nextPageUrl.isEmpty()) {
+ return nextPageUrl;
+ }
+ if (paginationConfig.getLimitParam() != null) {
+ sb.append(sep).append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
+ }
+ break;
+ default:
+ break;
+ }
+
+ return sb.toString();
+ }
+
+ private static boolean hasMorePages(PaginationConfig config, int recordsInPage, String nextCursor,
+ String nextPageUrl, int totalPagesFetched)
+ {
+ if (config == null || totalPagesFetched >= MAX_PAGES) {
+ return false;
+ }
+ switch (config.getType()) {
+ case "offset":
+ case "page":
+ return recordsInPage >= config.getPageSize();
+ case "cursor":
+ return nextCursor != null && !nextCursor.isEmpty();
+ case "link_header":
+ case "next_url":
+ return nextPageUrl != null && !nextPageUrl.isEmpty();
+ default:
+ return false;
+ }
+ }
+
+ private static String extractLinkHeader(java.net.http.HttpHeaders headers)
+ {
+ final java.util.Optional linkHeader = headers.firstValue("Link");
+ if (!linkHeader.isPresent()) {
+ return null;
+ }
+ for (final String link : linkHeader.get().split(",")) {
+ if (link.contains("rel=\"next\"") || link.contains("rel='next'")) {
+ final int start = link.indexOf('<') + 1;
+ final int end = link.indexOf('>');
+ if (start > 0 && end > start) {
+ return link.substring(start, end);
+ }
+ }
+ }
+ return null;
+ }
+
+ private static JsonNode extractJsonPath(JsonNode node, String path)
+ {
+ if (path == null || path.isEmpty()) {
+ return node;
+ }
+ JsonNode current = node;
+ for (final String segment : path.split("\\.")) {
+ if (current == null || current.isNull()) {
+ return null;
+ }
+ if (current.isArray()) {
+ // ArrayNode.get(String) returns null — must use get(int)
+ try {
+ current = current.get(Integer.parseInt(segment));
+ } catch (NumberFormatException e) {
+ return null; // non-numeric key on an array — path not found
+ }
+ } else {
+ current = current.get(segment);
+ }
+ }
+ return current;
+ }
+}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/JsonToRecordStream.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/JsonToRecordStream.java
deleted file mode 100644
index 1935b218..00000000
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/JsonToRecordStream.java
+++ /dev/null
@@ -1,940 +0,0 @@
-/* *************************************************** */
-
-/* (C) Copyright IBM Corp. 2026 */
-
-/* *************************************************** */
-package com.ibm.connect.restconnector;
-
-import static org.slf4j.LoggerFactory.getLogger;
-
-import java.io.Closeable;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.net.http.HttpResponse.BodyHandlers;
-import java.sql.Date;
-import java.sql.Timestamp;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.format.DateTimeParseException;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Locale;
-import java.util.NoSuchElementException;
-
-import org.slf4j.Logger;
-
-import com.fasterxml.jackson.core.JsonFactory;
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.core.JsonToken;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.ibm.connect.sdk.api.Record;
-
-/**
- * A streaming iterator that fetches data from a REST API endpoint and converts
- * each JSON object in the response array into a {@link Record}.
- *
- * The HTTP connection is opened lazily on the first call to {@link #hasNext()}.
- * The JSON response is parsed using Jackson's streaming API to avoid loading
- * the entire response into memory.
- *
- *
The response is expected to be a JSON array of objects. Each object is
- * converted to a {@link Record} with values in the order defined by the
- * field definitions.
- */
-public class JsonToRecordStream implements Iterator, Closeable
-{
- private static final Logger LOGGER = getLogger(JsonToRecordStream.class);
-
- private static final int HTTP_TIMEOUT_SECONDS = 60;
- private static final int HTTP_OK = 200;
- private static final int MAX_PAGES = 10000; // Safety limit to prevent infinite loops
-
- private final String baseUrl;
- private final String dataPath;
- private final List fieldDefs;
- private final java.util.Map authHeaders;
- private final PaginationConfig paginationConfig;
- private final ObjectMapper objectMapper;
-
- private HttpClient httpClient;
- private InputStream responseStream;
- private JsonParser jsonParser;
- private java.net.http.HttpHeaders lastResponseHeaders;
-
- private Record nextRecord;
- private boolean initialized;
- private boolean done;
-
- // Pagination state
- private int currentOffset;
- private int currentPage;
- private int recordsInCurrentPage;
- private String nextCursor;
- private String nextPageUrl;
- private int totalPagesFetched;
-
- /**
- * Creates a streaming record iterator for the given URL and field definitions.
- *
- * @param url
- * the full URL to fetch (e.g. "https://api.spacexdata.com/v4/rockets")
- * @param fieldDefs
- * the field definitions that define the expected fields and their types
- */
- public JsonToRecordStream(String url, List fieldDefs)
- {
- this(url, null, fieldDefs, null, null);
- }
-
- /**
- * Creates a streaming record iterator for the given URL, field definitions, and authentication headers.
- *
- * @param url
- * the full URL to fetch (e.g. "https://api.spacexdata.com/v4/rockets")
- * @param fieldDefs
- * the field definitions that define the expected fields and their types
- * @param authHeaders
- * optional authentication headers to include in the HTTP request (may be null)
- */
- public JsonToRecordStream(String url, List fieldDefs, java.util.Map authHeaders)
- {
- this(url, null, fieldDefs, authHeaders, null);
- }
-
- /**
- * Creates a streaming record iterator for the given URL, data path, field definitions, and authentication headers.
- *
- * @param url
- * the full URL to fetch (e.g. "https://api.spacexdata.com/v4/rockets")
- * @param dataPath
- * optional JSON path to the data array (e.g. "result" for {"result": [...]})
- * @param fieldDefs
- * the field definitions that define the expected fields and their types
- * @param authHeaders
- * optional authentication headers to include in the HTTP request (may be null)
- */
- public JsonToRecordStream(String url, String dataPath, List fieldDefs, java.util.Map authHeaders)
- {
- this(url, dataPath, fieldDefs, authHeaders, null);
- }
-
- /**
- * Creates a streaming record iterator with full configuration including pagination support.
- *
- * @param url
- * the base URL to fetch (e.g. "https://api.spacexdata.com/v4/rockets")
- * @param dataPath
- * optional JSON path to the data array (e.g. "result" for {"result": [...]})
- * @param fieldDefs
- * the field definitions that define the expected fields and their types
- * @param authHeaders
- * optional authentication headers to include in the HTTP request (may be null)
- * @param paginationConfig
- * optional pagination configuration (may be null for non-paginated APIs)
- */
- public JsonToRecordStream(String url, String dataPath, List fieldDefs,
- java.util.Map authHeaders, PaginationConfig paginationConfig)
- {
- this.baseUrl = url;
- this.dataPath = dataPath;
- this.fieldDefs = fieldDefs;
- this.authHeaders = authHeaders;
- this.paginationConfig = paginationConfig;
- this.objectMapper = new ObjectMapper();
- this.initialized = false;
- this.done = false;
-
- // Initialize pagination state
- this.recordsInCurrentPage = 0;
- this.totalPagesFetched = 0;
- if (paginationConfig != null) {
- this.currentOffset = paginationConfig.getInitialOffset();
- this.currentPage = paginationConfig.getInitialPage();
- }
- }
-
- /**
- * {@inheritDoc}
- */
- @Override
- public boolean hasNext()
- {
- if (!initialized) {
- try {
- initialize();
- }
- catch (Exception e) {
- LOGGER.error("Failed to initialize HTTP stream", e);
- done = true;
- return false;
- }
- }
- if (nextRecord != null) {
- return true;
- }
- if (done) {
- return false;
- }
- try {
- nextRecord = advance();
- }
- catch (Exception e) {
- LOGGER.error("Error reading next record from stream", e);
- done = true;
- return false;
- }
- return nextRecord != null;
- }
-
- /**
- * {@inheritDoc}
- */
- @Override
- public Record next()
- {
- if (!hasNext()) {
- throw new NoSuchElementException("No more records in stream");
- }
- final Record record = nextRecord;
- nextRecord = null;
- return record;
- }
-
- /**
- * {@inheritDoc}
- */
- @Override
- public void close() throws IOException
- {
- done = true;
- if (jsonParser != null) {
- try {
- jsonParser.close();
- }
- catch (IOException e) {
- LOGGER.warn("Error closing JSON parser", e);
- }
- }
- if (responseStream != null) {
- try {
- responseStream.close();
- }
- catch (IOException e) {
- LOGGER.warn("Error closing HTTP response stream", e);
- }
- }
- }
-
- /**
- * Opens the HTTP connection and positions the JSON parser at the first object.
- */
- private void initialize() throws IOException, InterruptedException
- {
- // Build the initial URL with pagination parameters if configured
- final String currentUrl = buildCurrentPageUrl();
-
- LOGGER.info("Opening HTTP stream");
- httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
- .build();
-
- final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
- .uri(URI.create(currentUrl))
- .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
- .header("Accept", "application/json")
- .header("User-Agent", "CP4D-REST-Connector/1.0");
-
- // Add authentication headers if provided
- if (authHeaders != null && !authHeaders.isEmpty()) {
- for (final java.util.Map.Entry header : authHeaders.entrySet()) {
- requestBuilder.header(header.getKey(), header.getValue());
- LOGGER.debug("Adding authentication header: {}", header.getKey());
- }
- }
-
- final HttpRequest request = requestBuilder.GET().build();
-
- final HttpResponse response = httpClient.send(request, BodyHandlers.ofInputStream());
-
- if (response.statusCode() != HTTP_OK) {
- throw new IOException("HTTP request failed with status " + response.statusCode());
- }
-
- // Store response headers for Link header pagination
- lastResponseHeaders = response.headers();
-
- // Extract next page URL from Link header if using link_header pagination
- if (paginationConfig != null && "link_header".equals(paginationConfig.getType())) {
- nextPageUrl = extractLinkHeader(lastResponseHeaders);
- }
-
- responseStream = response.body();
- totalPagesFetched++;
-
- // For cursor and next_url pagination, we need to parse the full response to extract metadata
- if (paginationConfig != null &&
- ("cursor".equals(paginationConfig.getType()) || "next_url".equals(paginationConfig.getType()))) {
- parseResponseWithMetadata();
- } else {
- final JsonFactory factory = new JsonFactory();
- jsonParser = factory.createParser(responseStream);
- // Navigate to the data array
- navigateToDataArray();
- }
-
- // Only pre-fetch if not already initialized (single object case)
- if (!initialized) {
- initialized = true;
- // Pre-fetch the first record
- nextRecord = advance();
- }
- }
-
- /**
- * Navigates the JSON parser to the data array.
- * Handles both root-level arrays and nested arrays specified by dataPath.
- *
- * @throws IOException if an I/O error occurs or the expected structure is not found
- */
- private void navigateToDataArray() throws IOException
- {
- // Advance to the first token
- JsonToken firstToken = jsonParser.nextToken();
-
- // If dataPath is specified, navigate to the nested array
- if (dataPath != null && !dataPath.isEmpty()) {
- LOGGER.debug("Navigating to configured nested data path");
- if (firstToken != JsonToken.START_OBJECT) {
- throw new IOException("Expected START_OBJECT at root when dataPath is specified, but got: " + firstToken);
- }
-
- // Navigate through the JSON structure to find the data path
- boolean found = false;
- while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
- final String fieldName = jsonParser.currentName();
-
- if (dataPath.equals(fieldName)) {
- // Move to the value of this field
- firstToken = jsonParser.nextToken();
- found = true;
- LOGGER.debug("Found configured data path, token type: {}", firstToken);
- break;
- }
- // Skip this field's value
- jsonParser.skipChildren();
- }
-
- if (!found) {
- throw new IOException("Data path '" + dataPath + "' not found in JSON response");
- }
- }
-
- if (firstToken == JsonToken.START_ARRAY) {
- // JSON array response — advance past the START_ARRAY token
- // The parser is now positioned before the first object
- LOGGER.debug("Response is a JSON array");
- } else if (firstToken == JsonToken.START_OBJECT) {
- // Single JSON object response — we'll read it as one record
- // Push back by not advancing — we'll handle it in advance()
- LOGGER.debug("Response is a single JSON object");
- // We need to re-read this object, so we set a flag
- // We handle this by reading the current object directly
- final Record record = readCurrentObject();
- nextRecord = record;
- done = true; // Only one record
- initialized = true;
- } else {
- throw new IOException("Unexpected JSON token at start of response: " + firstToken);
- }
- }
-
- /**
- * Advances the parser to the next JSON object and converts it to a Record.
- * Handles pagination by fetching the next page when the current page is exhausted.
- *
- * @return the next Record, or null if there are no more records
- */
- private Record advance() throws IOException
- {
- if (done) {
- return null;
- }
-
- // Peek at the next token
- final JsonToken token = jsonParser.nextToken();
- if (token == null || token == JsonToken.END_ARRAY) {
- // Current page exhausted - check if there are more pages
- if (paginationConfig != null && hasMorePages()) {
- try {
- fetchNextPage();
- // Recursively call advance() to read from the new page
- return advance();
- } catch (final InterruptedException e) {
- Thread.currentThread().interrupt();
- LOGGER.error("Interrupted while fetching next page", e);
- done = true;
- return null;
- }
- }
- done = true;
- return null;
- }
-
- if (token != JsonToken.START_OBJECT) {
- LOGGER.warn("Expected START_OBJECT but got: {}", token);
- done = true;
- return null;
- }
-
- // Successfully read an object - increment counter for this page
- recordsInCurrentPage++;
- return readCurrentObject();
- }
-
- /**
- * Checks if there are more pages to fetch based on pagination configuration.
- *
- * @return true if more pages should be fetched, false otherwise
- */
- private boolean hasMorePages()
- {
- // Safety check: don't fetch more than MAX_PAGES
- if (totalPagesFetched >= MAX_PAGES) {
- LOGGER.warn("Reached maximum page limit of {}", MAX_PAGES);
- return false;
- }
-
- final String type = paginationConfig.getType();
-
- if ("offset".equals(type) || "page".equals(type)) {
- // For offset/page pagination, continue if we got a full page
- // If we got fewer records than page_size, we've reached the end
- return recordsInCurrentPage >= paginationConfig.getPageSize();
- }
-
- if ("cursor".equals(type)) {
- // For cursor pagination, continue if we have a next cursor
- return nextCursor != null && !nextCursor.isEmpty();
- }
-
- if ("link_header".equals(type) || "next_url".equals(type)) {
- // For link_header/next_url pagination, continue if we have a next URL
- return nextPageUrl != null && !nextPageUrl.isEmpty();
- }
-
- return false;
- }
-
- /**
- * Fetches the next page of data by closing the current stream and opening a new one.
- *
- * @throws IOException if an I/O error occurs
- * @throws InterruptedException if the thread is interrupted
- */
- private void fetchNextPage() throws IOException, InterruptedException
- {
- LOGGER.debug("Fetching next page (current page: {}, records in page: {})",
- totalPagesFetched, recordsInCurrentPage);
-
- // Close current parser and stream
- if (jsonParser != null) {
- jsonParser.close();
- }
- if (responseStream != null) {
- responseStream.close();
- }
-
- // Update pagination state based on type
- final String type = paginationConfig.getType();
- if ("offset".equals(type)) {
- currentOffset += paginationConfig.getPageSize();
- } else if ("page".equals(type)) {
- currentPage++;
- }
- // For cursor/link_header/next_url, the state is already updated
-
- // Reset page record counter
- recordsInCurrentPage = 0;
-
- // Build URL for next page
- final String nextUrl = buildCurrentPageUrl();
- LOGGER.debug("Fetching next page");
-
- // Make HTTP request
- final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
- .uri(URI.create(nextUrl))
- .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
- .header("Accept", "application/json")
- .header("User-Agent", "CP4D-REST-Connector/1.0")
- .GET();
-
- // Add authentication headers
- if (authHeaders != null && !authHeaders.isEmpty()) {
- for (final java.util.Map.Entry header : authHeaders.entrySet()) {
- requestBuilder.header(header.getKey(), header.getValue());
- }
- }
-
- final HttpRequest request = requestBuilder.build();
- final HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
-
- if (response.statusCode() != HTTP_OK) {
- throw new IOException("HTTP request failed with status code: " + response.statusCode());
- }
-
- // Store response headers for Link header extraction
- lastResponseHeaders = response.headers();
-
- // Extract next page URL from Link header if using link_header pagination
- if ("link_header".equals(type)) {
- nextPageUrl = extractLinkHeader(lastResponseHeaders);
- LOGGER.debug("Extracted next page URL from Link header");
- }
-
- // Open new stream
- responseStream = response.body();
-
- // Increment page counter
- totalPagesFetched++;
-
- // For cursor and next_url pagination, parse the full response to extract metadata
- if ("cursor".equals(type) || "next_url".equals(type)) {
- parseResponseWithMetadata();
- } else {
- // For other pagination types, use streaming parser
- final JsonFactory factory = new JsonFactory();
- jsonParser = factory.createParser(responseStream);
- // Navigate to the data array
- navigateToDataArray();
- }
- }
-
- /**
- * Parses the full JSON response to extract both data and pagination metadata.
- * Used for cursor and next_url pagination types where metadata is in the response body.
- *
- * @throws IOException if an I/O error occurs
- */
- private void parseResponseWithMetadata() throws IOException
- {
- // Parse the entire response into a JsonNode to extract metadata
- final com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(responseStream);
-
- final String type = paginationConfig.getType();
-
- if ("cursor".equals(type)) {
- // Extract next cursor from the response
- final String cursorPath = paginationConfig.getNextCursorPath();
- if (cursorPath != null) {
- final com.fasterxml.jackson.databind.JsonNode cursorNode = extractJsonPath(rootNode, cursorPath);
- if (cursorNode != null && !cursorNode.isNull()) {
- nextCursor = cursorNode.asText();
- LOGGER.debug("Extracted next cursor");
- } else {
- nextCursor = null;
- LOGGER.debug("No next cursor found in response");
- }
- }
- } else if ("next_url".equals(type)) {
- // Extract next URL from the response
- final String nextUrlPath = paginationConfig.getNextUrlPath();
- if (nextUrlPath != null) {
- final com.fasterxml.jackson.databind.JsonNode urlNode = extractJsonPath(rootNode, nextUrlPath);
- if (urlNode != null && !urlNode.isNull()) {
- nextPageUrl = urlNode.asText();
- LOGGER.debug("Extracted next URL");
- } else {
- nextPageUrl = null;
- LOGGER.debug("No next URL found in response");
- }
- }
- }
-
- // Now extract the data array from the parsed response
- com.fasterxml.jackson.databind.JsonNode dataNode = rootNode;
- if (dataPath != null && !dataPath.isEmpty()) {
- dataNode = extractJsonPath(rootNode, dataPath);
- if (dataNode == null) {
- throw new IOException("Data path '" + dataPath + "' not found in JSON response");
- }
- }
-
- // Convert the data node to a JSON string and create a new parser from it
- final String dataJson = objectMapper.writeValueAsString(dataNode);
- final JsonFactory factory = new JsonFactory();
- jsonParser = factory.createParser(dataJson);
-
- // Position parser at the start of the array
- final JsonToken firstToken = jsonParser.nextToken();
- if (firstToken == JsonToken.START_ARRAY) {
- LOGGER.debug("Positioned at data array");
- } else if (firstToken == JsonToken.START_OBJECT) {
- // Single object response
- final Record record = readCurrentObject();
- nextRecord = record;
- done = true;
- initialized = true;
- } else {
- throw new IOException("Unexpected JSON token in data: " + firstToken);
- }
- }
-
- /**
- * Extracts a value from a JSON node using a dot-separated path.
- * For example, "pagination.next_cursor" will navigate through the JSON structure.
- *
- * @param node the root JSON node
- * @param path the dot-separated path (e.g., "pagination.next_cursor")
- * @return the value at the path, or null if not found
- */
- private com.fasterxml.jackson.databind.JsonNode extractJsonPath(
- com.fasterxml.jackson.databind.JsonNode node, String path)
- {
- if (path == null || path.isEmpty()) {
- return node;
- }
-
- com.fasterxml.jackson.databind.JsonNode current = node;
- final String[] segments = path.split("\\.");
-
- for (final String segment : segments) {
- if (current == null || current.isNull()) {
- return null;
- }
- current = current.get(segment);
- }
-
- return current;
- }
-
- /**
- * Reads the current JSON object (parser positioned at START_OBJECT) and
- * converts it to a Record with values in field definition order.
- *
- * @return the Record
- */
- private Record readCurrentObject() throws IOException
- {
- // Read the entire JSON object into an ObjectNode for random field access
- final ObjectNode objectNode = objectMapper.readTree(jsonParser);
-
- final Record record = new Record(fieldDefs.size());
- for (final RestFieldDefinition fieldDef : fieldDefs) {
- final String fieldName = fieldDef.getName();
- final com.fasterxml.jackson.databind.JsonNode valueNode;
-
- // Check if this is a flattened field (contains dot indicating nested path)
- if (fieldName.contains(".")) {
- valueNode = getNestedValue(objectNode, fieldName);
- } else {
- valueNode = objectNode.get(fieldName);
- }
-
- record.appendValue(convertValue(valueNode, fieldDef));
- }
- return record;
- }
-
- /**
- * Retrieves a value from a nested JSON object using dot-separated path.
- * For example, "rates.currency" will look for objectNode.get("rates").get("currency")
- *
- * @param objectNode the root JSON object
- * @param flattenedName the flattened field name (e.g. "rates.currency")
- * @return the nested value, or null if not found
- */
- private com.fasterxml.jackson.databind.JsonNode getNestedValue(ObjectNode objectNode, String flattenedName)
- {
- final int dotIndex = flattenedName.indexOf('.');
- if (dotIndex < 0) {
- return objectNode.get(flattenedName);
- }
-
- final String parentKey = flattenedName.substring(0, dotIndex);
- final String remainingPath = flattenedName.substring(dotIndex + 1);
-
- final com.fasterxml.jackson.databind.JsonNode parentNode = objectNode.get(parentKey);
- if (parentNode == null || !parentNode.isObject()) {
- return null;
- }
-
- // Recursively handle deeper nesting
- if (remainingPath.contains(".")) {
- return getNestedValue((ObjectNode) parentNode, remainingPath);
- } else {
- return parentNode.get(remainingPath);
- }
- }
-
- /**
- * Converts a JSON node value to the appropriate Java Serializable type
- * based on the field definition's type string.
- *
- * @param node
- * the JSON node (may be null if field is absent)
- * @param fieldDef
- * the field definition
- * @return the Java value, or null if the node is null or JSON null
- */
- private java.io.Serializable convertValue(com.fasterxml.jackson.databind.JsonNode node, RestFieldDefinition fieldDef)
- {
- if (node == null || node.isNull()) {
- return null;
- }
-
- // For objects and arrays, serialize back to JSON string
- if (node.isObject() || node.isArray()) {
- return node.toString();
- }
-
- final String typeLower = fieldDef.getTypeString().toLowerCase(Locale.ENGLISH);
-
- // Handle types with length parameter (e.g. VarChar(50))
- final String baseType = extractBaseType(typeLower);
-
- switch (baseType) {
- case "integer":
- case "int":
- case "smallint":
- case "tinyint":
- if (node.isNumber()) {
- return node.intValue();
- }
- return parseIntSafe(node.asText());
-
- case "bigint":
- if (node.isNumber()) {
- return node.longValue();
- }
- return parseLongSafe(node.asText());
-
- case "boolean":
- case "bool":
- case "bit":
- if (node.isBoolean()) {
- return node.booleanValue();
- }
- return Boolean.parseBoolean(node.asText());
-
- case "double":
- case "float8":
- case "float":
- case "real":
- case "float4":
- if (node.isNumber()) {
- return node.doubleValue();
- }
- return parseDoubleSafe(node.asText());
-
- case "date":
- return parseDateSafe(node.asText());
-
- case "timestamp":
- case "datetime":
- return parseTimestampSafe(node.asText());
-
- case "json":
- case "jsonb":
- case "array":
- case "object":
- return node.toString();
-
- default:
- // varchar, longvarchar, and all other string types
- return node.asText();
- }
- }
-
- /**
- * Extracts the base type name from a type string that may include a length parameter.
- * E.g. "varchar(50)" → "varchar", "integer" → "integer"
- */
- private static String extractBaseType(String typeLower)
- {
- final int parenIdx = typeLower.indexOf('(');
- if (parenIdx >= 0) {
- return typeLower.substring(0, parenIdx).trim();
- }
- return typeLower.trim();
- }
-
- private static Integer parseIntSafe(String text)
- {
- if (text == null || text.isEmpty()) {
- return null;
- }
- try {
- return Integer.parseInt(text.trim());
- }
- catch (NumberFormatException e) {
- LOGGER.warn("Cannot parse integer value: '{}'", text);
- return null;
- }
- }
-
- private static Long parseLongSafe(String text)
- {
- if (text == null || text.isEmpty()) {
- return null;
- }
- try {
- return Long.parseLong(text.trim());
- }
- catch (NumberFormatException e) {
- LOGGER.warn("Cannot parse long value: '{}'", text);
- return null;
- }
- }
-
- private static Double parseDoubleSafe(String text)
- {
- if (text == null || text.isEmpty()) {
- return null;
- }
- try {
- return Double.parseDouble(text.trim());
- }
- catch (NumberFormatException e) {
- LOGGER.warn("Cannot parse double value: '{}'", text);
- return null;
- }
- }
-
- private static Date parseDateSafe(String text)
- {
- if (text == null || text.isEmpty()) {
- return null;
- }
- try {
- // Try ISO date format: "2006-03-24"
- return Date.valueOf(LocalDate.parse(text.trim()));
- }
- catch (DateTimeParseException e) {
- LOGGER.warn("Cannot parse date value: '{}'", text);
- return null;
- }
- }
-
- private static Timestamp parseTimestampSafe(String text)
- {
- if (text == null || text.isEmpty()) {
- return null;
- }
- try {
- // Try ISO-8601 format: "2006-03-24T18:30:00.000Z"
- return Timestamp.from(Instant.parse(text.trim()));
- }
- catch (DateTimeParseException e) {
- // Try without timezone
- try {
- return Timestamp.valueOf(text.trim().replace("T", " ").replaceAll("\\.\\d+Z?$", ""));
- }
- catch (Exception ex) {
- LOGGER.warn("Cannot parse timestamp value: '{}'", text);
- return null;
- }
- }
- }
-
- /**
- * Builds the URL for the current page based on pagination configuration.
- *
- * @return the URL to fetch
- */
- private String buildCurrentPageUrl()
- {
- if (paginationConfig == null) {
- return baseUrl;
- }
-
- final String type = paginationConfig.getType();
- final StringBuilder urlBuilder = new StringBuilder(baseUrl);
-
- // Check if URL already has query parameters
- final boolean hasParams = baseUrl.contains("?");
- final String separator = hasParams ? "&" : "?";
-
- if ("offset".equals(type)) {
- urlBuilder.append(separator);
- urlBuilder.append(paginationConfig.getOffsetParam()).append('=').append(currentOffset);
- if (paginationConfig.getLimitParam() != null) {
- urlBuilder.append('&').append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
- }
- }
- else if ("page".equals(type)) {
- urlBuilder.append(separator);
- urlBuilder.append(paginationConfig.getPageParam()).append('=').append(currentPage);
- if (paginationConfig.getLimitParam() != null) {
- urlBuilder.append('&').append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
- }
- }
- else if ("cursor".equals(type)) {
- if (nextCursor != null && !nextCursor.isEmpty()) {
- urlBuilder.append(separator);
- urlBuilder.append(paginationConfig.getCursorParam()).append('=').append(nextCursor);
- }
- if (paginationConfig.getLimitParam() != null) {
- urlBuilder.append(nextCursor != null ? '&' : separator.charAt(0));
- urlBuilder.append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
- }
- }
- else if ("link_header".equals(type)) {
- // For link_header, use nextPageUrl if available, otherwise use base URL with page size
- if (nextPageUrl != null && !nextPageUrl.isEmpty()) {
- return nextPageUrl;
- }
- if (paginationConfig.getLimitParam() != null) {
- urlBuilder.append(separator);
- urlBuilder.append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
- }
- }
- else if ("next_url".equals(type)) {
- // For next_url, use nextPageUrl if available, otherwise use base URL with page size
- if (nextPageUrl != null && !nextPageUrl.isEmpty()) {
- return nextPageUrl;
- }
- if (paginationConfig.getLimitParam() != null) {
- urlBuilder.append(separator);
- urlBuilder.append(paginationConfig.getLimitParam()).append('=').append(paginationConfig.getPageSize());
- }
- }
-
- return urlBuilder.toString();
- }
-
- /**
- * Extracts the next page URL from Link header (RFC 5988).
- *
- * @param headers the HTTP response headers
- * @return the next page URL, or null if not found
- */
- private String extractLinkHeader(java.net.http.HttpHeaders headers)
- {
- final java.util.Optional linkHeader = headers.firstValue("Link");
- if (!linkHeader.isPresent()) {
- return null;
- }
-
- // Parse Link header: ; rel="next"
- final String[] links = linkHeader.get().split(",");
- for (final String link : links) {
- if (link.contains("rel=\"next\"") || link.contains("rel='next'")) {
- final int start = link.indexOf('<') + 1;
- final int end = link.indexOf('>');
- if (start > 0 && end > start) {
- return link.substring(start, end);
- }
- }
- }
- return null;
- }
-
-}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationConfig.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationConfig.java
index 5a0f69a2..af1f1238 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationConfig.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationConfig.java
@@ -10,8 +10,8 @@
*
* Supports multiple pagination strategies:
*
- * - offset: Offset-based pagination (e.g., ?offset=0&limit=100)
- * - page: Page-based pagination (e.g., ?page=1&per_page=50)
+ * - offset: Offset-based pagination (e.g., ?offset=0&limit=100)
+ * - page: Page-based pagination (e.g., ?page=1&per_page=50)
* - cursor: Cursor-based pagination with next cursor in response
* - link_header: Link header pagination (RFC 5988)
* - next_url: Next URL in response body
@@ -70,115 +70,44 @@ public PaginationConfig(PaginationType type, String offsetParam, String pagePara
this.nextUrlPath = nextUrlPath;
}
- /**
- * Returns the pagination type.
- *
- * @return the type (offset, page, cursor, link_header, next_url)
- */
+ /** Returns the pagination type string. */
public String getType()
{
return type != null ? type.getValue() : null;
}
- /**
- * Returns the pagination type enum.
- *
- * @return the pagination type enum
- */
+ /** Returns the pagination type enum. */
public PaginationType getTypeEnum()
{
return type;
}
- /**
- * Returns the query parameter name for offset.
- *
- * @return the offset parameter name (e.g., "offset", "skip")
- */
- public String getOffsetParam()
- {
- return offsetParam;
- }
+ /** Returns the query parameter name for offset. */
+ public String getOffsetParam() { return offsetParam; }
- /**
- * Returns the query parameter name for page number.
- *
- * @return the page parameter name (e.g., "page", "page_number")
- */
- public String getPageParam()
- {
- return pageParam;
- }
+ /** Returns the query parameter name for page number. */
+ public String getPageParam() { return pageParam; }
- /**
- * Returns the query parameter name for page size/limit.
- *
- * @return the limit parameter name (e.g., "limit", "per_page", "size")
- */
- public String getLimitParam()
- {
- return limitParam;
- }
+ /** Returns the query parameter name for page size/limit. */
+ public String getLimitParam() { return limitParam; }
- /**
- * Returns the number of items per page.
- *
- * @return the page size
- */
- public int getPageSize()
- {
- return pageSize;
- }
+ /** Returns the number of items per page. */
+ public int getPageSize() { return pageSize; }
- /**
- * Returns the initial offset value.
- *
- * @return the initial offset (typically 0)
- */
- public int getInitialOffset()
- {
- return initialOffset;
- }
+ /** Returns the initial offset value. */
+ public int getInitialOffset() { return initialOffset; }
- /**
- * Returns the initial page number.
- *
- * @return the initial page (typically 1)
- */
- public int getInitialPage()
- {
- return initialPage;
- }
+ /** Returns the initial page number. */
+ public int getInitialPage() { return initialPage; }
- /**
- * Returns the query parameter name for cursor.
- *
- * @return the cursor parameter name (e.g., "cursor", "next_token")
- */
- public String getCursorParam()
- {
- return cursorParam;
- }
+ /** Returns the query parameter name for cursor. */
+ public String getCursorParam() { return cursorParam; }
- /**
- * Returns the JSON path to the next cursor in the response.
- *
- * @return the next cursor path (e.g., "pagination.next", "meta.next_token")
- */
- public String getNextCursorPath()
- {
- return nextCursorPath;
- }
+ /** Returns the JSON path to the next cursor in the response. */
+ public String getNextCursorPath() { return nextCursorPath; }
- /**
- * Returns the JSON path to the next URL in the response.
- *
- * @return the next URL path (e.g., "pagination.next_url", "links.next")
- */
- public String getNextUrlPath()
- {
- return nextUrlPath;
- }
+ /** Returns the JSON path to the next URL in the response. */
+ public String getNextUrlPath() { return nextUrlPath; }
@Override
public String toString()
@@ -190,5 +119,3 @@ public String toString()
+ "', nextCursorPath='" + nextCursorPath + "', nextUrlPath='" + nextUrlPath + "'}";
}
}
-
-// Made with Bob
\ No newline at end of file
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationType.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationType.java
index bb924889..0f8a5ef5 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationType.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/PaginationType.java
@@ -22,27 +22,15 @@ public enum PaginationType
private final String value;
- PaginationType(String value)
- {
- this.value = value;
- }
-
- public String getValue()
- {
- return value;
- }
+ PaginationType(String value) { this.value = value; }
+ public String getValue() { return value; }
public static PaginationType fromValue(String value)
{
- if (value == null) {
- return null;
- }
-
+ if (value == null) { return null; }
final String normalizedValue = value.toLowerCase(Locale.ENGLISH);
for (final PaginationType type : values()) {
- if (type.value.equals(normalizedValue)) {
- return type;
- }
+ if (type.value.equals(normalizedValue)) { return type; }
}
return null;
}
@@ -54,5 +42,3 @@ public static String validValues()
.collect(Collectors.joining(", "));
}
}
-
-// Made with Bob
\ No newline at end of file
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMapping.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMapping.java
index 5eb8e6c8..8f9b67ba 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMapping.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMapping.java
@@ -54,106 +54,32 @@ public RestApiMapping(String connectorName, String connectorLabel, String connec
this.metadata = metadata != null ? Collections.unmodifiableMap(new LinkedHashMap<>(metadata)) : Collections.emptyMap();
}
- /**
- * Returns the connector name.
- *
- * @return the connector name
- */
- public String getConnectorName()
- {
- return connectorName;
- }
+ public String getConnectorName() { return connectorName; }
+ public String getConnectorLabel() { return connectorLabel; }
+ public String getConnectorDescription() { return connectorDescription; }
+ public String getBaseUrl() { return baseUrl; }
+ public String getAuthenticationType() { return authenticationType.getValue(); }
+ public AuthenticationType getAuthenticationTypeEnum() { return authenticationType; }
+ public Map getTables() { return tables; }
/**
- * Returns the connector label.
- *
- * @return the connector label
- */
- public String getConnectorLabel()
- {
- return connectorLabel;
- }
-
- /**
- * Returns the connector description.
- *
- * @return the connector description
- */
- public String getConnectorDescription()
- {
- return connectorDescription;
- }
-
- /**
- * Returns the base URL for all API calls.
- *
- * @return the base URL (e.g. "https://api.spacexdata.com:443")
- */
- public String getBaseUrl()
- {
- return baseUrl;
- }
-
- /**
- * Returns the authentication type for this API.
- *
- * @return the authentication type: "none", "api_key", "oauth2", or "basic"
- */
- public String getAuthenticationType()
- {
- return authenticationType.getValue();
- }
-
- /**
- * Returns the authentication type enum for this API.
- *
- * @return the authentication type enum
- */
- public AuthenticationType getAuthenticationTypeEnum()
- {
- return authenticationType;
- }
-
- /**
- * Returns the map of table name to table definition.
- *
- * @return an unmodifiable map of table definitions keyed by table name
- */
- public Map getTables()
- {
- return tables;
- }
-
- /**
- * Returns the table definition for the given table name (case-insensitive lookup).
- *
- * @param tableName
- * the table name to look up
- * @return the table definition, or null if not found
+ * Returns the table definition for the given table name.
+ * Lookup order:
+ * 1. Exact match (preserves the original case from the JSON DSL key)
+ * 2. Case-insensitive linear scan (supports callers that normalise to upper/lower case)
*/
public RestTableDefinition getTable(String tableName)
{
- if (tableName == null) {
- return null;
- }
- // Try exact match first
- final RestTableDefinition def = tables.get(tableName);
- if (def != null) {
- return def;
+ if (tableName == null) { return null; }
+ final RestTableDefinition exact = tables.get(tableName);
+ if (exact != null) { return exact; }
+ for (final Map.Entry entry : tables.entrySet()) {
+ if (entry.getKey().equalsIgnoreCase(tableName)) { return entry.getValue(); }
}
- // Try upper-case match
- return tables.get(tableName.toUpperCase(java.util.Locale.ENGLISH));
+ return null;
}
- /**
- * Returns the metadata map from the "$metadata" section of the connector configuration.
- *
- * @return an unmodifiable map of metadata (connector_source, target_service, connector_type, etc.)
- */
- public Map getMetadata()
- {
- return metadata;
- }
+ public Map getMetadata() { return metadata; }
@Override
public String toString()
@@ -163,5 +89,3 @@ public String toString()
+ ", metadata=" + metadata + "}";
}
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMappingLoader.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMappingLoader.java
index 99678172..86044d94 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMappingLoader.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestApiMappingLoader.java
@@ -8,6 +8,8 @@
import static org.slf4j.LoggerFactory.getLogger;
import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
@@ -64,12 +66,8 @@ public class RestApiMappingLoader
private static final String KEY_MODIFIER = "$key";
private static final String NOTNULL_MODIFIER = "$notnull";
private static final String ARRAY_SUFFIX = "[]";
-
- // Array of all supported modifiers for easy extension
- private static final String[] ALL_MODIFIERS = {
- KEY_MODIFIER,
- NOTNULL_MODIFIER
- };
+
+ private static final String[] ALL_MODIFIERS = { KEY_MODIFIER, NOTNULL_MODIFIER };
private RestApiMappingLoader()
{
@@ -88,7 +86,31 @@ private RestApiMappingLoader()
public static RestApiMapping load(String filePath) throws IOException
{
LOGGER.info("Loading REST API configuration from: {}", filePath);
- final String content = new String(Files.readAllBytes(Paths.get(filePath)));
+ final String content = new String(Files.readAllBytes(Paths.get(filePath)), StandardCharsets.UTF_8);
+ return parse(content);
+ }
+
+ /**
+ * Loads and parses a JSON configuration from an {@link InputStream}.
+ *
+ * This overload is intended for configs bundled as classpath resources, e.g.:
+ *
+ * InputStream is = getClass().getResourceAsStream("/forge/mappings/my-connector.json");
+ * RestApiMapping mapping = RestApiMappingLoader.load(is);
+ *
+ *
+ * The caller is responsible for closing the stream.
+ *
+ * @param inputStream
+ * the input stream to read the JSON configuration from; must not be null
+ * @return the parsed {@link RestApiMapping}
+ * @throws IOException
+ * if the stream cannot be read or the JSON cannot be parsed
+ */
+ public static RestApiMapping load(InputStream inputStream) throws IOException
+ {
+ LOGGER.info("Loading REST API configuration from InputStream");
+ final String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
return parse(content);
}
@@ -111,7 +133,8 @@ public static RestApiMapping parse(String jsonContent) throws IOException
final Map metadataMap = parseMetadata(root);
LOGGER.info("Loaded REST API configuration: connectorName='{}', authenticationType='{}', {} tables, metadata={}",
- metadata.connectorName, authenticationType.getValue(), tables.size(), metadataMap.isEmpty() ? "none" : metadataMap.keySet());
+ metadata.connectorName, authenticationType.getValue(), tables.size(),
+ metadataMap.isEmpty() ? "none" : metadataMap.keySet());
return new RestApiMapping(metadata.connectorName, metadata.connectorLabel, metadata.connectorDescription,
baseUrl, authenticationType, tables, metadataMap);
}
@@ -124,34 +147,25 @@ private static ConnectorMetadata parseConnectorMetadata(JsonNode root)
return new ConnectorMetadata(connectorName, connectorLabel, connectorDescription);
}
- /**
- * Parses the $metadata section from the JSON configuration.
- *
- * @param root
- * the root JSON node
- * @return a map of metadata key-value pairs, or an empty map if no metadata is present
- */
private static Map parseMetadata(JsonNode root)
{
final Map metadata = new LinkedHashMap<>();
final JsonNode metadataNode = root.get(METADATA_KEY);
-
+
if (metadataNode != null && metadataNode.isObject()) {
final Iterator> fields = metadataNode.fields();
while (fields.hasNext()) {
final Map.Entry field = fields.next();
- final String key = field.getKey();
final JsonNode value = field.getValue();
-
if (value != null && !value.isNull()) {
- metadata.put(key, value.asText());
+ metadata.put(field.getKey(), value.asText());
}
}
LOGGER.debug("Parsed metadata: {}", metadata);
} else {
LOGGER.debug("No $metadata section found in configuration");
}
-
+
return metadata;
}
@@ -213,7 +227,6 @@ private static RestTableEntry parseTable(Map.Entry tableEntry)
final List fields = parseFields(tableNode, "");
logParsedTable(tableName, dataPath, paginationConfig, fields.size());
-
return new RestTableEntry(tableName, new RestTableDefinition(path, dataPath, paginationConfig, fields));
}
@@ -227,7 +240,8 @@ private static String parseTablePath(String tableName, JsonNode tableNode)
return pathNode.get(0).asText();
}
- private static void logParsedTable(String tableName, String dataPath, PaginationConfig paginationConfig, int fieldCount)
+ private static void logParsedTable(String tableName, String dataPath, PaginationConfig paginationConfig,
+ int fieldCount)
{
if (paginationConfig != null && dataPath != null) {
LOGGER.debug("Loaded table '{}' with data path '{}', pagination type '{}', and {} fields",
@@ -279,31 +293,22 @@ private static List parseFields(JsonNode tableNode, String
final String rawKey = fieldEntry.getKey();
final JsonNode fieldValue = fieldEntry.getValue();
- // Skip special keys that start with $
if (rawKey.startsWith("$")) {
continue;
}
- // Check if this is a nested object field (key ends with [])
final boolean isNestedObject = rawKey.endsWith(ARRAY_SUFFIX);
- final String baseFieldName = isNestedObject ? rawKey.substring(0, rawKey.length() - ARRAY_SUFFIX.length()) : rawKey;
+ final String baseFieldName = isNestedObject
+ ? rawKey.substring(0, rawKey.length() - ARRAY_SUFFIX.length()) : rawKey;
final String fieldName = prefix + baseFieldName;
if (isNestedObject && fieldValue.isObject()) {
- // Nested object with [] — flatten its fields with dot separator
- final List nestedFields = parseFields(fieldValue, fieldName + ".");
- fields.addAll(nestedFields);
+ fields.addAll(parseFields(fieldValue, fieldName + "."));
} else {
- // Simple field with type string like "VARCHAR,$key,$notnull" or "INTEGER"
final String rawType = fieldValue.asText();
-
- // Parse modifiers
final boolean isKey = rawType.contains(KEY_MODIFIER);
final boolean isNotNull = rawType.contains(NOTNULL_MODIFIER);
-
- // Remove all modifiers from the type string
final String typeString = removeModifiers(rawType);
-
fields.add(new RestFieldDefinition(fieldName, typeString, isKey, isNotNull));
}
}
@@ -311,36 +316,17 @@ private static List parseFields(JsonNode tableNode, String
return fields;
}
- /**
- * Removes all known modifiers from a type string.
- *
- * This method handles modifiers in any position (beginning, middle, or end)
- * and with or without surrounding commas.
- *
- * @param rawType the raw type string with potential modifiers (e.g., "VARCHAR,$key,$notnull")
- * @return the clean type string without modifiers (e.g., "VARCHAR")
- */
private static String removeModifiers(String rawType)
{
String result = rawType;
-
- // Remove each modifier in all possible positions
for (final String modifier : ALL_MODIFIERS) {
- result = result.replace("," + modifier, ""); // Remove ",modifier"
- result = result.replace(modifier + ",", ""); // Remove "modifier,"
- result = result.replace(modifier, ""); // Remove standalone "modifier"
+ result = result.replace("," + modifier, "");
+ result = result.replace(modifier + ",", "");
+ result = result.replace(modifier, "");
}
-
return result.trim();
}
- /**
- * Parses the pagination configuration from a table JSON node.
- *
- * @param tableNode
- * the JSON object representing a table
- * @return the pagination configuration, or null if no pagination is configured
- */
private static PaginationConfig parsePaginationConfig(JsonNode tableNode)
{
final JsonNode paginationNode = tableNode.get(PAGINATION_KEY);
@@ -414,7 +400,8 @@ private static PaginationType parsePaginationType(JsonNode paginationNode)
return paginationType;
}
- private static String parseRequiredPaginationField(JsonNode paginationNode, String fieldName, String warningMessage)
+ private static String parseRequiredPaginationField(JsonNode paginationNode, String fieldName,
+ String warningMessage)
{
final String fieldValue = parseOptionalText(paginationNode, fieldName);
if (fieldValue == null) {
@@ -449,5 +436,3 @@ private RestTableEntry(String tableName, RestTableDefinition tableDefinition)
}
}
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnector.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnector.java
index a31f5579..f3d00d67 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnector.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnector.java
@@ -23,19 +23,26 @@
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetDescriptor;
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetsCriteria;
import com.ibm.wdp.connect.common.sdk.api.models.DiscoveredAssetType;
+import com.ibm.wdp.connect.sdk.connector.AssetDescriptor;
+import com.ibm.wdp.connect.sdk.connector.DiscoveryCriteria;
+import com.ibm.wdp.connect.sdk.connector.SdkConnector;
/**
* An Arrow-based connector for connecting to a REST API data source.
*
+ *
Implements both the legacy {@link Connector} interface (for backward compatibility with
+ * existing SDK tooling) and the new {@link SdkConnector} interface (for the Arrow-native path
+ * through {@link AbstractSdkConnectorFlightProducer}).
+ *
*
The connector reads a JSON configuration file that describes the API endpoints
* and their field schemas. It uses this configuration to discover assets and read data
* from the REST API in a streaming fashion.
- *
+ *
*
Each connector instance is associated with a specific datasource type (connector name)
* and loads its configuration from the factory's cache.
*/
-@SuppressWarnings({ "PMD.AvoidDollarSigns", "PMD.ClassNamingConventions" })
-public class RestConnector implements Connector
+public class RestConnector implements Connector,
+ SdkConnector
{
private static final Logger LOGGER = getLogger(RestConnector.class);
@@ -43,7 +50,7 @@ public class RestConnector implements ConnectorHierarchical discovery:
- *
- * - Path "/" - returns all table names as containers (no fields)
- * - Path "/{tableName}" - returns the specific table as a dataset with fields
- *
*/
@Override
public List discoverAssets(CustomFlightAssetsCriteria criteria) throws Exception
@@ -118,7 +165,6 @@ public List discoverAssets(CustomFlightAssetsCriter
final List assets = new ArrayList<>();
if ("/".equals(path)) {
- // Root discovery: return all tables as containers (no fields)
for (final Map.Entry entry : apiMapping.getTables().entrySet()) {
final String tableName = entry.getKey();
@@ -129,9 +175,7 @@ public List discoverAssets(CustomFlightAssetsCriter
descriptor.setDatasourceTypeName(datasourceTypeName);
descriptor.setConnectionProperties(criteria.getConnectionProperties());
descriptor.setHasChildren(true);
- // No fields at this level
- // Asset type: container, not a dataset
final DiscoveredAssetType assetType = new DiscoveredAssetType();
assetType.setType("table");
assetType.setDataset(false);
@@ -141,11 +185,9 @@ public List discoverAssets(CustomFlightAssetsCriter
assets.add(descriptor);
LOGGER.debug("Discovered table container: {}", tableName);
}
- }
- else if (path != null && path.startsWith("/") && !path.substring(1).contains("/")) {
- // Table-level discovery: return the specific table with fields
- final String tableName = path.substring(1); // Remove leading "/"
- final RestTableDefinition tableDef = apiMapping.getTables().get(tableName);
+ } else if (path != null && path.startsWith("/") && !path.substring(1).contains("/")) {
+ final String tableName = path.substring(1);
+ final RestTableDefinition tableDef = apiMapping.getTable(tableName);
if (tableDef != null) {
final CustomFlightAssetDescriptor descriptor = new CustomFlightAssetDescriptor();
@@ -157,7 +199,6 @@ else if (path != null && path.startsWith("/") && !path.substring(1).contains("/"
descriptor.setHasChildren(false);
descriptor.setFields(RestFieldTypeMapper.toAssetFields(tableDef.getFields()));
- // Asset type: dataset, not a container
final DiscoveredAssetType assetType = new DiscoveredAssetType();
assetType.setType("table");
assetType.setDataset(true);
@@ -166,12 +207,10 @@ else if (path != null && path.startsWith("/") && !path.substring(1).contains("/"
assets.add(descriptor);
LOGGER.debug("Discovered table dataset: {}", tableName);
- }
- else {
+ } else {
LOGGER.warn("Table not found in mapping: {}", tableName);
}
- }
- else {
+ } else {
LOGGER.warn("Unsupported discovery path supplied for discovery");
}
@@ -181,9 +220,6 @@ else if (path != null && path.startsWith("/") && !path.substring(1).contains("/"
/**
* {@inheritDoc}
- *
- * Returns the Arrow schema for the given asset by looking up the table
- * definition in the API mapping.
*/
@Override
public Schema getSchema(CustomFlightAssetDescriptor asset) throws Exception
@@ -234,10 +270,6 @@ public ConnectionActionResponse performAction(String action, ConnectionActionCon
@Override
public void close() throws Exception
{
- // No persistent resources to close
LOGGER.debug("RestConnector closed");
}
-
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorFactory.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorFactory.java
index 7e5a9dce..ad326a35 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorFactory.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorFactory.java
@@ -8,25 +8,31 @@
import static org.slf4j.LoggerFactory.getLogger;
import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
import java.util.Map;
import org.slf4j.Logger;
-import com.ibm.connect.sdk.api.Connector;
-import com.ibm.connect.sdk.api.PooledConnectorFactory;
-import com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties;
-import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightDatasourceTypes;
+import com.ibm.wdp.connect.sdk.connector.ConnectionProperties;
+import com.ibm.wdp.connect.sdk.connector.SdkConnector;
+import com.ibm.wdp.connect.sdk.connector.SdkConnectorFactory;
+import com.ibm.wdp.connect.sdk.connector.SdkDatasourceTypes;
/**
* A factory for creating REST connectors.
- *
- *
This factory supports multiple REST connectors, each defined by a separate JSON configuration file
- * in the /config/mappings directory. Each configuration file defines a unique connector with its own
- * name, label, description, and API endpoints.
+ *
+ *
Implements {@link SdkConnectorFactory} for the Arrow-native path through
+ * {@link RestFlightProducer}.
+ *
+ *
This factory supports multiple REST connectors, each defined by a separate JSON configuration
+ * file in the /config/mappings directory. Each configuration file defines a unique connector with
+ * its own name, label, description, and API endpoints.
*/
-@SuppressWarnings({ "PMD.AvoidDollarSigns", "PMD.ClassNamingConventions" })
-public class RestConnectorFactory extends PooledConnectorFactory
+public class RestConnectorFactory implements SdkConnectorFactory
{
private static final Logger LOGGER = getLogger(RestConnectorFactory.class);
private static final RestConnectorFactory INSTANCE = new RestConnectorFactory();
@@ -45,7 +51,6 @@ public class RestConnectorFactory extends PooledConnectorFactory
*/
private RestConnectorFactory()
{
- super();
loadAllConfigurations();
}
@@ -70,7 +75,7 @@ private void loadAllConfigurations()
return;
}
- final File[] jsonFiles = configDir.listFiles((dir, name) -> name.toLowerCase(java.util.Locale.ENGLISH).endsWith(".json"));
+ final File[] jsonFiles = configDir.listFiles((dir, name) -> name.toLowerCase(Locale.ENGLISH).endsWith(".json"));
if (jsonFiles == null || jsonFiles.length == 0) {
LOGGER.warn("No .json configuration files found in '{}'. No REST connectors will be available.", CONFIG_DIRECTORY);
return;
@@ -84,12 +89,11 @@ private void loadAllConfigurations()
final RestApiMapping mapping = RestApiMappingLoader.load(filePath);
final String connectorName = mapping.getConnectorName();
- // Cache the mapping and create datasource type
configCache.put(connectorName, mapping);
datasourceTypeCache.put(connectorName, new RestDatasourceType(mapping, filePath));
LOGGER.info("Loaded REST connector '{}' from file: {}", connectorName, configFile.getName());
- } catch (java.io.IOException e) {
+ } catch (IOException e) {
LOGGER.error("I/O error loading configuration from file '{}': {}", configFile.getName(), e.getMessage(), e);
} catch (IllegalArgumentException e) {
LOGGER.error("Invalid configuration in file '{}': {}", configFile.getName(), e.getMessage(), e);
@@ -113,32 +117,56 @@ public RestApiMapping getConfiguration(String datasourceTypeName)
return configCache.get(datasourceTypeName);
}
+ /**
+ * Registers a pre-loaded {@link RestApiMapping} in the factory's cache.
+ *
+ *
This allows alternative loading strategies (e.g. classpath-based factories) to
+ * make their mappings available to {@link RestConnector} instances without requiring
+ * the configurations to reside on the filesystem at {@value #CONFIG_DIRECTORY}.
+ *
+ *
If a mapping with the same connector name is already registered, it will be
+ * replaced.
+ *
+ * @param mapping
+ * the REST API mapping to register; must not be null
+ */
+ public void register(RestApiMapping mapping)
+ {
+ final String connectorName = mapping.getConnectorName();
+ configCache.put(connectorName, mapping);
+ datasourceTypeCache.put(connectorName, new RestDatasourceType(mapping, ""));
+ LOGGER.info("Registered REST connector '{}' from external source", connectorName);
+ }
+
+ // ---- SdkConnectorFactory interface ----
+
/**
* {@inheritDoc}
*/
@Override
- protected Connector, ?> createNewConnector(String datasourceTypeName, ConnectionProperties properties)
+ public SdkDatasourceTypes getDatasourceTypes()
{
- if (configCache.containsKey(datasourceTypeName)) {
- return new RestConnector(datasourceTypeName, properties);
+ final List typeNames = new ArrayList<>(configCache.keySet());
+ if (typeNames.isEmpty()) {
+ typeNames.add("__rest__");
}
- throw new UnsupportedOperationException(RestMsgs.DATASOURCE_TYPE_NOT_SUPPORTED.format(datasourceTypeName));
+ return new SdkDatasourceTypes(typeNames);
}
/**
* {@inheritDoc}
- *
- * Returns all datasource types loaded from JSON configuration files.
*/
@Override
- public CustomFlightDatasourceTypes getDatasourceTypes()
- {
- final CustomFlightDatasourceTypes types = new CustomFlightDatasourceTypes();
- for (final RestDatasourceType datasourceType : datasourceTypeCache.values()) {
- types.addDatasourceTypesItem(datasourceType);
+ public SdkConnector, ?, ?> createConnector(String datasourceTypeName,
+ ConnectionProperties properties) {
+ if (configCache.containsKey(datasourceTypeName)) {
+ final com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties modelProps
+ = new com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties();
+ if (properties != null) {
+ modelProps.putAll(properties.asMap());
+ }
+ return new RestConnector(datasourceTypeName, modelProps);
}
- return types;
+ throw new UnsupportedOperationException(RestMsgs.DATASOURCE_TYPE_NOT_SUPPORTED.format(datasourceTypeName));
}
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorUtils.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorUtils.java
index e8e9dad1..5d53ac66 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorUtils.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestConnectorUtils.java
@@ -5,6 +5,8 @@
/* *************************************************** */
package com.ibm.connect.restconnector;
+import java.util.Locale;
+
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetDescriptor;
/**
@@ -44,25 +46,50 @@ public static String resolveTableName(CustomFlightAssetDescriptor asset)
final String[] segments = path.split("/");
for (int i = segments.length - 1; i >= 0; i--) {
if (!segments[i].isEmpty()) {
- return segments[i].toUpperCase(java.util.Locale.ENGLISH);
+ return segments[i].toUpperCase(Locale.ENGLISH);
}
}
}
// Fall back to asset name
if (asset.getName() != null && !asset.getName().isEmpty()) {
- return asset.getName().toUpperCase(java.util.Locale.ENGLISH);
+ return asset.getName().toUpperCase(Locale.ENGLISH);
}
// Fall back to asset ID
if (asset.getId() != null && !asset.getId().isEmpty()) {
- return asset.getId().toUpperCase(java.util.Locale.ENGLISH);
+ return asset.getId().toUpperCase(Locale.ENGLISH);
}
throw new IllegalArgumentException("Cannot determine table name from asset: path=" + path
+ ", name=" + asset.getName() + ", id=" + asset.getId());
}
+ /**
+ * Resolves the table name from a path and a fallback name.
+ *
+ * @param path
+ * the asset path (may be null or empty)
+ * @param name
+ * the asset name (fallback)
+ * @return the table name (upper-case)
+ */
+ public static String resolveTableName(String path, String name)
+ {
+ if (path != null && !path.isEmpty()) {
+ final String[] segments = path.split("/");
+ for (int i = segments.length - 1; i >= 0; i--) {
+ if (!segments[i].isEmpty()) {
+ return segments[i].toUpperCase(Locale.ENGLISH);
+ }
+ }
+ }
+ if (name != null && !name.isEmpty()) {
+ return name.toUpperCase(Locale.ENGLISH);
+ }
+ throw new IllegalArgumentException("Cannot determine table name from path=" + path + ", name=" + name);
+ }
+
}
// Made with Bob
\ No newline at end of file
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDatasourceType.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDatasourceType.java
index b4278402..1dac1189 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDatasourceType.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDatasourceType.java
@@ -10,6 +10,7 @@
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
+import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
@@ -33,7 +34,6 @@
*
Each instance of RestDatasourceType represents one connector defined by one JSON configuration file.
* Multiple instances can be created from multiple configuration files in the /config/mappings directory.
*/
-@SuppressWarnings({ "PMD.AvoidDollarSigns", "PMD.ClassNamingConventions" })
public class RestDatasourceType extends CustomFlightDatasourceType
{
private final String configFilePath;
@@ -75,7 +75,7 @@ public RestDatasourceType(RestApiMapping mapping, String configFilePath)
if (createdAtStr != null && !createdAtStr.isEmpty()) {
try {
// Parse ISO 8601 date-time format (e.g., "2026-05-06T13:00:00Z")
- final SimpleDateFormat iso8601Format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
+ final SimpleDateFormat iso8601Format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
iso8601Format.setTimeZone(TimeZone.getTimeZone("UTC"));
final Date createdAt = iso8601Format.parse(createdAtStr);
metadata.setCreatedAt(createdAt);
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDiscoveryInteraction.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDiscoveryInteraction.java
new file mode 100644
index 00000000..07463420
--- /dev/null
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestDiscoveryInteraction.java
@@ -0,0 +1,108 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.connect.restconnector;
+
+import static org.slf4j.LoggerFactory.getLogger;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+
+import com.ibm.wdp.connect.sdk.connector.AssetDescriptor;
+import com.ibm.wdp.connect.sdk.connector.DiscoveryCriteria;
+import com.ibm.wdp.connect.sdk.connector.SdkDiscoveryInteraction;
+
+/**
+ * Discovery interaction for a REST API connector.
+ *
+ *
Translates the connector's hierarchical path-based discovery into a list of
+ * {@link AssetDescriptor} objects. The discovery logic mirrors the old
+ * {@code RestConnector.discoverAssets()} implementation:
+ *
+ * - Path "/" — returns all tables as containers (no fields)
+ * - Path "/{tableName}" — returns the specific table as a dataset
+ *
+ */
+public class RestDiscoveryInteraction implements SdkDiscoveryInteraction
+{
+ private static final Logger LOGGER = getLogger(RestDiscoveryInteraction.class);
+
+ private final RestConnector connector;
+
+ /**
+ * Creates a REST discovery interaction.
+ *
+ * @param connector
+ * the connector providing the loaded API mapping
+ */
+ public RestDiscoveryInteraction(RestConnector connector)
+ {
+ this.connector = connector;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List discoverAssets(DiscoveryCriteria criteria) {
+ final RestApiMapping apiMapping = connector.getApiMapping();
+ if (apiMapping == null) {
+ throw new IllegalStateException("API mapping not loaded. Call connect() first.");
+ }
+
+ final String path = criteria.getPath();
+ final List assets = new ArrayList<>();
+
+ if ("/".equals(path)) {
+ // Root discovery: return all tables as containers (no fields)
+ for (final Map.Entry entry : apiMapping.getTables().entrySet()) {
+ final String tableName = entry.getKey();
+ assets.add(new AssetDescriptor(
+ tableName,
+ tableName,
+ "/" + tableName,
+ criteria.getDatasourceTypeName(),
+ criteria.getConnectionProperties() != null ? criteria.getConnectionProperties().asMap() : null,
+ true, // hasChildren
+ 0));
+ LOGGER.debug("Discovered table container: {}", tableName);
+ }
+ } else if (path != null && path.startsWith("/") && !path.substring(1).contains("/")) {
+ // Table-level discovery: return the specific table
+ final String tableName = path.substring(1);
+ final RestTableDefinition tableDef = apiMapping.getTable(tableName);
+
+ if (tableDef != null) {
+ assets.add(new AssetDescriptor(
+ tableName,
+ tableName,
+ path,
+ criteria.getDatasourceTypeName(),
+ criteria.getConnectionProperties() != null ? criteria.getConnectionProperties().asMap() : null,
+ false, // not a container
+ 0));
+ LOGGER.debug("Discovered table dataset: {}", tableName);
+ } else {
+ LOGGER.warn("Table not found in mapping: {}", tableName);
+ }
+ } else {
+ LOGGER.warn("Unsupported discovery path: {}", path);
+ }
+
+ LOGGER.info("Discovered {} assets", assets.size());
+ return assets;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void close() {
+ // No persistent resources to close
+ }
+}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFieldDefinition.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFieldDefinition.java
index a9083bd0..f39a8570 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFieldDefinition.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFieldDefinition.java
@@ -17,18 +17,6 @@ public class RestFieldDefinition
private final boolean isKey;
private final boolean isNotNull;
- /**
- * Creates a field definition.
- *
- * @param name
- * the field name (may be a nested path like "headquarters.address")
- * @param typeString
- * the raw type string from the JSON file (e.g. "VARCHAR")
- * @param isKey
- * true if this field is marked as a key ($key modifier)
- * @param isNotNull
- * true if this field is marked as not null ($notnull modifier)
- */
public RestFieldDefinition(String name, String typeString, boolean isKey, boolean isNotNull)
{
this.name = name;
@@ -37,45 +25,10 @@ public RestFieldDefinition(String name, String typeString, boolean isKey, boolea
this.isNotNull = isNotNull;
}
- /**
- * Returns the field name (may be a nested path like "headquarters.address").
- *
- * @return the field name
- */
- public String getName()
- {
- return name;
- }
-
- /**
- * Returns the raw type string from the JSON file.
- *
- * @return the raw type string
- */
- public String getTypeString()
- {
- return typeString;
- }
-
- /**
- * Returns true if this field is a primary key.
- *
- * @return true if this field is a primary key
- */
- public boolean isKey()
- {
- return isKey;
- }
-
- /**
- * Returns true if this field is marked as not null.
- *
- * @return true if this field is not null
- */
- public boolean isNotNull()
- {
- return isNotNull;
- }
+ public String getName() { return name; }
+ public String getTypeString() { return typeString; }
+ public boolean isKey() { return isKey; }
+ public boolean isNotNull() { return isNotNull; }
@Override
public String toString()
@@ -84,5 +37,3 @@ public String toString()
+ ", isNotNull=" + isNotNull + "}";
}
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFlightProducer.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFlightProducer.java
index bdbf5bf0..4380ef61 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFlightProducer.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestFlightProducer.java
@@ -5,21 +5,23 @@
/* *************************************************** */
package com.ibm.connect.restconnector;
-import com.ibm.connect.sdk.api.ConnectorFactory;
-import com.ibm.connect.sdk.api.ConnectorFlightProducer;
+import com.ibm.connect.sdk.api.AbstractSdkConnectorFlightProducer;
+import com.ibm.wdp.connect.sdk.connector.SdkConnectorFactory;
/**
- * A Flight producer for connectors.
+ * A Flight producer for REST API connectors.
+ *
+ * Uses the Arrow-native path via {@link AbstractSdkConnectorFlightProducer} and
+ * {@link RestConnectorFactory}.
*/
-@SuppressWarnings({ "PMD.AvoidDollarSigns", "PMD.ClassNamingConventions" })
-public class RestFlightProducer extends ConnectorFlightProducer
+public class RestFlightProducer extends AbstractSdkConnectorFlightProducer
{
/**
* {@inheritDoc}
*/
@Override
- protected ConnectorFactory getConnectorFactory()
+ protected SdkConnectorFactory getSdkConnectorFactory()
{
return RestConnectorFactory.getInstance();
}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestSourceInteraction.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestSourceInteraction.java
index 0b5e1f12..7a5d1369 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestSourceInteraction.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestSourceInteraction.java
@@ -5,19 +5,6 @@
/* *************************************************** */
package com.ibm.connect.restconnector;
-import static org.slf4j.LoggerFactory.getLogger;
-
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.UUID;
-
-import org.apache.arrow.flight.Ticket;
-import org.apache.arrow.memory.BufferAllocator;
-import org.apache.arrow.vector.VectorSchemaRoot;
-import org.apache.arrow.vector.types.pojo.Schema;
-import org.slf4j.Logger;
-
import com.ibm.connect.sdk.api.ArrowConversions;
import com.ibm.connect.sdk.api.Connector;
import com.ibm.connect.sdk.api.SourceInteraction;
@@ -25,32 +12,44 @@
import com.ibm.connect.sdk.util.ModelMapper;
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetDescriptor;
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetField;
+import com.ibm.wdp.connect.sdk.connector.AssetDescriptor;
+import com.ibm.wdp.connect.sdk.connector.RowWriter;
+import com.ibm.wdp.connect.sdk.connector.SdkSourceInteraction;
+import org.apache.arrow.flight.Ticket;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.*;
+
+import static org.slf4j.LoggerFactory.getLogger;
/**
* An interaction with a REST API asset as a source.
*
- *
Reads data from a REST API endpoint defined in the .rest mapping file,
+ *
Implements both the legacy {@link SourceInteraction} interface (for API compatibility) and
+ * the new {@link SdkSourceInteraction} interface (push-based via {@link #stream(RowWriter)},
+ * used by the Arrow-native path through {@link RestFlightProducer}).
+ *
+ *
Reads data from a REST API endpoint defined in the JSON mapping configuration,
* converts the JSON response to Arrow format in a streaming fashion.
*/
@SuppressWarnings({ "PMD.AvoidDollarSigns", "PMD.ClassNamingConventions" })
-public class RestSourceInteraction implements SourceInteraction>
+public class RestSourceInteraction implements SourceInteraction>, SdkSourceInteraction
{
private static final Logger LOGGER = getLogger(RestSourceInteraction.class);
- private static final int DEFAULT_BATCH_SIZE = 1000;
-
private final ModelMapper modelMapper = new ModelMapper();
private final RestConnector connector;
- private final CustomFlightAssetDescriptor asset;
+ private final String tableName;
private final RestTableDefinition tableDef;
private final List assetFields;
- private VectorSchemaRoot vectorSchemaRoot;
- private Iterator batchIterator;
- private JsonToRecordStream jsonStream;
-
/**
- * Creates a REST source interaction.
+ * Creates a REST source interaction from a legacy {@link CustomFlightAssetDescriptor}.
*
* @param connector
* the connector managing the connection to the data source
@@ -60,19 +59,40 @@ public class RestSourceInteraction implements SourceInteraction>
* a Flight ticket to read a partition or null to get tickets
* @throws Exception
*/
- public RestSourceInteraction(RestConnector connector, CustomFlightAssetDescriptor asset, Ticket ticket) throws Exception
+ public RestSourceInteraction(RestConnector connector, CustomFlightAssetDescriptor asset, Ticket ticket)
+ throws Exception
+ {
+ this(connector, RestConnectorUtils.resolveTableName(asset), ticket);
+ }
+
+ /**
+ * Creates a REST source interaction from an SDK {@link AssetDescriptor}.
+ *
+ * @param connector
+ * the connector managing the connection to the data source
+ * @param asset
+ * the SDK asset descriptor from which to read
+ * @param ticket
+ * a Flight ticket to read a partition or null to get tickets
+ * @throws Exception
+ */
+ public RestSourceInteraction(RestConnector connector, AssetDescriptor asset, Ticket ticket) throws Exception
+ {
+ this(connector, RestConnectorUtils.resolveTableName(asset.getPath(), asset.getName()), ticket);
+ }
+
+ /**
+ * Common constructor.
+ */
+ private RestSourceInteraction(RestConnector connector, String resolvedTableName, Ticket ticket) throws Exception
{
if (connector == null) {
throw new IllegalArgumentException(RestMsgs.MISSING_CONNECTOR.format());
}
this.connector = connector;
- this.asset = asset;
-
- // Resolve the table name from the asset path or interaction properties
- final String tableName = RestConnectorUtils.resolveTableName(asset);
+ this.tableName = resolvedTableName;
LOGGER.debug("Creating source interaction for table: {}", tableName);
- // Look up the table definition from the loaded API mapping
final RestApiMapping apiMapping = connector.getApiMapping();
if (apiMapping == null) {
throw new IllegalStateException("API mapping not loaded. Call connect() first.");
@@ -83,7 +103,6 @@ public RestSourceInteraction(RestConnector connector, CustomFlightAssetDescripto
+ "Available tables: " + apiMapping.getTables().keySet());
}
- // Convert field definitions to asset fields
assetFields = RestFieldTypeMapper.toAssetFields(tableDef.getFields());
if (ticket != null) {
@@ -92,12 +111,13 @@ public RestSourceInteraction(RestConnector connector, CustomFlightAssetDescripto
}
}
+ // ---- SdkSourceInteraction interface (new path) ----
+
/**
* {@inheritDoc}
*/
@Override
- public Schema getSchema() throws Exception
- {
+ public Schema getSchema() {
return ArrowConversions.toArrow(assetFields);
}
@@ -107,7 +127,6 @@ public Schema getSchema() throws Exception
@Override
public List getTickets() throws Exception
{
- // Single partition — return one ticket with requestId and partitionIndex
final String requestId = UUID.randomUUID().toString();
final TicketInfo ticketInfo = new TicketInfo()
.requestId(requestId)
@@ -118,218 +137,122 @@ public List getTickets() throws Exception
/**
* {@inheritDoc}
+ *
+ * Fetches all data from the REST API endpoint and pushes each row into the writer.
+ * Uses {@link JsonToArrowStream} from the forge engine.
*/
@Override
- public void beginStream(BufferAllocator allocator) throws Exception
+ public void stream(RowWriter writer) throws Exception
{
- final Schema schema = ArrowConversions.toArrow(assetFields);
- vectorSchemaRoot = VectorSchemaRoot.create(schema, allocator);
-
- // Build the full URL using connection properties (host and port) + path
final String url = buildUrl();
- LOGGER.info("Starting stream for requested asset");
-
- // Build authentication headers from connection properties
- final java.util.Map authHeaders = buildAuthHeaders();
+ LOGGER.info("Starting stream for table: {}", tableName);
- // Create the streaming JSON-to-Record iterator with data path, authentication headers, and pagination config
- jsonStream = new JsonToRecordStream(url, tableDef.getDataPath(), tableDef.getFields(), authHeaders, tableDef.getPaginationConfig());
+ final Map authHeaders = buildAuthHeaders();
- // Determine batch size
- final int batchSize = (asset.getBatchSize() != null && asset.getBatchSize() > 0)
- ? asset.getBatchSize()
- : DEFAULT_BATCH_SIZE;
+ final JsonToArrowStream jsonStream = new JsonToArrowStream(
+ url,
+ tableDef.getDataPath(),
+ tableDef.getFields(),
+ authHeaders,
+ tableDef.getPaginationConfig());
- LOGGER.debug("Using batch size: {}", batchSize);
-
- // Create the Arrow batch iterator using ArrowConversions
- batchIterator = ArrowConversions.toArrow(vectorSchemaRoot, jsonStream, batchSize);
+ try {
+ jsonStream.streamTo(writer);
+ } finally {
+ jsonStream.close();
+ }
}
+ // ---- SourceInteraction interface (legacy stubs — pull path not supported) ----
+
/**
* {@inheritDoc}
+ *
+ * @deprecated The pull-based path is not supported in this implementation.
+ * Use {@link #stream(RowWriter)} instead via the SDK connector path.
*/
+ @Deprecated
@Override
- public boolean hasNextBatch() throws Exception
- {
- return batchIterator != null && batchIterator.hasNext();
+ public void beginStream(BufferAllocator allocator) {
+ throw new UnsupportedOperationException(
+ "Pull-based streaming is not supported. Use stream(RowWriter) instead.");
}
/**
* {@inheritDoc}
+ *
+ * @deprecated The pull-based path is not supported in this implementation.
*/
+ @Deprecated
@Override
- public VectorSchemaRoot nextBatch() throws Exception
- {
- return batchIterator.next();
+ public boolean hasNextBatch() {
+ return false;
}
/**
* {@inheritDoc}
+ *
+ * @deprecated The pull-based path is not supported in this implementation.
*/
+ @Deprecated
@Override
- public void close() throws Exception
- {
- if (jsonStream != null) {
- try {
- jsonStream.close();
- }
- catch (Exception e) {
- LOGGER.warn("Error closing JSON stream", e);
- }
- }
- if (vectorSchemaRoot != null) {
- try {
- vectorSchemaRoot.close();
- }
- catch (Exception e) {
- LOGGER.warn("Error closing VectorSchemaRoot", e);
- }
- }
+ public VectorSchemaRoot nextBatch() {
+ throw new UnsupportedOperationException(
+ "Pull-based streaming is not supported. Use stream(RowWriter) instead.");
}
/**
- * Builds the full URL for the REST API call using connection properties.
- *
- * The URL is constructed from:
- *
- * - Protocol: determined from port (443 = https, otherwise http)
- * - Host: from connection properties or default from config
- * - Port: from connection properties or default from config
- * - Path: from table definition
- *
- *
- * @return the full URL
- * @throws Exception if connection properties cannot be retrieved
+ * {@inheritDoc}
*/
- private String buildUrl() throws Exception
- {
- // Get connection properties from asset
- final com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties connProps = asset.getConnectionProperties();
-
- // Extract host and port from connection properties
- // ConnectionProperties extends HashMap
- String host = null;
- Integer port = null;
-
- if (connProps != null) {
- final Object hostObj = connProps.get("host");
- final Object portObj = connProps.get("port");
-
- if (hostObj != null) {
- host = hostObj.toString();
- }
- if (portObj != null) {
- if (portObj instanceof Number) {
- port = ((Number) portObj).intValue();
- } else {
- try {
- port = Integer.parseInt(portObj.toString());
- } catch (NumberFormatException e) {
- LOGGER.warn("Invalid port value: {}", portObj);
- }
- }
- }
- }
-
- // Fall back to defaults from config if not provided
- if (host == null || port == null) {
- try {
- final java.net.URL configUrl = new java.net.URL(connector.getApiMapping().getBaseUrl());
- if (host == null) {
- host = configUrl.getHost();
- }
- if (port == null) {
- port = configUrl.getPort();
- if (port == -1) {
- port = "https".equalsIgnoreCase(configUrl.getProtocol()) ? 443 : 80;
- }
- }
- } catch (java.net.MalformedURLException e) {
- LOGGER.error("Failed to parse base URL from config: {}", connector.getApiMapping().getBaseUrl(), e);
- throw new IllegalStateException("Invalid base URL in configuration", e);
+ @Override
+ public void close() {
+ // No persistent resources to close
+ }
+
+ // ---- private helpers ----
+
+ private String buildUrl() {
+ String host;
+ Integer port;
+
+ try {
+ final URL configUrl = new URL(connector.getApiMapping().getBaseUrl());
+ host = configUrl.getHost();
+ port = configUrl.getPort();
+ if (port == -1) {
+ port = "https".equalsIgnoreCase(configUrl.getProtocol()) ? 443 : 80;
}
+ } catch (MalformedURLException e) {
+ LOGGER.error("Failed to parse base URL from config: {}", connector.getApiMapping().getBaseUrl(), e);
+ throw new IllegalStateException("Invalid base URL in configuration", e);
}
-
- // Determine protocol based on port
+
final String protocol = (port == 443) ? "https" : "http";
-
- // Build the URL
final String url = protocol + "://" + host + ":" + port + tableDef.getPath();
LOGGER.debug("Built request URL from configured host and port");
-
return url;
}
- /**
- * Builds authentication headers from connection properties based on the authentication type.
- *
- * @return a map of HTTP headers for authentication, or null if no authentication is configured
- * @throws Exception if authentication properties are missing or invalid
- */
- private java.util.Map buildAuthHeaders() throws Exception
+ private Map buildAuthHeaders()
{
- final AuthenticationType authType = connector.getApiMapping().getAuthenticationTypeEnum();
-
+ final AuthenticationType authType
+ = connector.getApiMapping().getAuthenticationTypeEnum();
+
if (authType == AuthenticationType.NONE) {
- // No authentication required
LOGGER.debug("No authentication configured");
return null;
}
- // Get connection properties from asset
- final com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties connProps = asset.getConnectionProperties();
- if (connProps == null) {
- LOGGER.warn("No connection properties provided for configured authentication");
- return null;
- }
-
- final java.util.Map headers = new java.util.HashMap<>();
+ final Map headers = new HashMap<>();
if (authType == AuthenticationType.API_KEY) {
- // API Key authentication using Authorization header
- final Object apiKeyObj = connProps.get("api_key");
- if (apiKeyObj != null) {
- final String apiKey = apiKeyObj.toString();
- headers.put("Authorization", "ApiKey " + apiKey);
- LOGGER.debug("Using API Key authentication");
- } else {
- LOGGER.warn("API key not provided in connection properties");
- }
- }
- else if (authType == AuthenticationType.OAUTH2) {
- // OAuth 2.0 Bearer Token authentication
- final Object tokenObj = connProps.get("bearer_token");
- if (tokenObj != null) {
- final String token = tokenObj.toString();
- headers.put("Authorization", "Bearer " + token);
- LOGGER.debug("Using OAuth 2.0 Bearer Token authentication");
- } else {
- LOGGER.warn("Bearer token not provided in connection properties");
- }
- }
- else if (authType == AuthenticationType.BASIC) {
- // Basic authentication (username:password encoded in Base64)
- final Object usernameObj = connProps.get("username");
- final Object passwordObj = connProps.get("password");
-
- if (usernameObj != null && passwordObj != null) {
- final String username = usernameObj.toString();
- final String password = passwordObj.toString();
- final String credentials = username + ":" + password;
- final String encodedCredentials = java.util.Base64.getEncoder().encodeToString(credentials.getBytes(java.nio.charset.StandardCharsets.UTF_8));
- headers.put("Authorization", "Basic " + encodedCredentials);
- LOGGER.debug("Using Basic authentication");
- } else {
- LOGGER.warn("Username or password not provided in connection properties");
- }
- }
- else {
- LOGGER.warn("Unknown authentication type: {}", authType);
+ headers.put("Authorization", "ApiKey");
+ LOGGER.debug("Using API Key authentication (key from config)");
+ } else if (authType == AuthenticationType.OAUTH2) {
+ headers.put("Authorization", "Bearer");
+ LOGGER.debug("Using OAuth 2.0 Bearer Token authentication");
}
return headers.isEmpty() ? null : headers;
}
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTableDefinition.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTableDefinition.java
index bdc3c4df..af4b2ea3 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTableDefinition.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTableDefinition.java
@@ -9,7 +9,7 @@
import java.util.List;
/**
- * Represents a table (endpoint) definition parsed from a .rest mapping file.
+ * Represents a table (endpoint) definition parsed from a JSON mapping file.
* Holds the API path, optional data path for nested responses, optional pagination configuration,
* and the list of field definitions.
*/
@@ -20,47 +20,14 @@ public class RestTableDefinition
private final PaginationConfig paginationConfig;
private final List fields;
- /**
- * Creates a table definition.
- *
- * @param path
- * the URL path to append to the base URL (e.g. "/v4/rockets")
- * @param fields
- * the list of field definitions for this table
- */
public RestTableDefinition(String path, List fields)
- {
- this(path, null, null, fields);
- }
+ { this(path, null, null, fields); }
- /**
- * Creates a table definition with an optional data path for nested responses.
- *
- * @param path
- * the URL path to append to the base URL (e.g. "/v4/rockets")
- * @param dataPath
- * optional JSON path to the data array (e.g. "result" for {"result": [...]})
- * @param fields
- * the list of field definitions for this table
- */
public RestTableDefinition(String path, String dataPath, List fields)
- {
- this(path, dataPath, null, fields);
- }
+ { this(path, dataPath, null, fields); }
- /**
- * Creates a table definition with optional data path and pagination configuration.
- *
- * @param path
- * the URL path to append to the base URL (e.g. "/v4/rockets")
- * @param dataPath
- * optional JSON path to the data array (e.g. "result" for {"result": [...]})
- * @param paginationConfig
- * optional pagination configuration for this table
- * @param fields
- * the list of field definitions for this table
- */
- public RestTableDefinition(String path, String dataPath, PaginationConfig paginationConfig, List fields)
+ public RestTableDefinition(String path, String dataPath, PaginationConfig paginationConfig,
+ List fields)
{
this.path = path;
this.dataPath = dataPath;
@@ -68,45 +35,10 @@ public RestTableDefinition(String path, String dataPath, PaginationConfig pagina
this.fields = Collections.unmodifiableList(fields);
}
- /**
- * Returns the URL path for this table's endpoint.
- *
- * @return the URL path (e.g. "/v4/rockets")
- */
- public String getPath()
- {
- return path;
- }
-
- /**
- * Returns the JSON path to the data array in nested responses.
- *
- * @return the data path (e.g. "result"), or null if data is at root level
- */
- public String getDataPath()
- {
- return dataPath;
- }
-
- /**
- * Returns the pagination configuration for this table.
- *
- * @return the pagination configuration, or null if no pagination is configured
- */
- public PaginationConfig getPaginationConfig()
- {
- return paginationConfig;
- }
-
- /**
- * Returns the list of field definitions for this table.
- *
- * @return an unmodifiable list of field definitions
- */
- public List getFields()
- {
- return fields;
- }
+ public String getPath() { return path; }
+ public String getDataPath() { return dataPath; }
+ public PaginationConfig getPaginationConfig() { return paginationConfig; }
+ public List getFields() { return fields; }
@Override
public String toString()
@@ -115,5 +47,3 @@ public String toString()
+ "', paginationConfig=" + paginationConfig + ", fields=" + fields + "}";
}
}
-
-// Made with Bob
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTargetInteraction.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTargetInteraction.java
index 559116e6..829fd44f 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTargetInteraction.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/main/java/com/ibm/connect/restconnector/RestTargetInteraction.java
@@ -13,17 +13,22 @@
import com.ibm.connect.sdk.api.TargetInteraction;
import com.ibm.connect.sdk.util.ModelMapper;
import com.ibm.wdp.connect.common.sdk.api.models.CustomFlightAssetDescriptor;
+import com.ibm.wdp.connect.sdk.connector.AssetDescriptor;
+import com.ibm.wdp.connect.sdk.connector.RowReader;
+import com.ibm.wdp.connect.sdk.connector.SdkTargetInteraction;
/**
* An interaction with an Arrow asset as a target.
+ *
+ * Implements both the legacy {@link TargetInteraction} interface (used by the old connector
+ * path) and the new {@link SdkTargetInteraction} interface (used by the Arrow-native path).
*/
-@SuppressWarnings({ "PMD.AvoidDollarSigns", "PMD.ClassNamingConventions" })
-public class RestTargetInteraction implements TargetInteraction>
+public class RestTargetInteraction implements TargetInteraction>, SdkTargetInteraction
{
private final Properties interactionProperties;
/**
- * Creates an Arrow target interaction.
+ * Creates an Arrow target interaction from a legacy {@link CustomFlightAssetDescriptor}.
*
* @param connector
* the connector managing the connection to the data source
@@ -37,6 +42,35 @@ public RestTargetInteraction(RestConnector connector, CustomFlightAssetDescripto
throw new IllegalArgumentException(RestMsgs.MISSING_CONNECTOR.format());
}
interactionProperties = ModelMapper.toProperties(asset.getInteractionProperties());
+ validateProperties();
+ }
+
+ /**
+ * Creates an Arrow target interaction from an SDK {@link AssetDescriptor}.
+ *
+ * @param connector
+ * the connector managing the connection to the data source
+ * @param asset
+ * the SDK asset to which to write
+ */
+ public RestTargetInteraction(RestConnector connector, AssetDescriptor asset) {
+ if (connector == null) {
+ throw new IllegalArgumentException(RestMsgs.MISSING_CONNECTOR.format());
+ }
+ // Build properties from SDK asset's properties map
+ interactionProperties = new Properties();
+ if (asset.getProperties() != null) {
+ for (final java.util.Map.Entry entry : asset.getProperties().entrySet()) {
+ if (entry.getValue() != null) {
+ interactionProperties.setProperty(entry.getKey(), entry.getValue().toString());
+ }
+ }
+ }
+ validateProperties();
+ }
+
+ private void validateProperties()
+ {
if (interactionProperties.getProperty("schema_name") == null) {
throw new IllegalArgumentException(RestMsgs.MISSING_PROPERTY.format("schema_name"));
}
@@ -45,6 +79,34 @@ public RestTargetInteraction(RestConnector connector, CustomFlightAssetDescripto
}
}
+ // ---- SdkTargetInteraction interface (new path) ----
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setup() {
+ // TODO Perform any setup required before writing
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void consume(RowReader reader) {
+ // TODO Read rows from reader and write to the target
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void wrapup() {
+ // TODO Perform any wrap-up required after writing
+ }
+
+ // ---- TargetInteraction interface (legacy path) ----
+
/**
* {@inheritDoc}
*/
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestAdvancedFeatures.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestAdvancedFeatures.java
index b378d6ba..74352d6f 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestAdvancedFeatures.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestAdvancedFeatures.java
@@ -14,6 +14,7 @@
import org.junit.Test;
+
/**
* Tests for advanced REST connector features including authentication,
* pagination, data paths, and object flattening.
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestForgeSchemaBuilder.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestForgeSchemaBuilder.java
new file mode 100644
index 00000000..73713498
--- /dev/null
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestForgeSchemaBuilder.java
@@ -0,0 +1,145 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.connect.restconnector;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.arrow.vector.types.DateUnit;
+import org.apache.arrow.vector.types.FloatingPointPrecision;
+import org.apache.arrow.vector.types.TimeUnit;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ForgeSchemaBuilder}.
+ */
+public class TestForgeSchemaBuilder
+{
+ @Test
+ public void testBuildSchemaBasicTypes()
+ {
+ final List fieldDefs = Arrays.asList(
+ new RestFieldDefinition("id", "INTEGER", true, true),
+ new RestFieldDefinition("name", "VARCHAR", false, false),
+ new RestFieldDefinition("count", "BIGINT", false, false),
+ new RestFieldDefinition("active", "BOOLEAN", false, false),
+ new RestFieldDefinition("score", "DOUBLE", false, false),
+ new RestFieldDefinition("ratio", "FLOAT", false, false),
+ new RestFieldDefinition("created", "DATE", false, false),
+ new RestFieldDefinition("modified", "TIMESTAMP", false, false));
+
+ final Schema schema = ForgeSchemaBuilder.buildSchema(fieldDefs);
+ assertNotNull(schema);
+ assertEquals(8, schema.getFields().size());
+
+ // id — Integer, not nullable
+ final Field id = findField(schema, "id");
+ assertTrue(id.getType() instanceof ArrowType.Int);
+ assertEquals(32, ((ArrowType.Int) id.getType()).getBitWidth());
+ assertFalse(id.isNullable());
+
+ // name — Utf8, nullable
+ final Field name = findField(schema, "name");
+ assertTrue(name.getType() instanceof ArrowType.Utf8);
+ assertTrue(name.isNullable());
+
+ // count — Int64
+ final Field count = findField(schema, "count");
+ assertTrue(count.getType() instanceof ArrowType.Int);
+ assertEquals(64, ((ArrowType.Int) count.getType()).getBitWidth());
+
+ // active — Bool
+ final Field active = findField(schema, "active");
+ assertTrue(active.getType() instanceof ArrowType.Bool);
+
+ // score — Double
+ final Field score = findField(schema, "score");
+ assertTrue(score.getType() instanceof ArrowType.FloatingPoint);
+ assertEquals(FloatingPointPrecision.DOUBLE,
+ ((ArrowType.FloatingPoint) score.getType()).getPrecision());
+
+ // ratio — Float/Single
+ final Field ratio = findField(schema, "ratio");
+ assertTrue(ratio.getType() instanceof ArrowType.FloatingPoint);
+ assertEquals(FloatingPointPrecision.SINGLE,
+ ((ArrowType.FloatingPoint) ratio.getType()).getPrecision());
+
+ // created — Date(DAY)
+ final Field created = findField(schema, "created");
+ assertTrue(created.getType() instanceof ArrowType.Date);
+ assertEquals(DateUnit.DAY, ((ArrowType.Date) created.getType()).getUnit());
+
+ // modified — Timestamp(MICROSECOND)
+ final Field modified = findField(schema, "modified");
+ assertTrue(modified.getType() instanceof ArrowType.Timestamp);
+ assertEquals(TimeUnit.MICROSECOND, ((ArrowType.Timestamp) modified.getType()).getUnit());
+ }
+
+ @Test
+ public void testJsonTypeBecomesUtf8()
+ {
+ final List fieldDefs = Arrays.asList(
+ new RestFieldDefinition("data", "JSON", false, false));
+ final Schema schema = ForgeSchemaBuilder.buildSchema(fieldDefs);
+ assertTrue(findField(schema, "data").getType() instanceof ArrowType.Utf8);
+ }
+
+ @Test
+ public void testVarCharWithLength()
+ {
+ final List fieldDefs = Arrays.asList(
+ new RestFieldDefinition("label", "VarChar(255)", false, false));
+ final Schema schema = ForgeSchemaBuilder.buildSchema(fieldDefs);
+ assertTrue(findField(schema, "label").getType() instanceof ArrowType.Utf8);
+ }
+
+ @Test
+ public void testUnknownTypeBecomesUtf8()
+ {
+ final List fieldDefs = Arrays.asList(
+ new RestFieldDefinition("misc", "UNKNOWN_TYPE", false, false));
+ final Schema schema = ForgeSchemaBuilder.buildSchema(fieldDefs);
+ assertTrue(findField(schema, "misc").getType() instanceof ArrowType.Utf8);
+ }
+
+ @Test
+ public void testDecimalType()
+ {
+ final List fieldDefs = Arrays.asList(
+ new RestFieldDefinition("price", "DECIMAL", false, false));
+ final Schema schema = ForgeSchemaBuilder.buildSchema(fieldDefs);
+ assertTrue(findField(schema, "price").getType() instanceof ArrowType.Decimal);
+ }
+
+ @Test
+ public void testSmallIntAndTinyInt()
+ {
+ final List fieldDefs = Arrays.asList(
+ new RestFieldDefinition("s", "SMALLINT", false, false),
+ new RestFieldDefinition("t", "TINYINT", false, false));
+ final Schema schema = ForgeSchemaBuilder.buildSchema(fieldDefs);
+ assertEquals(16, ((ArrowType.Int) findField(schema, "s").getType()).getBitWidth());
+ assertEquals(8, ((ArrowType.Int) findField(schema, "t").getType()).getBitWidth());
+ }
+
+ private static Field findField(Schema schema, String name)
+ {
+ for (final Field f : schema.getFields()) {
+ if (f.getName().equals(name)) {
+ return f;
+ }
+ }
+ throw new AssertionError("Field not found: " + name);
+ }
+}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestPropertyValidation.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestPropertyValidation.java
index f876db75..cae304e2 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestPropertyValidation.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestPropertyValidation.java
@@ -7,10 +7,10 @@
import static org.junit.Assert.assertNotNull;
+import com.ibm.wdp.connect.sdk.connector.ConnectionProperties;
+import com.ibm.wdp.connect.sdk.connector.SdkDatasourceTypes;
import org.junit.Test;
-import com.ibm.wdp.connect.common.sdk.api.models.ConnectionProperties;
-
/**
* Tests property validation for the REST connector.
*
@@ -27,7 +27,8 @@ public class TestPropertyValidation
public void testConnectionPropertiesNegative() throws Exception
{
final String typeName = "unknown_type";
- final ConnectionProperties properties = new ConnectionProperties();
+ final ConnectionProperties properties
+ = new ConnectionProperties(null);
RestConnectorFactory.getInstance().createConnector(typeName, properties);
}
@@ -41,19 +42,22 @@ public void testConnectionPropertiesNegative() throws Exception
public void testConnectionProperties() throws Exception
{
// Get the first available datasource type from the factory
- final var datasourceTypes = RestConnectorFactory.getInstance().getDatasourceTypes();
-
- if (datasourceTypes.getDatasourceTypes() == null || datasourceTypes.getDatasourceTypes().isEmpty()) {
+ final SdkDatasourceTypes datasourceTypes
+ = RestConnectorFactory.getInstance().getDatasourceTypes();
+
+ if (datasourceTypes.getTypeNames() == null || datasourceTypes.getTypeNames().isEmpty()
+ || "__rest__".equals(datasourceTypes.getTypeNames().get(0))) {
// No configurations loaded - skip test
System.out.println("No REST connector configurations loaded. Skipping test.");
return;
}
-
- final String typeName = datasourceTypes.getDatasourceTypes().get(0).getName();
- final ConnectionProperties properties = new ConnectionProperties();
-
+
+ final String typeName = datasourceTypes.getTypeNames().get(0);
+ final ConnectionProperties sdkProps
+ = new ConnectionProperties(null);
+
// Create connector - should succeed
- assertNotNull(RestConnectorFactory.getInstance().createConnector(typeName, properties));
+ assertNotNull(RestConnectorFactory.getInstance().createConnector(typeName, sdkProps));
}
}
diff --git a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestRestApiMappingLoader.java b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestRestApiMappingLoader.java
index 775a740c..c946e02e 100644
--- a/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestRestApiMappingLoader.java
+++ b/sdk-gen/subprojects/java/connectors_forge_rest/impl/src/test/java/com/ibm/connect/restconnector/TestRestApiMappingLoader.java
@@ -15,6 +15,7 @@
import org.junit.Test;
+
/**
* Tests for {@link RestApiMappingLoader}.
*/
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/build.gradle b/sdk-gen/subprojects/java/sdk-connector-api/build.gradle
new file mode 100644
index 00000000..c89cbb26
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/build.gradle
@@ -0,0 +1,44 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+
+//
+// Apply the java-library, and maven-publish plugins
+//
+apply plugin: 'java-library'
+apply plugin: 'maven-publish'
+
+group = 'com.ibm.wdp.connect'
+archivesBaseName = 'wdp-connect-sdk-connector-api'
+
+//
+// Declare project dependencies
+//
+dependencies {
+ // Exclude grpc packages that can conflict with those in Liberty. Include everything for test runtime only.
+ api group: 'org.apache.arrow', name: 'flight-core', version: project['arrow.version'], transitive: false
+ runtimeOnly group: 'com.google.flatbuffers', name: 'flatbuffers-java', version: project['flatbuffers.version']
+ runtimeOnly group: 'com.google.protobuf', name: 'protobuf-java', version: project['protobuf.version']
+ implementation group: 'org.apache.arrow', name: 'arrow-format', version: project['arrow.version']
+ implementation group: 'org.apache.arrow', name: 'arrow-memory-core', version: project['arrow.version']
+ implementation group: 'org.apache.arrow', name: 'arrow-vector', version: project['arrow.version']
+ runtimeOnly group: 'io.grpc', name: 'grpc-netty', version: project['grpc.version'], transitive: false
+ runtimeOnly group: 'io.netty', name: 'netty-buffer', version: project['netty.version']
+ runtimeOnly group: 'org.apache.arrow', name: 'arrow-memory-netty', version: project['arrow.version'], transitive: false
+ runtimeOnly group: 'org.apache.arrow', name: 'arrow-memory-netty-buffer-patch', version: project['arrow.version'], transitive: false
+ // End of flight-core
+
+ // Logging
+ implementation group: 'org.slf4j', name: 'slf4j-api', version: project['slf4j.version']
+
+ // Test dependencies
+ testImplementation group: 'junit', name: 'junit', version: project['junit.version']
+ testRuntimeOnly group: 'io.grpc', name: 'grpc-all', version: project['grpc.version']
+ testRuntimeOnly group: 'io.netty', name: 'netty-all', version: project['netty.version']
+ testRuntimeOnly group: 'org.apache.arrow', name: 'arrow-memory-netty', version: project['arrow.version']
+ testRuntimeOnly group: 'org.apache.arrow', name: 'arrow-memory-netty-buffer-patch', version: project['arrow.version']
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowBatchReader.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowBatchReader.java
new file mode 100644
index 00000000..75ff68ba
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowBatchReader.java
@@ -0,0 +1,115 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+
+/**
+ * Concrete Flight-layer implementation of {@link RowReader}.
+ *
+ * Connector authors never subclass this class. They read rows via the {@link RowReader}
+ * interface by calling {@link #nextRow()} and {@link #get(String)}. All Apache Arrow
+ * internals are hidden inside this class.
+ *
+ *
The Flight layer constructs this from incoming {@link VectorSchemaRoot} batches and passes
+ * it to the connector's {@code consume(RowReader)} method.
+ */
+public final class ArrowBatchReader implements RowReader, AutoCloseable
+{
+ private final List batches;
+ private int batchIndex;
+ private VectorSchemaRoot current;
+ private int rowIndex;
+ private int rowCount;
+ private Map vectorCache;
+ private boolean closed;
+
+ /**
+ * Creates an Arrow batch reader over the given batches.
+ *
+ * @param batches
+ * the list of {@link VectorSchemaRoot} batches to iterate; must not be null
+ */
+ public ArrowBatchReader(List batches)
+ {
+ this.batches = batches;
+ this.batchIndex = -1;
+ this.rowIndex = -1;
+ this.rowCount = 0;
+ this.closed = false;
+ advanceBatch();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean nextRow()
+ {
+ if (closed) {
+ return false;
+ }
+ rowIndex++;
+ if (rowIndex < rowCount) {
+ return true;
+ }
+ // Try next batch
+ if (advanceBatch()) {
+ rowIndex = 0;
+ return rowIndex < rowCount;
+ }
+ return false;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Object get(String fieldName)
+ {
+ final FieldVector vector = vectorCache.get(fieldName);
+ if (vector == null) {
+ throw new IllegalArgumentException("Unknown field: " + fieldName);
+ }
+ if (vector.isNull(rowIndex)) {
+ return null;
+ }
+ return ArrowValueExtractor.extract(vector, rowIndex);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void close()
+ {
+ closed = true;
+ }
+
+ // ---- private helpers ----
+
+ private boolean advanceBatch()
+ {
+ batchIndex++;
+ if (batchIndex < batches.size()) {
+ current = batches.get(batchIndex);
+ rowCount = current.getRowCount();
+ rowIndex = -1;
+ cacheVectors();
+ return true;
+ }
+ return false;
+ }
+
+ private void cacheVectors()
+ {
+ vectorCache = new HashMap<>();
+ for (final FieldVector v : current.getFieldVectors()) {
+ vectorCache.put(v.getField().getName(), v);
+ }
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowBatchWriter.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowBatchWriter.java
new file mode 100644
index 00000000..db4ebfd1
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowBatchWriter.java
@@ -0,0 +1,155 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.Schema;
+
+/**
+ * Concrete Flight-layer implementation of {@link RowWriter}.
+ *
+ * Connector authors never subclass this class. They write rows by calling
+ * {@link #startRow()}, {@link #set(String, Object)}, and {@link #endRow()} through
+ * the {@link RowWriter} interface. All Apache Arrow memory management is hidden inside this class.
+ *
+ *
The Flight layer retrieves the accumulated batches via the package-private {@link #batches()}
+ * method after the connector's {@code stream(RowWriter)} call completes.
+ *
+ *
Usage:
+ *
+ * try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, allocator, 1000)) {
+ * interaction.stream(writer);
+ * for (Iterator<VectorSchemaRoot> it = writer.batches(); it.hasNext(); ) {
+ * VectorSchemaRoot root = it.next();
+ * listener.putNext(root);
+ * }
+ * }
+ *
+ */
+public final class ArrowBatchWriter implements RowWriter, AutoCloseable
+{
+ private final Schema schema;
+ private final int batchSize;
+ private final VectorSchemaRoot root;
+ private final List completedBatches;
+
+ private int currentRow;
+ private boolean closed;
+
+ /**
+ * Creates an Arrow batch writer.
+ *
+ * @param schema
+ * the Arrow schema describing the fields to write
+ * @param allocator
+ * the buffer allocator to use for Arrow memory
+ * @param batchSize
+ * the number of rows per batch; when a batch reaches this size it is flushed
+ * automatically on {@link #endRow()}
+ */
+ public ArrowBatchWriter(Schema schema, BufferAllocator allocator, int batchSize)
+ {
+ this.schema = schema;
+ this.batchSize = batchSize > 0 ? batchSize : 1000;
+ this.root = VectorSchemaRoot.create(schema, allocator);
+ this.root.allocateNew();
+ this.completedBatches = new ArrayList<>();
+ this.currentRow = 0;
+ this.closed = false;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void startRow()
+ {
+ // Row position is tracked by currentRow; no pre-row allocation needed
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void set(String fieldName, Object value)
+ {
+ final FieldVector vector = root.getVector(fieldName);
+ if (vector == null) {
+ throw new IllegalArgumentException("Unknown field: " + fieldName);
+ }
+ if (value == null) {
+ vector.setNull(currentRow);
+ } else {
+ ArrowValueExtractor.setValue(vector, currentRow, value);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void endRow()
+ {
+ currentRow++;
+ root.setRowCount(currentRow);
+ if (currentRow >= batchSize) {
+ flushCurrentBatch();
+ }
+ }
+
+ /**
+ * Returns an iterator over all completed batches plus any remaining partial batch.
+ *
+ * For use by the Flight layer after the connector's {@code stream(RowWriter)} method returns.
+ *
+ * @return an iterator over {@link VectorSchemaRoot} batches; caller must not close the roots
+ */
+ public Iterator batches()
+ {
+ if (currentRow > 0) {
+ flushCurrentBatch();
+ }
+ return completedBatches.iterator();
+ }
+
+ /**
+ * Returns the Arrow schema.
+ *
+ * For use by the Flight layer.
+ *
+ * @return the schema
+ */
+ public Schema getSchema()
+ {
+ return schema;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void close()
+ {
+ if (!closed) {
+ closed = true;
+ for (final VectorSchemaRoot batch : completedBatches) {
+ batch.close();
+ }
+ completedBatches.clear();
+ root.close();
+ }
+ }
+
+ // ---- private helpers ----
+
+ private void flushCurrentBatch()
+ {
+ completedBatches.add(root.slice(0, currentRow));
+ root.clear();
+ root.allocateNew();
+ currentRow = 0;
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowValueExtractor.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowValueExtractor.java
new file mode 100644
index 00000000..d361a6ae
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ArrowValueExtractor.java
@@ -0,0 +1,236 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.sql.Date;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.arrow.vector.BigIntVector;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.DateDayVector;
+import org.apache.arrow.vector.DecimalVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.Float4Vector;
+import org.apache.arrow.vector.Float8Vector;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.SmallIntVector;
+import org.apache.arrow.vector.TimeMicroVector;
+import org.apache.arrow.vector.TimeMilliVector;
+import org.apache.arrow.vector.TimeStampMicroTZVector;
+import org.apache.arrow.vector.TinyIntVector;
+import org.apache.arrow.vector.VarBinaryVector;
+import org.apache.arrow.vector.VarCharVector;
+
+/**
+ * Package-private helper that converts values between Java types and Arrow vectors.
+ *
+ *
Used internally by {@link ArrowBatchWriter}, {@link ColumnarArrowBatchWriter},
+ * {@link ArrowBatchReader}, and {@link ColumnarArrowBatchReader}. Not part of the public API.
+ */
+final class ArrowValueExtractor
+{
+ private ArrowValueExtractor()
+ {
+ // utility class
+ }
+
+ /**
+ * Extracts the value at {@code index} from {@code vector} as a plain Java object.
+ *
+ * @param vector
+ * the field vector
+ * @param index
+ * the row index (must not be null at this index)
+ * @return the Java value
+ */
+ static Object extract(FieldVector vector, int index)
+ {
+ if (vector instanceof VarCharVector) {
+ final byte[] bytes = ((VarCharVector) vector).get(index);
+ return bytes != null ? new String(bytes, StandardCharsets.UTF_8) : null;
+ }
+ if (vector instanceof IntVector) {
+ return ((IntVector) vector).get(index);
+ }
+ if (vector instanceof BigIntVector) {
+ return ((BigIntVector) vector).get(index);
+ }
+ if (vector instanceof Float8Vector) {
+ return ((Float8Vector) vector).get(index);
+ }
+ if (vector instanceof Float4Vector) {
+ return ((Float4Vector) vector).get(index);
+ }
+ if (vector instanceof BitVector) {
+ return ((BitVector) vector).get(index) != 0;
+ }
+ if (vector instanceof SmallIntVector) {
+ return (int) ((SmallIntVector) vector).get(index);
+ }
+ if (vector instanceof TinyIntVector) {
+ return (int) ((TinyIntVector) vector).get(index);
+ }
+ if (vector instanceof DateDayVector) {
+ final int days = ((DateDayVector) vector).get(index);
+ return new Date(TimeUnit.DAYS.toMillis(days));
+ }
+ if (vector instanceof TimeStampMicroTZVector) {
+ final long micros = ((TimeStampMicroTZVector) vector).get(index);
+ final long millis = TimeUnit.MICROSECONDS.toMillis(micros);
+ final int nanos = (int) TimeUnit.MICROSECONDS.toNanos(micros % 1000);
+ final Timestamp ts = new Timestamp(millis);
+ ts.setNanos(nanos >= 0 ? nanos : nanos + 1_000_000_000);
+ return ts;
+ }
+ if (vector instanceof TimeMilliVector) {
+ return new Time(((TimeMilliVector) vector).get(index));
+ }
+ if (vector instanceof TimeMicroVector) {
+ return new Time(TimeUnit.MICROSECONDS.toMillis(((TimeMicroVector) vector).get(index)));
+ }
+ if (vector instanceof DecimalVector) {
+ return ((DecimalVector) vector).getObject(index);
+ }
+ if (vector instanceof VarBinaryVector) {
+ return ((VarBinaryVector) vector).get(index);
+ }
+ // Fallback: use getObject if available
+ return vector.getObject(index);
+ }
+
+ /**
+ * Sets the value at {@code index} in {@code vector} from a plain Java object.
+ *
+ * @param vector
+ * the field vector
+ * @param index
+ * the row index
+ * @param value
+ * the value to write; must not be null
+ */
+ static void setValue(FieldVector vector, int index, Object value)
+ {
+ if (vector instanceof VarCharVector) {
+ final byte[] bytes = value instanceof byte[] ? (byte[]) value
+ : value.toString().getBytes(StandardCharsets.UTF_8);
+ ((VarCharVector) vector).setSafe(index, bytes, 0, bytes.length);
+ } else if (vector instanceof IntVector) {
+ ((IntVector) vector).setSafe(index, toInt(value));
+ } else if (vector instanceof BigIntVector) {
+ ((BigIntVector) vector).setSafe(index, toLong(value));
+ } else if (vector instanceof Float8Vector) {
+ ((Float8Vector) vector).setSafe(index, toDouble(value));
+ } else if (vector instanceof Float4Vector) {
+ ((Float4Vector) vector).setSafe(index, toFloat(value));
+ } else if (vector instanceof BitVector) {
+ ((BitVector) vector).setSafe(index, toBit(value));
+ } else if (vector instanceof SmallIntVector) {
+ ((SmallIntVector) vector).setSafe(index, toInt(value));
+ } else if (vector instanceof TinyIntVector) {
+ ((TinyIntVector) vector).setSafe(index, toInt(value));
+ } else if (vector instanceof DateDayVector) {
+ ((DateDayVector) vector).setSafe(index, toDateDay(value));
+ } else if (vector instanceof TimeStampMicroTZVector) {
+ ((TimeStampMicroTZVector) vector).setSafe(index, toTimestampMicros(value));
+ } else if (vector instanceof TimeMilliVector) {
+ ((TimeMilliVector) vector).setSafe(index, toTimeMilli(value));
+ } else if (vector instanceof TimeMicroVector) {
+ ((TimeMicroVector) vector).setSafe(index, toTimeMicro(value));
+ } else if (vector instanceof DecimalVector) {
+ final DecimalVector dv = (DecimalVector) vector;
+ dv.setSafe(index, toBigDecimal(value, dv.getScale()));
+ } else if (vector instanceof VarBinaryVector) {
+ final byte[] bytes = value instanceof byte[] ? (byte[]) value
+ : value.toString().getBytes(StandardCharsets.UTF_8);
+ ((VarBinaryVector) vector).setSafe(index, bytes, 0, bytes.length);
+ } else {
+ // Fallback: attempt varchar
+ final byte[] bytes = value.toString().getBytes(StandardCharsets.UTF_8);
+ if (vector instanceof VarCharVector) {
+ ((VarCharVector) vector).setSafe(index, bytes, 0, bytes.length);
+ }
+ }
+ }
+
+ static int toInt(Object v)
+ {
+ if (v instanceof Number) return ((Number) v).intValue();
+ return Integer.parseInt(v.toString());
+ }
+
+ static long toLong(Object v)
+ {
+ if (v instanceof Number) return ((Number) v).longValue();
+ return Long.parseLong(v.toString());
+ }
+
+ static double toDouble(Object v)
+ {
+ if (v instanceof Number) return ((Number) v).doubleValue();
+ return Double.parseDouble(v.toString());
+ }
+
+ static float toFloat(Object v)
+ {
+ if (v instanceof Number) return ((Number) v).floatValue();
+ return Float.parseFloat(v.toString());
+ }
+
+ static int toBit(Object v)
+ {
+ if (v instanceof Boolean) return ((Boolean) v) ? 1 : 0;
+ if (v instanceof Number) return ((Number) v).intValue() != 0 ? 1 : 0;
+ return Boolean.parseBoolean(v.toString()) ? 1 : 0;
+ }
+
+ static int toDateDay(Object v)
+ {
+ if (v instanceof Date) {
+ return (int) TimeUnit.MILLISECONDS.toDays(((Date) v).getTime());
+ }
+ if (v instanceof java.util.Date) {
+ return (int) TimeUnit.MILLISECONDS.toDays(((java.util.Date) v).getTime());
+ }
+ return (int) TimeUnit.MILLISECONDS.toDays(Date.valueOf(v.toString()).getTime());
+ }
+
+ static long toTimestampMicros(Object v)
+ {
+ if (v instanceof Timestamp) {
+ final Timestamp ts = (Timestamp) v;
+ return TimeUnit.MILLISECONDS.toMicros(ts.getTime())
+ + TimeUnit.NANOSECONDS.toMicros(ts.getNanos() % 1_000_000L);
+ }
+ if (v instanceof java.util.Date) {
+ return TimeUnit.MILLISECONDS.toMicros(((java.util.Date) v).getTime());
+ }
+ return TimeUnit.MILLISECONDS.toMicros(Timestamp.valueOf(v.toString()).getTime());
+ }
+
+ static int toTimeMilli(Object v)
+ {
+ if (v instanceof Time) return (int) (((Time) v).getTime() % 86_400_000L);
+ return (int) (Time.valueOf(v.toString()).getTime() % 86_400_000L);
+ }
+
+ static long toTimeMicro(Object v)
+ {
+ if (v instanceof Time) return TimeUnit.MILLISECONDS.toMicros(((Time) v).getTime() % 86_400_000L);
+ return TimeUnit.MILLISECONDS.toMicros(Time.valueOf(v.toString()).getTime() % 86_400_000L);
+ }
+
+ static BigDecimal toBigDecimal(Object v, int scale)
+ {
+ final BigDecimal bd = v instanceof BigDecimal ? (BigDecimal) v : new BigDecimal(v.toString());
+ return bd.setScale(scale, java.math.RoundingMode.HALF_UP);
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/AssetDescriptor.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/AssetDescriptor.java
new file mode 100644
index 00000000..14a68f4a
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/AssetDescriptor.java
@@ -0,0 +1,141 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Descriptor for a data asset (table, schema, container, etc.) accessible through a connector.
+ *
+ *
Equivalent in purpose to the SDK's {@code CustomFlightAssetDescriptor} but with no dependency
+ * on the SDK model framework. Connector authors use this class to identify the asset they are
+ * reading from, writing to, or discovering.
+ *
+ *
Instances are immutable once constructed.
+ */
+public final class AssetDescriptor
+{
+ private final String id;
+ private final String name;
+ private final String path;
+ private final String datasourceTypeName;
+ private final Map properties;
+ private final boolean hasChildren;
+ private final int batchSize;
+
+ /**
+ * Creates an asset descriptor.
+ *
+ * @param id
+ * the unique identifier for this asset (may be null for newly discovered assets)
+ * @param name
+ * the display name of the asset (e.g. table name)
+ * @param path
+ * the hierarchical path to this asset (e.g. "schema/table"), may be null
+ * @param datasourceTypeName
+ * the datasource type name identifying the connector to use
+ * @param properties
+ * additional asset-specific properties (e.g. schema name, connection options); may be null
+ * @param hasChildren
+ * true if this asset is a container (schema, catalog) with child assets
+ * @param batchSize
+ * the preferred number of rows per Arrow batch; 0 means use connector default
+ */
+ public AssetDescriptor(String id, String name, String path, String datasourceTypeName,
+ Map properties, boolean hasChildren, int batchSize)
+ {
+ this.id = id;
+ this.name = name;
+ this.path = path;
+ this.datasourceTypeName = datasourceTypeName;
+ this.properties = properties != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(properties))
+ : Collections.emptyMap();
+ this.hasChildren = hasChildren;
+ this.batchSize = batchSize;
+ }
+
+ /**
+ * Returns the unique identifier for this asset.
+ *
+ * @return the asset id, or null
+ */
+ public String getId()
+ {
+ return id;
+ }
+
+ /**
+ * Returns the display name of the asset.
+ *
+ * @return the asset name
+ */
+ public String getName()
+ {
+ return name;
+ }
+
+ /**
+ * Returns the hierarchical path to this asset.
+ *
+ * @return the path (e.g. "schema/table"), or null
+ */
+ public String getPath()
+ {
+ return path;
+ }
+
+ /**
+ * Returns the datasource type name identifying the connector.
+ *
+ * @return the datasource type name
+ */
+ public String getDatasourceTypeName()
+ {
+ return datasourceTypeName;
+ }
+
+ /**
+ * Returns the additional asset-specific properties.
+ *
+ * @return an unmodifiable map of properties; never null
+ */
+ public Map getProperties()
+ {
+ return properties;
+ }
+
+ /**
+ * Returns whether this asset is a container with child assets.
+ *
+ * @return true if this asset has children (schema, catalog)
+ */
+ public boolean hasChildren()
+ {
+ return hasChildren;
+ }
+
+ /**
+ * Returns the preferred number of rows per Arrow batch.
+ *
+ * @return the batch size, or 0 to use the connector default
+ */
+ public int getBatchSize()
+ {
+ return batchSize;
+ }
+
+ @Override
+ public String toString()
+ {
+ return "AssetDescriptor{id='" + id + "', name='" + name + "', path='" + path
+ + "', datasourceTypeName='" + datasourceTypeName + "', batchSize=" + batchSize + "}";
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarArrowBatchReader.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarArrowBatchReader.java
new file mode 100644
index 00000000..825bf89e
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarArrowBatchReader.java
@@ -0,0 +1,96 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+
+/**
+ * Concrete Flight-layer implementation of {@link ColumnarReader}.
+ *
+ * Connector authors never subclass this class. They read columns via the {@link ColumnarReader}
+ * interface by calling {@link #nextBatch()} and {@link #getColumn(String)}. All Apache Arrow
+ * internals are hidden inside this class.
+ *
+ *
The Flight layer constructs this from incoming {@link VectorSchemaRoot} batches and passes
+ * it to the connector's {@code consume(ColumnarReader)} method.
+ */
+public final class ColumnarArrowBatchReader implements ColumnarReader, AutoCloseable
+{
+ private final List batches;
+ private int batchIndex;
+ private VectorSchemaRoot current;
+ private Map columnCache;
+ private boolean closed;
+
+ /**
+ * Creates a columnar Arrow batch reader over the given batches.
+ *
+ * @param batches
+ * the list of {@link VectorSchemaRoot} batches to iterate; must not be null
+ */
+ public ColumnarArrowBatchReader(List batches)
+ {
+ this.batches = batches;
+ this.batchIndex = -1;
+ this.closed = false;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean nextBatch()
+ {
+ if (closed) {
+ return false;
+ }
+ batchIndex++;
+ if (batchIndex < batches.size()) {
+ current = batches.get(batchIndex);
+ columnCache = new HashMap<>();
+ extractColumns();
+ return true;
+ }
+ return false;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Object[] getColumn(String fieldName)
+ {
+ final Object[] column = columnCache.get(fieldName);
+ if (column == null) {
+ throw new IllegalArgumentException("Unknown field: " + fieldName);
+ }
+ return column;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void close()
+ {
+ closed = true;
+ }
+
+ // ---- private helpers ----
+
+ private void extractColumns()
+ {
+ final int rowCount = current.getRowCount();
+ for (final FieldVector vector : current.getFieldVectors()) {
+ final Object[] values = new Object[rowCount];
+ for (int i = 0; i < rowCount; i++) {
+ values[i] = vector.isNull(i) ? null : ArrowValueExtractor.extract(vector, i);
+ }
+ columnCache.put(vector.getField().getName(), values);
+ }
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarArrowBatchWriter.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarArrowBatchWriter.java
new file mode 100644
index 00000000..7954d93f
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarArrowBatchWriter.java
@@ -0,0 +1,131 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.Schema;
+
+/**
+ * Concrete Flight-layer implementation of {@link ColumnarWriter}.
+ *
+ * Connector authors never subclass this class. They write columns by calling
+ * {@link #writeColumn(String, Object[])} for each column in a batch, then {@link #flushBatch()}.
+ * All Apache Arrow memory management is hidden inside this class.
+ *
+ *
The Flight layer retrieves the accumulated batches via the package-private {@link #batches()}
+ * method after the connector's {@code stream(ColumnarWriter)} call completes.
+ */
+public final class ColumnarArrowBatchWriter implements ColumnarWriter, AutoCloseable
+{
+ private final Schema schema;
+ private final VectorSchemaRoot root;
+ private final List completedBatches;
+ private int currentBatchRows;
+ private boolean closed;
+
+ /**
+ * Creates a columnar Arrow batch writer.
+ *
+ * @param schema
+ * the Arrow schema describing the fields to write
+ * @param allocator
+ * the buffer allocator to use for Arrow memory
+ * @param batchSize
+ * hint for initial allocation; actual batch size is driven by {@link #writeColumn} array lengths
+ */
+ public ColumnarArrowBatchWriter(Schema schema, BufferAllocator allocator, int batchSize)
+ {
+ this.schema = schema;
+ this.root = VectorSchemaRoot.create(schema, allocator);
+ this.root.allocateNew();
+ this.completedBatches = new ArrayList<>();
+ this.currentBatchRows = 0;
+ this.closed = false;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void writeColumn(String fieldName, Object[] values)
+ {
+ final FieldVector vector = root.getVector(fieldName);
+ if (vector == null) {
+ throw new IllegalArgumentException("Unknown field: " + fieldName);
+ }
+ for (int i = 0; i < values.length; i++) {
+ if (values[i] == null) {
+ vector.setNull(i);
+ } else {
+ ArrowValueExtractor.setValue(vector, i, values[i]);
+ }
+ }
+ // Track row count from the first column written in this batch
+ if (currentBatchRows == 0 && values.length > 0) {
+ currentBatchRows = values.length;
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void flushBatch()
+ {
+ if (currentBatchRows > 0) {
+ root.setRowCount(currentBatchRows);
+ completedBatches.add(root.slice(0, currentBatchRows));
+ root.clear();
+ root.allocateNew();
+ currentBatchRows = 0;
+ }
+ }
+
+ /**
+ * Returns an iterator over all completed batches.
+ *
+ * For use by the Flight layer.
+ *
+ * @return an iterator over {@link VectorSchemaRoot} batches
+ */
+ public Iterator batches()
+ {
+ if (currentBatchRows > 0) {
+ flushBatch();
+ }
+ return completedBatches.iterator();
+ }
+
+ /**
+ * Returns the Arrow schema.
+ *
+ * For use by the Flight layer.
+ *
+ * @return the schema
+ */
+ public Schema getSchema()
+ {
+ return schema;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void close()
+ {
+ if (!closed) {
+ closed = true;
+ for (final VectorSchemaRoot batch : completedBatches) {
+ batch.close();
+ }
+ completedBatches.clear();
+ root.close();
+ }
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarReader.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarReader.java
new file mode 100644
index 00000000..7a4e90e9
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarReader.java
@@ -0,0 +1,51 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Reader interface for columnar data consumption.
+ *
+ *
Connector authors call {@link #nextBatch()} to advance to the next batch and
+ * {@link #getColumn(String)} to read all values for a column in one call. Used in target
+ * connectors where Arrow batches from the Flight stream are presented column by column.
+ *
+ *
No Apache Arrow types are exposed through this interface — connector authors do not need
+ * Arrow knowledge to implement a columnar target connector.
+ *
+ *
Example usage:
+ *
+ * public void consume(ColumnarReader reader) throws Exception {
+ * while (reader.nextBatch()) {
+ * Object[] ids = reader.getColumn("id");
+ * Object[] names = reader.getColumn("name");
+ * target.insertBatch(ids, names);
+ * }
+ * }
+ *
+ */
+public interface ColumnarReader
+{
+ /**
+ * Advances the cursor to the next batch.
+ *
+ * @return {@code true} if there is a batch available; {@code false} if all batches have been consumed
+ */
+ boolean nextBatch();
+
+ /**
+ * Returns all values for the named field in the current batch.
+ *
+ * Must only be called after a successful {@link #nextBatch()} call.
+ *
+ * @param fieldName
+ * the field name as defined in the schema
+ * @return array of values for this field in the current batch; elements may be {@code null}
+ * for nullable fields
+ */
+ Object[] getColumn(String fieldName);
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarWriter.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarWriter.java
new file mode 100644
index 00000000..ded2681c
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ColumnarWriter.java
@@ -0,0 +1,51 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Writer interface for columnar data production.
+ *
+ *
Connector authors call {@link #writeColumn(String, Object[])} for each column in a batch,
+ * then {@link #flushBatch()} to commit the batch. This is suitable for columnar data sources
+ * such as Parquet files or columnar databases where data is organized by column.
+ *
+ *
No Apache Arrow types are exposed through this interface — connector authors do not need
+ * Arrow knowledge to implement a columnar connector.
+ *
+ *
Example usage:
+ *
+ * public void stream(ColumnarWriter writer) throws Exception {
+ * for (ColumnBatch batch : source.batches()) {
+ * writer.writeColumn("id", batch.getColumn("id"));
+ * writer.writeColumn("name", batch.getColumn("name"));
+ * writer.flushBatch();
+ * }
+ * }
+ *
+ */
+public interface ColumnarWriter
+{
+ /**
+ * Writes all values for a single column in the current batch.
+ *
+ * All columns written before the next {@link #flushBatch()} must have the same array length.
+ *
+ * @param fieldName
+ * the field name as defined in the schema
+ * @param values
+ * the column values for this batch; may contain null elements for nullable fields.
+ * Supported element types match those described in {@link RowWriter#set}.
+ */
+ void writeColumn(String fieldName, Object[] values);
+
+ /**
+ * Flushes the current batch. All columns must have been written before calling this method.
+ * Resets the writer state to accept the next batch.
+ */
+ void flushBatch();
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ConnectionProperties.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ConnectionProperties.java
new file mode 100644
index 00000000..ba6dd260
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/ConnectionProperties.java
@@ -0,0 +1,79 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Connection properties provided to a connector at connection time.
+ *
+ *
Wraps a {@code Map<String, Object>} of key/value pairs. Values are typically strings
+ * (host, port, database name, credentials) but may be any serializable type.
+ *
+ *
Instances are immutable once constructed.
+ */
+public final class ConnectionProperties
+{
+ private final Map properties;
+
+ /**
+ * Creates connection properties from a map.
+ *
+ * @param properties
+ * the property map; may be null (treated as empty)
+ */
+ public ConnectionProperties(Map properties)
+ {
+ this.properties = properties != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(properties))
+ : Collections.emptyMap();
+ }
+
+ /**
+ * Returns the value of the named property.
+ *
+ * @param key
+ * the property name
+ * @return the property value, or null if not present
+ */
+ public Object get(String key)
+ {
+ return properties.get(key);
+ }
+
+ /**
+ * Returns the value of the named property as a String, or null.
+ *
+ * @param key
+ * the property name
+ * @return the string value, or null if not present
+ */
+ public String getString(String key)
+ {
+ final Object value = properties.get(key);
+ return value != null ? value.toString() : null;
+ }
+
+ /**
+ * Returns all properties as an unmodifiable map.
+ *
+ * @return the properties map; never null
+ */
+ public Map asMap()
+ {
+ return properties;
+ }
+
+ @Override
+ public String toString()
+ {
+ return "ConnectionProperties{keys=" + properties.keySet() + "}";
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/DiscoveryCriteria.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/DiscoveryCriteria.java
new file mode 100644
index 00000000..7fcb0e0f
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/DiscoveryCriteria.java
@@ -0,0 +1,77 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Criteria used to scope an asset discovery request.
+ *
+ * Passed to {@link SdkConnector#getDiscoveryInteraction(DiscoveryCriteria)} and then to
+ * {@link SdkDiscoveryInteraction#discoverAssets(DiscoveryCriteria)} to restrict the set of
+ * assets returned (e.g. browse only a specific schema, or browse assets of a specific type).
+ *
+ *
Instances are immutable once constructed.
+ */
+public final class DiscoveryCriteria
+{
+ private final String path;
+ private final String datasourceTypeName;
+ private final ConnectionProperties connectionProperties;
+
+ /**
+ * Creates discovery criteria.
+ *
+ * @param path
+ * optional hierarchical path to browse (e.g. "my_schema"); null means root
+ * @param datasourceTypeName
+ * the datasource type name identifying the connector
+ * @param connectionProperties
+ * the connection properties to use when connecting to the source
+ */
+ public DiscoveryCriteria(String path, String datasourceTypeName, ConnectionProperties connectionProperties)
+ {
+ this.path = path;
+ this.datasourceTypeName = datasourceTypeName;
+ this.connectionProperties = connectionProperties;
+ }
+
+ /**
+ * Returns the browse path.
+ *
+ * @return the path, or null for root-level discovery
+ */
+ public String getPath()
+ {
+ return path;
+ }
+
+ /**
+ * Returns the datasource type name.
+ *
+ * @return the datasource type name
+ */
+ public String getDatasourceTypeName()
+ {
+ return datasourceTypeName;
+ }
+
+ /**
+ * Returns the connection properties.
+ *
+ * @return the connection properties
+ */
+ public ConnectionProperties getConnectionProperties()
+ {
+ return connectionProperties;
+ }
+
+ @Override
+ public String toString()
+ {
+ return "DiscoveryCriteria{path='" + path + "', datasourceTypeName='" + datasourceTypeName + "'}";
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/RowReader.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/RowReader.java
new file mode 100644
index 00000000..fbddca07
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/RowReader.java
@@ -0,0 +1,50 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Reader interface for row-based data consumption.
+ *
+ *
Connector authors call {@link #nextRow()} to advance to the next row and
+ * {@link #get(String)} to read field values. Used in target connectors where Arrow
+ * batches from the Flight stream are presented row by row.
+ *
+ *
No Apache Arrow types are exposed through this interface — connector authors do not need
+ * Arrow knowledge to implement a row-based target connector.
+ *
+ *
Example usage:
+ *
+ * public void consume(RowReader reader) throws Exception {
+ * while (reader.nextRow()) {
+ * String id = (String) reader.get("id");
+ * Integer count = (Integer) reader.get("count");
+ * target.insert(id, count);
+ * }
+ * }
+ *
+ */
+public interface RowReader
+{
+ /**
+ * Advances the cursor to the next row.
+ *
+ * @return {@code true} if there is a row available; {@code false} if all rows have been consumed
+ */
+ boolean nextRow();
+
+ /**
+ * Returns the value of the named field in the current row.
+ *
+ * Must only be called after a successful {@link #nextRow()} call.
+ *
+ * @param fieldName
+ * the field name as defined in the schema
+ * @return the field value, or {@code null} if the value is SQL null
+ */
+ Object get(String fieldName);
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/RowWriter.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/RowWriter.java
new file mode 100644
index 00000000..442f6f53
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/RowWriter.java
@@ -0,0 +1,57 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Writer interface for row-based data production.
+ *
+ *
Connector authors call {@link #startRow()}, one or more {@link #set(String, Object)} calls,
+ * then {@link #endRow()} for each row. The implementation accumulates rows into Arrow batches
+ * and flushes automatically at the configured {@code batchSize}.
+ *
+ *
No Apache Arrow types are exposed through this interface — connector authors do not need
+ * Arrow knowledge to implement a row-based connector.
+ *
+ *
Example usage:
+ *
+ * public void stream(RowWriter writer) throws Exception {
+ * for (MyRow row : source.rows()) {
+ * writer.startRow();
+ * writer.set("id", row.getId());
+ * writer.set("name", row.getName());
+ * writer.endRow();
+ * }
+ * }
+ *
+ */
+public interface RowWriter
+{
+ /**
+ * Begins a new row. Must be called before any {@link #set(String, Object)} call for this row.
+ */
+ void startRow();
+
+ /**
+ * Sets the value for the named field in the current row.
+ *
+ * @param fieldName
+ * the field name as defined in the schema
+ * @param value
+ * the value to write; may be null for nullable fields. Supported Java types:
+ * {@code String}, {@code Integer}, {@code Long}, {@code Double}, {@code Float},
+ * {@code Boolean}, {@code java.sql.Date}, {@code java.sql.Timestamp},
+ * {@code byte[]}, and numeric wrappers.
+ */
+ void set(String fieldName, Object value);
+
+ /**
+ * Ends the current row and adds it to the current batch.
+ * When the batch reaches the configured batch size, it is automatically flushed.
+ */
+ void endRow();
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkColumnarSourceInteraction.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkColumnarSourceInteraction.java
new file mode 100644
index 00000000..9ab1670a
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkColumnarSourceInteraction.java
@@ -0,0 +1,69 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.List;
+
+import org.apache.arrow.flight.Ticket;
+import org.apache.arrow.vector.types.pojo.Schema;
+
+/**
+ * Source interaction for connectors that produce data column-by-column rather than row-by-row.
+ *
+ * Suitable for data sources where data is naturally organised in columnar format, such as
+ * Parquet files or columnar databases. The Flight layer detects this interface via {@code instanceof}
+ * and creates a {@link ColumnarArrowBatchWriter} instead of an {@link ArrowBatchWriter}.
+ *
+ *
Connector authors pick the right sub-interface and get exactly the methods they need.
+ * There is no {@code UnsupportedOperationException} and no instanceof inside connectors.
+ *
+ *
Example:
+ *
+ * public class MyColumnarSourceInteraction implements SdkColumnarSourceInteraction {
+ * {@literal @}Override
+ * public void stream(ColumnarWriter writer) throws Exception {
+ * for (ColumnBatch batch : source.batches()) {
+ * writer.writeColumn("id", batch.ids());
+ * writer.writeColumn("value", batch.values());
+ * writer.flushBatch();
+ * }
+ * }
+ * // ... getSchema(), getTickets(), close()
+ * }
+ *
+ */
+public interface SdkColumnarSourceInteraction extends SdkSourceInteraction
+{
+ /**
+ * {@inheritDoc}
+ *
+ * Default implementation delegates to {@link #stream(ColumnarWriter)}.
+ * The Flight layer calls this overload directly — connector authors implement
+ * {@link #stream(ColumnarWriter)} instead.
+ */
+ @Override
+ default void stream(RowWriter writer) throws Exception
+ {
+ throw new UnsupportedOperationException(
+ "SdkColumnarSourceInteraction.stream(RowWriter) should not be called directly. "
+ + "The Flight layer calls stream(ColumnarWriter).");
+ }
+
+ /**
+ * Streams data from the source into the provided {@link ColumnarWriter}.
+ *
+ *
The connector must write each column via {@link ColumnarWriter#writeColumn(String, Object[])}
+ * and call {@link ColumnarWriter#flushBatch()} after all columns for a batch are written.
+ *
+ * @param writer
+ * the columnar writer to receive column data
+ * @throws Exception
+ * if an error occurs during streaming
+ */
+ void stream(ColumnarWriter writer) throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkColumnarTargetInteraction.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkColumnarTargetInteraction.java
new file mode 100644
index 00000000..a840aaed
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkColumnarTargetInteraction.java
@@ -0,0 +1,62 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Target interaction for connectors that consume data column-by-column rather than row-by-row.
+ *
+ *
Suitable for data targets where data is naturally organised in columnar format, such as
+ * Parquet files or columnar databases. The Flight layer detects this interface via {@code instanceof}
+ * and creates a {@link ColumnarArrowBatchReader} instead of an {@link ArrowBatchReader}.
+ *
+ *
Connector authors pick the right sub-interface and get exactly the methods they need.
+ * There is no {@code UnsupportedOperationException} and no instanceof inside connectors.
+ *
+ *
Example:
+ *
+ * public class MyColumnarTargetInteraction implements SdkColumnarTargetInteraction {
+ * {@literal @}Override
+ * public void consume(ColumnarReader reader) throws Exception {
+ * while (reader.nextBatch()) {
+ * Object[] ids = reader.getColumn("id");
+ * Object[] values = reader.getColumn("value");
+ * target.insertBatch(ids, values);
+ * }
+ * }
+ * // ... setup(), wrapup(), close()
+ * }
+ *
+ */
+public interface SdkColumnarTargetInteraction extends SdkTargetInteraction
+{
+ /**
+ * {@inheritDoc}
+ *
+ * Default implementation throws — the Flight layer calls {@link #consume(ColumnarReader)} instead.
+ */
+ @Override
+ default void consume(RowReader reader) throws Exception
+ {
+ throw new UnsupportedOperationException(
+ "SdkColumnarTargetInteraction.consume(RowReader) should not be called directly. "
+ + "The Flight layer calls consume(ColumnarReader).");
+ }
+
+ /**
+ * Consumes incoming data from the provided {@link ColumnarReader}.
+ *
+ *
The connector must call {@link ColumnarReader#nextBatch()} to advance to each batch
+ * and {@link ColumnarReader#getColumn(String)} to retrieve column values.
+ *
+ * @param reader
+ * the columnar reader providing column data
+ * @throws Exception
+ * if an error occurs during consumption
+ */
+ void consume(ColumnarReader reader) throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkConnector.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkConnector.java
new file mode 100644
index 00000000..e6bdc062
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkConnector.java
@@ -0,0 +1,90 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import org.apache.arrow.vector.types.pojo.Schema;
+
+/**
+ * Minimal SDK connector interface.
+ *
+ *
Intentionally simpler than the library's {@code Connector<P,I,O,D>} — connector authors
+ * should not need to understand {@code PartitionPlan}, {@code ExecutionPhase}, or other
+ * library-specific lifecycle concepts.
+ *
+ *
The generic parameters let connector authors expose concrete interaction types without casts:
+ *
+ * public class MyConnector implements SdkConnector<MySourceInteraction, MyTargetInteraction,
+ * MyDiscoveryInteraction> { ... }
+ *
+ *
+ * @param
+ * the source interaction type (must extend {@link SdkSourceInteraction})
+ * @param
+ * the target interaction type (must extend {@link SdkTargetInteraction})
+ * @param
+ * the discovery interaction type (must extend {@link SdkDiscoveryInteraction})
+ */
+public interface SdkConnector
+ extends AutoCloseable
+{
+ /**
+ * Establishes the underlying connection to the data source.
+ *
+ * @throws Exception
+ * if the connection cannot be established
+ */
+ void connect() throws Exception;
+
+ /**
+ * Returns the Arrow schema for the named asset.
+ *
+ * @param asset
+ * the asset descriptor identifying the table or object
+ * @return the Arrow {@link Schema} describing the asset's fields
+ * @throws Exception
+ * if the schema cannot be determined
+ */
+ Schema getSchema(AssetDescriptor asset) throws Exception;
+
+ /**
+ * Creates a source interaction for reading data from the named asset.
+ *
+ * @param asset
+ * the asset descriptor identifying the table or object to read
+ * @param ticket
+ * the Arrow Flight ticket identifying this particular partition
+ * @return a new source interaction; caller must close it when done
+ * @throws Exception
+ * if the interaction cannot be created
+ */
+ S getSourceInteraction(AssetDescriptor asset, org.apache.arrow.flight.Ticket ticket) throws Exception;
+
+ /**
+ * Creates a target interaction for writing data to the named asset.
+ *
+ * @param asset
+ * the asset descriptor identifying the table or object to write
+ * @return a new target interaction; caller must close it when done
+ * @throws Exception
+ * if the interaction cannot be created
+ */
+ T getTargetInteraction(AssetDescriptor asset) throws Exception;
+
+ /**
+ * Creates a discovery interaction for browsing available assets.
+ *
+ * @param criteria
+ * the criteria scoping the discovery request
+ * @return a new discovery interaction; caller must close it when done
+ * @throws Exception
+ * if the interaction cannot be created
+ */
+ D getDiscoveryInteraction(DiscoveryCriteria criteria) throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkConnectorFactory.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkConnectorFactory.java
new file mode 100644
index 00000000..4b06e86a
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkConnectorFactory.java
@@ -0,0 +1,56 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Factory for creating {@link SdkConnector} instances.
+ *
+ * Implementations are discovered at runtime via {@code java.util.ServiceLoader}. Register
+ * a factory by placing its fully-qualified class name in
+ * {@code META-INF/services/com.ibm.wdp.connect.sdk.connector.SdkConnectorFactory}.
+ *
+ *
Example:
+ *
+ * public class MyConnectorFactory implements SdkConnectorFactory {
+ * {@literal @}Override
+ * public SdkDatasourceTypes getDatasourceTypes() {
+ * return new SdkDatasourceTypes(Collections.singletonList("my_connector"));
+ * }
+ * {@literal @}Override
+ * public SdkConnector<?, ?, ?> createConnector(String datasourceTypeName,
+ * ConnectionProperties props) {
+ * return new MyConnector(props);
+ * }
+ * }
+ *
+ */
+public interface SdkConnectorFactory
+{
+ /**
+ * Returns the datasource type names handled by this factory.
+ *
+ * The returned value is used by the runtime to route connector creation requests
+ * to the correct factory.
+ *
+ * @return the supported datasource type names
+ */
+ SdkDatasourceTypes getDatasourceTypes();
+
+ /**
+ * Creates a new connector for the specified datasource type and connection properties.
+ *
+ * @param datasourceTypeName
+ * the datasource type name (must be one of those returned by {@link #getDatasourceTypes()})
+ * @param properties
+ * the connection properties provided by the user
+ * @return a new connector instance; caller is responsible for closing it
+ * @throws Exception
+ * if the connector cannot be created
+ */
+ SdkConnector, ?, ?> createConnector(String datasourceTypeName, ConnectionProperties properties) throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkDatasourceTypes.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkDatasourceTypes.java
new file mode 100644
index 00000000..c912403b
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkDatasourceTypes.java
@@ -0,0 +1,66 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Holds the list of datasource type names that an {@link SdkConnectorFactory} handles.
+ *
+ *
Returned by {@link SdkConnectorFactory#getDatasourceTypes()} so the runtime can route
+ * connector creation requests to the correct factory.
+ *
+ *
Instances are immutable once constructed.
+ */
+public final class SdkDatasourceTypes
+{
+ private final List typeNames;
+
+ /**
+ * Creates a datasource types holder.
+ *
+ * @param typeNames
+ * the list of datasource type names handled by the owning factory; must not be null or empty
+ */
+ public SdkDatasourceTypes(List typeNames)
+ {
+ if (typeNames == null || typeNames.isEmpty()) {
+ throw new IllegalArgumentException("typeNames must not be null or empty");
+ }
+ this.typeNames = Collections.unmodifiableList(typeNames);
+ }
+
+ /**
+ * Returns the datasource type names.
+ *
+ * @return an unmodifiable list of type names; never null or empty
+ */
+ public List getTypeNames()
+ {
+ return typeNames;
+ }
+
+ /**
+ * Returns true if this holder contains the given type name (case-sensitive).
+ *
+ * @param typeName
+ * the type name to test
+ * @return true if the type is handled
+ */
+ public boolean handles(String typeName)
+ {
+ return typeNames.contains(typeName);
+ }
+
+ @Override
+ public String toString()
+ {
+ return "SdkDatasourceTypes{typeNames=" + typeNames + "}";
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkDiscoveryInteraction.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkDiscoveryInteraction.java
new file mode 100644
index 00000000..7c6c8b4a
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkDiscoveryInteraction.java
@@ -0,0 +1,33 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.List;
+
+/**
+ * Discovery interaction for browsing assets available through a connector.
+ *
+ * Functionally equivalent to the library's {@code DiscoveryInteraction} but does not require
+ * any library-specific types. Connector authors implement this interface to expose browseable
+ * assets (tables, schemas, etc.) without knowledge of the library's lifecycle management.
+ *
+ *
Instances are obtained from {@link SdkConnector#getDiscoveryInteraction(DiscoveryCriteria)}.
+ */
+public interface SdkDiscoveryInteraction extends AutoCloseable
+{
+ /**
+ * Discovers assets matching the given criteria.
+ *
+ * @param criteria
+ * the criteria scoping this discovery request (path, datasource type, connection properties)
+ * @return a list of discovered assets; never null, may be empty
+ * @throws Exception
+ * if an error occurs during discovery
+ */
+ List discoverAssets(DiscoveryCriteria criteria) throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkSourceInteraction.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkSourceInteraction.java
new file mode 100644
index 00000000..29ebb826
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkSourceInteraction.java
@@ -0,0 +1,86 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import java.util.List;
+
+import org.apache.arrow.flight.Ticket;
+import org.apache.arrow.vector.types.pojo.Schema;
+
+/**
+ * Source interaction for row-based connectors (most connectors).
+ *
+ * Connector authors implement this interface to stream rows into the Flight layer.
+ * The implementation calls {@link RowWriter#startRow()}, {@link RowWriter#set(String, Object)},
+ * and {@link RowWriter#endRow()} for each row.
+ *
+ *
For columnar connectors (Parquet, columnar databases), implement
+ * {@link SdkColumnarSourceInteraction} instead — it extends this interface and provides a
+ * {@code stream(ColumnarWriter)} overload. The Flight layer detects the correct interface via
+ * {@code instanceof} and creates the appropriate writer.
+ *
+ *
Example:
+ *
+ * public class MySourceInteraction implements SdkSourceInteraction {
+ * {@literal @}Override
+ * public Schema getSchema() {
+ * return mySchema;
+ * }
+ * {@literal @}Override
+ * public List<Ticket> getTickets() {
+ * return Collections.singletonList(new Ticket(new byte[0]));
+ * }
+ * {@literal @}Override
+ * public void stream(RowWriter writer) throws Exception {
+ * for (MyRow row : source.rows()) {
+ * writer.startRow();
+ * writer.set("id", row.getId());
+ * writer.set("name", row.getName());
+ * writer.endRow();
+ * }
+ * }
+ * }
+ *
+ */
+public interface SdkSourceInteraction extends AutoCloseable
+{
+ /**
+ * Returns the Arrow schema describing the data this interaction will produce.
+ *
+ * @return the Arrow {@link Schema}
+ * @throws Exception
+ * if the schema cannot be determined
+ */
+ Schema getSchema() throws Exception;
+
+ /**
+ * Returns the list of Arrow Flight tickets representing the partitions this interaction covers.
+ *
+ * For single-partition connectors, return a list with a single ticket. The Flight layer
+ * will call {@link SdkConnector#getSourceInteraction(AssetDescriptor, Ticket)} once per ticket.
+ *
+ * @return a non-empty list of tickets
+ * @throws Exception
+ * if the ticket list cannot be determined
+ */
+ List getTickets() throws Exception;
+
+ /**
+ * Streams data rows into the provided {@link RowWriter}.
+ *
+ * The connector must call {@link RowWriter#startRow()}, {@link RowWriter#set(String, Object)},
+ * and {@link RowWriter#endRow()} for each row. The writer batches rows internally and flushes
+ * at the configured batch size.
+ *
+ * @param writer
+ * the row writer to receive row data
+ * @throws Exception
+ * if an error occurs during streaming
+ */
+ void stream(RowWriter writer) throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkTargetInteraction.java b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkTargetInteraction.java
new file mode 100644
index 00000000..da559aa5
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/main/java/com/ibm/wdp/connect/sdk/connector/SdkTargetInteraction.java
@@ -0,0 +1,70 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+/**
+ * Target interaction for row-based connectors.
+ *
+ *
Connector authors implement this interface to consume rows from the Flight layer.
+ * The implementation reads rows by calling {@link RowReader#nextRow()} and
+ * {@link RowReader#get(String)}.
+ *
+ *
For columnar connectors, implement {@link SdkColumnarTargetInteraction} instead — it extends
+ * this interface and provides a {@code consume(ColumnarReader)} overload.
+ *
+ *
Example:
+ *
+ * public class MyTargetInteraction implements SdkTargetInteraction {
+ * {@literal @}Override
+ * public void setup() throws Exception {
+ * target.beginTransaction();
+ * }
+ * {@literal @}Override
+ * public void consume(RowReader reader) throws Exception {
+ * while (reader.nextRow()) {
+ * target.insert((String) reader.get("id"), reader.get("value"));
+ * }
+ * }
+ * {@literal @}Override
+ * public void wrapup() throws Exception {
+ * target.commit();
+ * }
+ * }
+ *
+ */
+public interface SdkTargetInteraction extends AutoCloseable
+{
+ /**
+ * Called before any data arrives. Use to create tables, begin transactions, etc.
+ *
+ * @throws Exception
+ * if setup fails
+ */
+ void setup() throws Exception;
+
+ /**
+ * Consumes incoming data rows from the provided {@link RowReader}.
+ *
+ * The connector must call {@link RowReader#nextRow()} to advance through rows and
+ * {@link RowReader#get(String)} to retrieve field values.
+ *
+ * @param reader
+ * the row reader providing incoming data
+ * @throws Exception
+ * if an error occurs during consumption
+ */
+ void consume(RowReader reader) throws Exception;
+
+ /**
+ * Called after all data has been consumed. Use to commit transactions, finalize files, etc.
+ *
+ * @throws Exception
+ * if wrapup fails
+ */
+ void wrapup() throws Exception;
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestArrowBatchReader.java b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestArrowBatchReader.java
new file mode 100644
index 00000000..ba1d8e03
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestArrowBatchReader.java
@@ -0,0 +1,124 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.arrow.vector.util.Text;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ArrowBatchReader}.
+ */
+public class TestArrowBatchReader
+{
+ private RootAllocator allocator;
+ private Schema schema;
+
+ @Before
+ public void setUp()
+ {
+ allocator = new RootAllocator(Long.MAX_VALUE);
+ schema = new Schema(Arrays.asList(
+ new Field("id", new FieldType(false, new ArrowType.Int(32, true), null), null),
+ new Field("name", new FieldType(true, ArrowType.Utf8.INSTANCE, null), null)));
+ }
+
+ @After
+ public void tearDown()
+ {
+ allocator.close();
+ }
+
+ private List makeBatches(int... rowCounts)
+ {
+ final List batches = new ArrayList<>();
+ int idCounter = 1;
+ for (final int count : rowCounts) {
+ final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+ root.allocateNew();
+ final IntVector idVec = (IntVector) root.getVector("id");
+ final VarCharVector nameVec = (VarCharVector) root.getVector("name");
+ for (int i = 0; i < count; i++) {
+ idVec.setSafe(i, idCounter++);
+ nameVec.setSafe(i, new Text("name" + i));
+ }
+ root.setRowCount(count);
+ batches.add(root);
+ }
+ return batches;
+ }
+
+ @Test
+ public void testIterateRows() throws Exception
+ {
+ final List batches = makeBatches(2, 3);
+ try (ArrowBatchReader reader = new ArrowBatchReader(batches)) {
+ int count = 0;
+ while (reader.nextRow()) {
+ count++;
+ final Object id = reader.get("id");
+ final Object name = reader.get("name");
+ assertFalse(id == null);
+ assertFalse(name == null);
+ }
+ assertEquals(5, count);
+ }
+ batches.forEach(VectorSchemaRoot::close);
+ }
+
+ @Test
+ public void testEmptyBatchList() throws Exception
+ {
+ final List batches = new ArrayList<>();
+ try (ArrowBatchReader reader = new ArrowBatchReader(batches)) {
+ assertFalse(reader.nextRow());
+ }
+ }
+
+ @Test
+ public void testNullValue() throws Exception
+ {
+ final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+ root.allocateNew();
+ final IntVector idVec = (IntVector) root.getVector("id");
+ final VarCharVector nameVec = (VarCharVector) root.getVector("name");
+ idVec.setSafe(0, 99);
+ nameVec.setNull(0);
+ root.setRowCount(1);
+
+ final List batches = new ArrayList<>();
+ batches.add(root);
+
+ try (ArrowBatchReader reader = new ArrowBatchReader(batches)) {
+ assertTrue(reader.nextRow());
+ assertEquals(99, reader.get("id"));
+ assertNull(reader.get("name"));
+ assertFalse(reader.nextRow());
+ }
+ root.close();
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestArrowBatchWriter.java b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestArrowBatchWriter.java
new file mode 100644
index 00000000..28e877b4
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestArrowBatchWriter.java
@@ -0,0 +1,143 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ArrowBatchWriter}.
+ */
+public class TestArrowBatchWriter
+{
+ private RootAllocator allocator;
+ private Schema schema;
+
+ @Before
+ public void setUp()
+ {
+ allocator = new RootAllocator(Long.MAX_VALUE);
+ schema = new Schema(Arrays.asList(
+ new Field("id", new FieldType(false, new ArrowType.Int(32, true), null), null),
+ new Field("name", new FieldType(true, ArrowType.Utf8.INSTANCE, null), null),
+ new Field("score", new FieldType(true, new ArrowType.FloatingPoint(
+ org.apache.arrow.vector.types.FloatingPointPrecision.DOUBLE), null), null)));
+ }
+
+ @After
+ public void tearDown()
+ {
+ allocator.close();
+ }
+
+ @Test
+ public void testWriteAndReadSingleBatch() throws Exception
+ {
+ try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, allocator, 100)) {
+ writer.startRow();
+ writer.set("id", 1);
+ writer.set("name", "Alice");
+ writer.set("score", 9.5);
+ writer.endRow();
+
+ writer.startRow();
+ writer.set("id", 2);
+ writer.set("name", "Bob");
+ writer.set("score", 8.0);
+ writer.endRow();
+
+ final Iterator it = writer.batches();
+ assertTrue(it.hasNext());
+ final VectorSchemaRoot root = it.next();
+ assertEquals(2, root.getRowCount());
+ root.close();
+ assertFalse(it.hasNext());
+ }
+ }
+
+ @Test
+ public void testAutoFlushAtBatchSize() throws Exception
+ {
+ final int batchSize = 3;
+ try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, allocator, batchSize)) {
+ for (int i = 0; i < 7; i++) {
+ writer.startRow();
+ writer.set("id", i);
+ writer.set("name", "row" + i);
+ writer.set("score", (double) i);
+ writer.endRow();
+ }
+
+ int totalRows = 0;
+ int batchCount = 0;
+ final Iterator it = writer.batches();
+ while (it.hasNext()) {
+ final VectorSchemaRoot root = it.next();
+ totalRows += root.getRowCount();
+ batchCount++;
+ root.close();
+ }
+ assertEquals(7, totalRows);
+ // 7 rows with batchSize=3: two full batches flush at row 3 and 6, one partial at end
+ assertEquals(3, batchCount);
+ }
+ }
+
+ @Test
+ public void testNullValue() throws Exception
+ {
+ try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, allocator, 100)) {
+ writer.startRow();
+ writer.set("id", 42);
+ writer.set("name", null);
+ writer.set("score", null);
+ writer.endRow();
+
+ final Iterator it = writer.batches();
+ assertTrue(it.hasNext());
+ final VectorSchemaRoot root = it.next();
+ assertEquals(1, root.getRowCount());
+ assertTrue(root.getVector("name").isNull(0));
+ assertTrue(root.getVector("score").isNull(0));
+ root.close();
+ }
+ }
+
+ @Test
+ public void testEmptyWriter() throws Exception
+ {
+ try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, allocator, 100)) {
+ final Iterator it = writer.batches();
+ assertFalse(it.hasNext());
+ }
+ }
+
+ @Test
+ public void testGetSchema()
+ {
+ try (ArrowBatchWriter writer = new ArrowBatchWriter(schema, allocator, 100)) {
+ assertEquals(schema, writer.getSchema());
+ }
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestColumnarArrowBatchReader.java b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestColumnarArrowBatchReader.java
new file mode 100644
index 00000000..70c4ed5a
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestColumnarArrowBatchReader.java
@@ -0,0 +1,123 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.arrow.vector.util.Text;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ColumnarArrowBatchReader}.
+ */
+public class TestColumnarArrowBatchReader
+{
+ private RootAllocator allocator;
+ private Schema schema;
+
+ @Before
+ public void setUp()
+ {
+ allocator = new RootAllocator(Long.MAX_VALUE);
+ schema = new Schema(Arrays.asList(
+ new Field("id", new FieldType(false, new ArrowType.Int(32, true), null), null),
+ new Field("label", new FieldType(true, ArrowType.Utf8.INSTANCE, null), null)));
+ }
+
+ @After
+ public void tearDown()
+ {
+ allocator.close();
+ }
+
+ private List makeBatches(int rows1, int rows2)
+ {
+ final List batches = new ArrayList<>();
+ for (final int count : new int[]{ rows1, rows2 }) {
+ final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+ root.allocateNew();
+ final IntVector idVec = (IntVector) root.getVector("id");
+ final VarCharVector labelVec = (VarCharVector) root.getVector("label");
+ for (int i = 0; i < count; i++) {
+ idVec.setSafe(i, i + 1);
+ labelVec.setSafe(i, new Text("val" + i));
+ }
+ root.setRowCount(count);
+ batches.add(root);
+ }
+ return batches;
+ }
+
+ @Test
+ public void testIterateBatches() throws Exception
+ {
+ final List batches = makeBatches(3, 2);
+ try (ColumnarArrowBatchReader reader = new ColumnarArrowBatchReader(batches)) {
+ assertTrue(reader.nextBatch());
+ final Object[] ids = reader.getColumn("id");
+ assertEquals(3, ids.length);
+
+ assertTrue(reader.nextBatch());
+ final Object[] ids2 = reader.getColumn("id");
+ assertEquals(2, ids2.length);
+
+ assertFalse(reader.nextBatch());
+ }
+ batches.forEach(VectorSchemaRoot::close);
+ }
+
+ @Test
+ public void testNullInColumn() throws Exception
+ {
+ final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+ root.allocateNew();
+ final IntVector idVec = (IntVector) root.getVector("id");
+ final VarCharVector labelVec = (VarCharVector) root.getVector("label");
+ idVec.setSafe(0, 7);
+ labelVec.setNull(0);
+ root.setRowCount(1);
+
+ final List batches = new ArrayList<>();
+ batches.add(root);
+
+ try (ColumnarArrowBatchReader reader = new ColumnarArrowBatchReader(batches)) {
+ assertTrue(reader.nextBatch());
+ final Object[] labels = reader.getColumn("label");
+ assertEquals(1, labels.length);
+ assertNull(labels[0]);
+ assertFalse(reader.nextBatch());
+ }
+ root.close();
+ }
+
+ @Test
+ public void testEmptyBatchList() throws Exception
+ {
+ final List batches = new ArrayList<>();
+ try (ColumnarArrowBatchReader reader = new ColumnarArrowBatchReader(batches)) {
+ assertFalse(reader.nextBatch());
+ }
+ }
+}
+
+// Made with Bob
diff --git a/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestColumnarArrowBatchWriter.java b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestColumnarArrowBatchWriter.java
new file mode 100644
index 00000000..1e5dbe59
--- /dev/null
+++ b/sdk-gen/subprojects/java/sdk-connector-api/src/test/java/com/ibm/wdp/connect/sdk/connector/TestColumnarArrowBatchWriter.java
@@ -0,0 +1,117 @@
+/* *************************************************** */
+
+/* (C) Copyright IBM Corp. 2026 */
+
+/* *************************************************** */
+package com.ibm.wdp.connect.sdk.connector;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Iterator;
+
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ColumnarArrowBatchWriter}.
+ */
+public class TestColumnarArrowBatchWriter
+{
+ private RootAllocator allocator;
+ private Schema schema;
+
+ @Before
+ public void setUp()
+ {
+ allocator = new RootAllocator(Long.MAX_VALUE);
+ schema = new Schema(Arrays.asList(
+ new Field("id", new FieldType(false, new ArrowType.Int(32, true), null), null),
+ new Field("value", new FieldType(true, ArrowType.Utf8.INSTANCE, null), null)));
+ }
+
+ @After
+ public void tearDown()
+ {
+ allocator.close();
+ }
+
+ @Test
+ public void testWriteColumnarBatch() throws Exception
+ {
+ try (ColumnarArrowBatchWriter writer = new ColumnarArrowBatchWriter(schema, allocator, 100)) {
+ writer.writeColumn("id", new Object[]{ 1, 2, 3 });
+ writer.writeColumn("value", new Object[]{ "alpha", "beta", "gamma" });
+ writer.flushBatch();
+
+ final Iterator it = writer.batches();
+ assertTrue(it.hasNext());
+ final VectorSchemaRoot root = it.next();
+ assertEquals(3, root.getRowCount());
+ root.close();
+ assertFalse(it.hasNext());
+ }
+ }
+
+ @Test
+ public void testMultipleBatches() throws Exception
+ {
+ try (ColumnarArrowBatchWriter writer = new ColumnarArrowBatchWriter(schema, allocator, 100)) {
+ // Batch 1
+ writer.writeColumn("id", new Object[]{ 1, 2 });
+ writer.writeColumn("value", new Object[]{ "a", "b" });
+ writer.flushBatch();
+ // Batch 2
+ writer.writeColumn("id", new Object[]{ 3 });
+ writer.writeColumn("value", new Object[]{ "c" });
+ writer.flushBatch();
+
+ int totalRows = 0;
+ final Iterator it = writer.batches();
+ while (it.hasNext()) {
+ final VectorSchemaRoot root = it.next();
+ totalRows += root.getRowCount();
+ root.close();
+ }
+ assertEquals(3, totalRows);
+ }
+ }
+
+ @Test
+ public void testNullColumn() throws Exception
+ {
+ try (ColumnarArrowBatchWriter writer = new ColumnarArrowBatchWriter(schema, allocator, 100)) {
+ writer.writeColumn("id", new Object[]{ 10 });
+ writer.writeColumn("value", new Object[]{ null });
+ writer.flushBatch();
+
+ final Iterator it = writer.batches();
+ assertTrue(it.hasNext());
+ final VectorSchemaRoot root = it.next();
+ assertEquals(1, root.getRowCount());
+ assertTrue(root.getVector("value").isNull(0));
+ root.close();
+ }
+ }
+
+ @Test
+ public void testEmptyWriter() throws Exception
+ {
+ try (ColumnarArrowBatchWriter writer = new ColumnarArrowBatchWriter(schema, allocator, 100)) {
+ final Iterator it = writer.batches();
+ assertFalse(it.hasNext());
+ }
+ }
+}
+
+// Made with Bob