Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/main/java/io/github/mapepire_ibmi/SqlJob.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.mapepire_ibmi;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URI;
import java.nio.ByteBuffer;
Expand All @@ -20,6 +21,10 @@
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;

import javax.net.ssl.HttpsURLConnection;

import java.net.URL;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
Expand All @@ -34,6 +39,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

import io.github.mapepire_ibmi.types.BlobRef;
import io.github.mapepire_ibmi.types.ConnectionResult;
import io.github.mapepire_ibmi.types.DaemonServer;
import io.github.mapepire_ibmi.types.ExplainResults;
Expand Down Expand Up @@ -67,6 +73,13 @@ public class SqlJob {
*/
private WebSocketClient socket;

/**
* The server details used for the current connection.
* Retained so that {@link #fetchBlob(BlobRef)} can re-use the credentials
* and TLS configuration when issuing the HTTPS GET for a blob token.
*/
private DaemonServer db2Server;

/**
* The job status.
*/
Expand Down Expand Up @@ -360,6 +373,7 @@ public int getRunningCount() {
*/
public CompletableFuture<ConnectionResult> connect(DaemonServer db2Server) throws Exception {
this.status = JobStatus.Connecting;
this.db2Server = db2Server;
ObjectMapper objectMapper = SingletonObjectMapper.getInstance();

return this.getChannel(db2Server)
Expand Down Expand Up @@ -429,6 +443,103 @@ public CompletableFuture<ConnectionResult> connect(DaemonServer db2Server) throw
});
}

/**
* Fetches the binary content of a BLOB from the server.
*
* <p>When a query returns a BLOB or binary column in daemon mode, each cell
* value is a {@link BlobRef} containing a {@code blob_url} and {@code size}.
* Pass that object here to retrieve the raw bytes as a {@code byte[]}.
*
* <p>The token embedded in {@code blobRef.getBlobUrl()} is
* <b>single-use</b> — calling this method consumes it. Subsequent calls
* with the same {@code BlobRef} will throw a {@code RuntimeException}
* wrapping an HTTP 404 error.
*
* @param blobRef The {@link BlobRef} object returned in query result data.
* @return A CompletableFuture that resolves to the raw blob bytes.
* @throws IllegalStateException If the job is not connected.
* @throws RuntimeException If the token has expired or already been
* consumed (HTTP 404), credentials are invalid
* (HTTP 401), or any other HTTP/IO error.
*/
public CompletableFuture<byte[]> fetchBlob(BlobRef blobRef) {
if (this.db2Server == null) {
CompletableFuture<byte[]> failed = new CompletableFuture<>();
failed.completeExceptionally(new IllegalStateException("SqlJob is not connected"));
return failed;
}

final DaemonServer server = this.db2Server;

return CompletableFuture.supplyAsync(() -> {
try {
String auth = server.getUser() + ":" + server.getPassword();
String encodedAuth = Base64.getEncoder()
.encodeToString(auth.getBytes(StandardCharsets.UTF_8));

URL url = new URL("https://" + server.getHost() + ":" + server.getPort()
+ blobRef.getBlobUrl());
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();

// Apply TLS trust settings for HTTPS blob fetches.
// Never disable certificate/hostname validation.
if (server.getCa() != null) {
// Use the same custom CA certificate as the WebSocket channel
InputStream caStream = new ByteArrayInputStream(
server.getCa().getBytes(StandardCharsets.UTF_8));
X509Certificate caCert = (X509Certificate) CertificateFactory
.getInstance("X509").generateCertificate(caStream);

KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setCertificateEntry("mapepire-ca", caCert);

TrustManagerFactory tmf = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ks);

javax.net.ssl.SSLContext sslCtx = javax.net.ssl.SSLContext.getInstance("TLS");
sslCtx.init(null, tmf.getTrustManagers(), new SecureRandom());
conn.setSSLSocketFactory(sslCtx.getSocketFactory());
}

conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Basic " + encodedAuth);
conn.setConnectTimeout(10_000);
conn.setReadTimeout(120_000);
conn.connect();

int status = conn.getResponseCode();
if (status == 200) {
try (InputStream in = conn.getInputStream()) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
} else if (status == 404) {
throw new RuntimeException(
"Blob token not found or expired (404): " + blobRef.getBlobUrl());
} else if (status == 401) {
throw new RuntimeException(
"Unauthorized fetching blob — credentials mismatch (401): "
+ blobRef.getBlobUrl());
} else {
throw new RuntimeException(
"Unexpected response fetching blob: HTTP " + status
+ " for " + blobRef.getBlobUrl());
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Failed to fetch blob: " + e.getMessage(), e);
}
});
}

/**
* Create a Query object for the specified SQL statement.
*
Expand Down
86 changes: 86 additions & 0 deletions src/main/java/io/github/mapepire_ibmi/types/BlobRef.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.github.mapepire_ibmi.types;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
* Reference to a BLOB value stored on the mapepire server, returned in query
* result data when a BLOB or binary column is selected in daemon mode.
*
* <p>Retrieve the raw bytes by calling {@link io.github.mapepire_ibmi.SqlJob#fetchBlob(BlobRef)}
* or by issuing an authenticated HTTP GET to
* {@code https://<host>:<port><blob_url>}.
*
* <p>The token is <b>single-use</b> and expires after the server-configured TTL
* (default 60 s, overridable via the {@code BLOB_TOKEN_TTL} environment
* variable on the server).
*
* <p>In single mode (no HTTP server) BLOB columns are returned as inline
* Base64 strings instead of a {@code BlobRef}.
*/
public class BlobRef {

/**
* Relative URL path for the blob, e.g. {@code "/blob/<token>"}.
*/
@JsonProperty("blob_url")
private String blobUrl;

/**
* Size of the blob in bytes.
*/
@JsonProperty("size")
private long size;

/**
* Construct a new BlobRef instance.
*/
public BlobRef() {
}

/**
* Construct a new BlobRef instance.
*
* @param blobUrl The relative URL path for the blob.
* @param size The size of the blob in bytes.
*/
public BlobRef(String blobUrl, long size) {
this.blobUrl = blobUrl;
this.size = size;
}

/**
* Get the relative URL path for the blob.
*
* @return The relative URL path for the blob.
*/
public String getBlobUrl() {
return blobUrl;
}

/**
* Set the relative URL path for the blob.
*
* @param blobUrl The relative URL path for the blob.
*/
public void setBlobUrl(String blobUrl) {
this.blobUrl = blobUrl;
}

/**
* Get the size of the blob in bytes.
*
* @return The size of the blob in bytes.
*/
public long getSize() {
return size;
}

/**
* Set the size of the blob in bytes.
*
* @param size The size of the blob in bytes.
*/
public void setSize(long size) {
this.size = size;
}
}
6 changes: 6 additions & 0 deletions src/main/java/io/github/mapepire_ibmi/types/QueryResult.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;
// BlobRef is in the same package; no explicit import needed but referenced in Javadoc.

/**
* Represents a standard query result.
Expand Down Expand Up @@ -34,6 +35,11 @@ public class QueryResult<T> extends ServerResponse {

/**
* The data returned from the query.
*
* <p>In daemon mode, BLOB/binary columns are represented as
* {@link BlobRef} objects rather than raw bytes. Call
* {@link io.github.mapepire_ibmi.SqlJob#fetchBlob(BlobRef)} to retrieve
* the actual binary content.
*/
@JsonProperty("data")
private List<T> data;
Expand Down
Loading
Loading