diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClient.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClient.java new file mode 100644 index 0000000..cf1ecfb --- /dev/null +++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClient.java @@ -0,0 +1,37 @@ +package com.github.magnusp.libsql.client; + +import com.github.magnusp.libsql.hrana.codec.Batch; +import com.github.magnusp.libsql.hrana.codec.BatchResult; +import com.github.magnusp.libsql.hrana.codec.Stmt; +import com.github.magnusp.libsql.hrana.codec.StmtResult; + +/** + * The transport-independent client seam used by the JDBC driver. + * + *
Both transports expose the same two execution styles. A one-shot call opens a stream, + * runs the work and closes the stream in as few round trips as possible, so it leaves no server-side + * state behind and needs no explicit cleanup. An on-stream call reuses a lazily opened + * interactive stream, which is what a transaction needs: the server keeps the transaction open + * between calls. + * + *
Implementations track their own stream state. {@link #hasOpenStream()} is the single source of
+ * truth for whether a transaction begin is still pending.
+ */
+public interface LibsqlClient extends AutoCloseable {
+
+ StmtResult executeOneShot(Stmt stmt) throws LibsqlException;
+
+ StmtResult executeOnStream(Stmt stmt) throws LibsqlException;
+
+ BatchResult executeBatchOneShot(Batch batch) throws LibsqlException;
+
+ BatchResult executeBatchOnStream(Batch batch) throws LibsqlException;
+
+ boolean hasOpenStream();
+
+ /** Closes the interactive stream, if one is open, and releases its server-side state. */
+ void closeStream() throws LibsqlException;
+
+ @Override
+ void close();
+}
diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlHttpClient.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlHttpClient.java
index d432fcc..3316814 100644
--- a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlHttpClient.java
+++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlHttpClient.java
@@ -1,5 +1,7 @@
package com.github.magnusp.libsql.client;
+import com.github.magnusp.libsql.hrana.codec.Batch;
+import com.github.magnusp.libsql.hrana.codec.BatchResult;
import com.github.magnusp.libsql.hrana.codec.DirectHranaJsonCodec;
import com.github.magnusp.libsql.hrana.codec.HranaError;
import com.github.magnusp.libsql.hrana.codec.HranaJsonCodec;
@@ -24,7 +26,7 @@
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
-public class LibsqlHttpClient {
+public class LibsqlHttpClient implements LibsqlClient {
private static final ConcurrentHashMap One WebSocket connection multiplexes any number of streams. Requests are matched to responses
+ * by {@code request_id}, so the client may send a whole one-shot sequence (open, execute, close)
+ * without waiting in between and still receive the responses in order.
+ *
+ * All request methods block the calling thread until the matching response arrives. The caller
+ * (the JDBC connection) serializes calls, so a single interactive stream id is enough.
+ *
+ * Transport note. The WebSocket is spoken directly over a socket rather than
+ * through {@link java.net.http.WebSocket}. sqld answers the upgrade with a {@code 101} that carries
+ * {@code content-length: 31} and no body, and the JDK client completes its opening handshake only
+ * after reading a full response body ({@code OpeningHandshake.send()} uses {@code
+ * BodyHandlers.ofString()}), so it hangs against every sqld version. The framing below is the RFC
+ * 6455 client subset Hrana needs: text/binary frames, fragmentation, ping/pong and close.
+ */
+public class LibsqlWebSocketClient implements LibsqlClient {
+
+ private static final String SUBPROTOCOL = "hrana3";
+ private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+ private static final int OP_CONTINUATION = 0x0;
+ private static final int OP_TEXT = 0x1;
+ private static final int OP_BINARY = 0x2;
+ private static final int OP_CLOSE = 0x8;
+ private static final int OP_PING = 0x9;
+ private static final int OP_PONG = 0xA;
+ private static final int CLOSE_NORMAL = 1000;
+
+ private static final int MAX_FRAME_BYTES = 1024 * 1024 * 1024;
+
+ private final LibsqlClientConfig config;
+ private final HranaWsCodec codec;
+ private final Duration timeout;
+ private final Transport transport;
+
+ private final ConcurrentHashMap A reader thread routes complete text/binary messages to {@link #dispatch(String)} and
+ * fails the pending futures when the connection ends. It is a virtual thread, so an idle
+ * connection never keeps the JVM alive.
+ */
+ private final class Transport {
+
+ private Socket socket;
+ private InputStream in;
+ private OutputStream out;
+ private final Object writeLock = new Object();
+
+ private volatile boolean open = true;
+
+ private Transport(URI uri, Duration timeout) {
+ Socket connected = null;
+ try {
+ connected = connect(uri, timeout);
+ this.socket = connected;
+ this.in = connected.getInputStream();
+ this.out = connected.getOutputStream();
+ handshake(uri, in, out, timeout);
+ } catch (IOException e) {
+ closeQuietly(connected);
+ throw new LibsqlException(
+ "Failed to open Hrana WebSocket to " + uri + ": " + e.getMessage(), e);
+ }
+ Thread.ofVirtual().name("hrana-ws-reader").start(this::readLoop);
+ }
+
+ private void send(byte[] payload, boolean binary) {
+ sendFrame(binary ? OP_BINARY : OP_TEXT, payload);
+ }
+
+ private void close(int statusCode, String reason) {
+ if (open) {
+ try {
+ sendFrame(OP_CLOSE, closePayload(statusCode, reason));
+ } catch (LibsqlException e) {
+ // The peer is already gone; closing the socket below is enough.
+ }
+ }
+ open = false;
+ failAll(new LibsqlException(
+ "Hrana WebSocket closed with code " + statusCode, "WS_CLOSED", 0));
+ closeQuietly(socket);
+ }
+
+ private void abort() {
+ open = false;
+ closeQuietly(socket);
+ }
+
+ private void sendFrame(int opcode, byte[] payload) {
+ synchronized (writeLock) {
+ if (!open) {
+ throw new LibsqlException("Hrana WebSocket is closed", "WS_CLOSED", 0);
+ }
+ try {
+ out.write(0x80 | opcode);
+ int length = payload.length;
+ if (length <= 125) {
+ out.write(0x80 | length);
+ } else if (length <= 0xFFFF) {
+ out.write(0x80 | 126);
+ out.write(length >>> 8);
+ out.write(length);
+ } else {
+ out.write(0x80 | 127);
+ for (int shift = 56; shift >= 0; shift -= 8) {
+ out.write((int) ((long) length >>> shift) & 0xFF);
+ }
+ }
+ byte[] mask = new byte[4];
+ ThreadLocalRandom.current().nextBytes(mask);
+ out.write(mask);
+ for (int i = 0; i < length; i++) {
+ out.write(payload[i] ^ mask[i & 3]);
+ }
+ out.flush();
+ } catch (IOException e) {
+ open = false;
+ throw new LibsqlException(
+ "Failed to send Hrana message: " + e.getMessage(), e);
+ }
+ }
+ }
+
+ private void readLoop() {
+ ByteArrayOutputStream fragments = new ByteArrayOutputStream();
+ int fragmentedOpcode = -1;
+ try {
+ while (open) {
+ int first = in.read();
+ if (first < 0) {
+ break;
+ }
+ int second = readByte(in);
+ boolean fin = (first & 0x80) != 0;
+ int opcode = first & 0x0F;
+ if ((second & 0x80) != 0) {
+ protocolViolation("server sent a masked frame");
+ return;
+ }
+ long length = second & 0x7F;
+ if (length == 126) {
+ length = (readByte(in) << 8) | readByte(in);
+ } else if (length == 127) {
+ length = 0;
+ for (int i = 0; i < 8; i++) {
+ length = (length << 8) | readByte(in);
+ }
+ }
+ if (length < 0 || length > MAX_FRAME_BYTES) {
+ protocolViolation("frame is too large: " + length + " bytes");
+ return;
+ }
+ byte[] payload = in.readNBytes((int) length);
+ if (payload.length != length) {
+ break;
+ }
+
+ switch (opcode) {
+ case OP_TEXT, OP_BINARY -> {
+ if (fragmentedOpcode != -1) {
+ protocolViolation("new message before the fragmented one finished");
+ return;
+ }
+ if (fin) {
+ deliver(opcode, payload);
+ } else {
+ fragmentedOpcode = opcode;
+ fragments.write(payload);
+ }
+ }
+ case OP_CONTINUATION -> {
+ if (fragmentedOpcode == -1) {
+ protocolViolation("continuation without a started message");
+ return;
+ }
+ fragments.write(payload);
+ if (fin) {
+ byte[] complete = fragments.toByteArray();
+ int messageOpcode = fragmentedOpcode;
+ fragments.reset();
+ fragmentedOpcode = -1;
+ deliver(messageOpcode, complete);
+ }
+ }
+ case OP_PING -> sendFrame(OP_PONG, payload);
+ case OP_PONG -> {
+ // Unsolicited pongs are allowed and need no answer.
+ }
+ case OP_CLOSE -> {
+ handlePeerClose(payload);
+ return;
+ }
+ default -> {
+ protocolViolation("unknown frame opcode 0x" + Integer.toHexString(opcode));
+ return;
+ }
+ }
+ }
+ if (open) {
+ connectionLost("connection closed by the server without a close frame");
+ }
+ } catch (IOException e) {
+ if (open) {
+ connectionLost("Hrana WebSocket connection failed: " + e.getMessage());
+ }
+ } finally {
+ open = false;
+ closeQuietly(socket);
+ }
+ }
+
+ private void deliver(int opcode, byte[] payload) {
+ boolean expectsBinary = codec.isBinary();
+ if ((opcode == OP_BINARY) != expectsBinary) {
+ protocolViolation("frame type does not match the negotiated encoding");
+ return;
+ }
+ dispatch(payload);
+ }
+
+ private void handlePeerClose(byte[] payload) {
+ int statusCode = payload.length >= 2
+ ? ((payload[0] & 0xFF) << 8) | (payload[1] & 0xFF)
+ : CLOSE_NORMAL;
+ String reason = payload.length > 2
+ ? new String(payload, 2, payload.length - 2, StandardCharsets.UTF_8)
+ : "";
+ try {
+ // Echo the close frame while the connection is still writable.
+ sendFrame(OP_CLOSE, closePayload(statusCode, ""));
+ } catch (LibsqlException e) {
+ // The peer closed; the socket is closed by the reader's finally block.
+ }
+ open = false;
+ connectionLost(reason.isBlank()
+ ? "Hrana WebSocket closed with code " + statusCode
+ : "Hrana WebSocket closed with code " + statusCode + ": " + reason);
+ }
+
+ private void connectionLost(String detail) {
+ failAll(new LibsqlException(detail, "WS_CLOSED", 0));
+ }
+ }
+
+ private static Socket connect(URI uri, Duration timeout) throws IOException {
+ boolean tls = "wss".equalsIgnoreCase(uri.getScheme());
+ String host = uri.getHost();
+ if (host == null) {
+ throw new IOException("WebSocket URL has no host: " + uri);
+ }
+ int port = uri.getPort() > 0 ? uri.getPort() : (tls ? 443 : 80);
+
+ Socket socket = new Socket();
+ try {
+ int connectTimeout = (int) Math.min(Integer.MAX_VALUE, Math.max(0, timeout.toMillis()));
+ socket.connect(new InetSocketAddress(host, port), connectTimeout);
+ socket.setTcpNoDelay(true);
+ if (!tls) {
+ return socket;
+ }
+ SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
+ SSLSocket ssl = (SSLSocket) factory.createSocket(socket, host, port, true);
+ SSLParameters parameters = ssl.getSSLParameters();
+ parameters.setEndpointIdentificationAlgorithm("HTTPS");
+ ssl.setSSLParameters(parameters);
+ ssl.startHandshake();
+ return ssl;
+ } catch (IOException e) {
+ closeQuietly(socket);
+ throw e;
+ }
+ }
+
+ private static void handshake(URI uri, InputStream in, OutputStream out, Duration timeout)
+ throws IOException {
+ String key = createKey();
+ String expectedAccept = acceptKey(key);
+ String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath();
+ if (uri.getRawQuery() != null && !uri.getRawQuery().isEmpty()) {
+ path = path + "?" + uri.getRawQuery();
+ }
+ String host = uri.getHost();
+ int port = uri.getPort();
+ String hostHeader = port > 0 ? host + ":" + port : host;
+
+ StringBuilder request = new StringBuilder(256);
+ request.append("GET ").append(path).append(" HTTP/1.1\r\n")
+ .append("Host: ").append(hostHeader).append("\r\n")
+ .append("Upgrade: websocket\r\n")
+ .append("Connection: Upgrade\r\n")
+ .append("Sec-WebSocket-Key: ").append(key).append("\r\n")
+ .append("Sec-WebSocket-Version: 13\r\n")
+ .append("Sec-WebSocket-Protocol: ").append(SUBPROTOCOL).append("\r\n")
+ .append("\r\n");
+ out.write(request.toString().getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+
+ String statusLine = readLine(in);
+ if (statusLine == null || !statusLine.contains(" 101 ")) {
+ throw new IOException("WebSocket upgrade rejected: " + statusLine);
+ }
+ Map One instance handles one frame at a time. With the JSON encoding every message is a complete
+ * UTF-8 JSON text frame; with the Protobuf encoding it is a binary frame.
+ */
+public interface HranaWsCodec {
+
+ /** Whether messages must be sent as binary frames ({@code true}) or text frames. */
+ boolean isBinary();
+
+ byte[] serialize(WsClientMsg msg) throws IOException;
+
+ WsServerMsg deserialize(byte[] payload) throws IOException;
+}
diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsClientMsg.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsClientMsg.java
new file mode 100644
index 0000000..ec3b442
--- /dev/null
+++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsClientMsg.java
@@ -0,0 +1,17 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+/**
+ * A message sent by the client over a Hrana 3 WebSocket connection.
+ *
+ * The {@code hello} message authenticates the connection; every other message is a numbered
+ * request whose response carries the same {@code request_id}. See the Hrana 3 specification,
+ * "Hrana over WebSocket".
+ */
+public sealed interface WsClientMsg {
+
+ /** Authenticates (or re-authenticates) the connection. A null JWT means no authentication. */
+ record Hello(String jwt) implements WsClientMsg {}
+
+ /** Carries a numbered request; the server answers with a matching {@code response_ok/error}. */
+ record Request(int requestId, WsRequest request) implements WsClientMsg {}
+}
diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsRequest.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsRequest.java
new file mode 100644
index 0000000..c64becc
--- /dev/null
+++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsRequest.java
@@ -0,0 +1,20 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+/**
+ * The payload of a Hrana 3 WebSocket {@code request} message.
+ *
+ * Unlike the HTTP variant, a stream is opened once per WebSocket connection and then reused for
+ * an arbitrary number of execute and batch requests.
+ */
+public sealed interface WsRequest {
+
+ record OpenStream(int streamId) implements WsRequest {}
+
+ record CloseStream(int streamId) implements WsRequest {}
+
+ record Execute(int streamId, Stmt stmt) implements WsRequest {}
+
+ // The component type is qualified because this nested record is itself named Batch and would
+ // otherwise shadow the top-level Batch type.
+ record Batch(int streamId, com.github.magnusp.libsql.hrana.codec.Batch batch) implements WsRequest {}
+}
diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsResponse.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsResponse.java
new file mode 100644
index 0000000..f36aad3
--- /dev/null
+++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsResponse.java
@@ -0,0 +1,17 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+/** The payload of a successful Hrana 3 WebSocket response. Its type matches the request type. */
+public sealed interface WsResponse {
+
+ record OpenStream() implements WsResponse {
+ public static final OpenStream INSTANCE = new OpenStream();
+ }
+
+ record CloseStream() implements WsResponse {
+ public static final CloseStream INSTANCE = new CloseStream();
+ }
+
+ record Execute(StmtResult result) implements WsResponse {}
+
+ record Batch(BatchResult result) implements WsResponse {}
+}
diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsServerMsg.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsServerMsg.java
new file mode 100644
index 0000000..3c2fb78
--- /dev/null
+++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/WsServerMsg.java
@@ -0,0 +1,15 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+/** A message sent by the server over a Hrana 3 WebSocket connection. */
+public sealed interface WsServerMsg {
+
+ record HelloOk() implements WsServerMsg {
+ public static final HelloOk INSTANCE = new HelloOk();
+ }
+
+ record HelloError(HranaError error) implements WsServerMsg {}
+
+ record ResponseOk(int requestId, WsResponse response) implements WsServerMsg {}
+
+ record ResponseError(int requestId, HranaError error) implements WsServerMsg {}
+}
diff --git a/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaWsJsonCodecTest.java b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaWsJsonCodecTest.java
new file mode 100644
index 0000000..08b44a9
--- /dev/null
+++ b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaWsJsonCodecTest.java
@@ -0,0 +1,200 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class DirectHranaWsJsonCodecTest {
+
+ private final DirectHranaJsonCodec codec = new DirectHranaJsonCodec();
+
+ private static String text(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+
+ private static byte[] utf8(String json) {
+ return json.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void codecUsesTextFrames() {
+ assertThat(codec).isInstanceOf(HranaWsCodec.class);
+ assertThat(codec.isBinary()).isFalse();
+ }
+
+ @Test
+ void serializesHello() throws IOException {
+ assertThat(text(codec.serialize(new WsClientMsg.Hello("jwt-token"))))
+ .isEqualTo("{\"type\":\"hello\",\"jwt\":\"jwt-token\"}");
+ assertThat(text(codec.serialize(new WsClientMsg.Hello(null))))
+ .isEqualTo("{\"type\":\"hello\",\"jwt\":null}");
+ }
+
+ @Test
+ void serializesOpenStreamRequest() throws IOException {
+ assertThat(text(codec.serialize(new WsClientMsg.Request(7, new WsRequest.OpenStream(3)))))
+ .isEqualTo("{\"type\":\"request\",\"request_id\":7,"
+ + "\"request\":{\"type\":\"open_stream\",\"stream_id\":3}}");
+ }
+
+ @Test
+ void serializesCloseStreamRequest() throws IOException {
+ assertThat(text(codec.serialize(new WsClientMsg.Request(8, new WsRequest.CloseStream(3)))))
+ .isEqualTo("{\"type\":\"request\",\"request_id\":8,"
+ + "\"request\":{\"type\":\"close_stream\",\"stream_id\":3}}");
+ }
+
+ @Test
+ void serializesExecuteWithPositionalAndNamedArgs() throws IOException {
+ Stmt stmt = new Stmt(
+ "SELECT :a, :b",
+ null,
+ List.of(Value.of(42L), Value.of("text \"quoted\"")),
+ List.of(new NamedArg(":a", Value.of(1.5)), new NamedArg(":b", new Value.Blob(new byte[] {1, 2, 3}))),
+ true);
+
+ String json = text(codec.serialize(new WsClientMsg.Request(9, new WsRequest.Execute(4, stmt))));
+
+ assertThat(json).isEqualTo("{\"type\":\"request\",\"request_id\":9,"
+ + "\"request\":{\"type\":\"execute\",\"stream_id\":4,\"stmt\":{"
+ + "\"sql\":\"SELECT :a, :b\","
+ + "\"args\":["
+ + "{\"type\":\"integer\",\"value\":\"42\"},"
+ + "{\"type\":\"text\",\"value\":\"text \\\"quoted\\\"\"}],"
+ + "\"named_args\":["
+ + "{\"name\":\":a\",\"value\":{\"type\":\"float\",\"value\":1.5}},"
+ + "{\"name\":\":b\",\"value\":{\"type\":\"blob\",\"base64\":\"AQID\"}}],"
+ + "\"want_rows\":true}}}");
+ }
+
+ @Test
+ void serializesBatchRequest() throws IOException {
+ Batch batch = new Batch(List.of(
+ new BatchStep(new Stmt("BEGIN")),
+ new BatchStep(new BatchCond.StepOk(0), new Stmt("INSERT INTO t VALUES (?)", List.of(Value.of(1L)))),
+ new BatchStep(new BatchCond.Not(BatchCond.IsAutocommit.INSTANCE), new Stmt("COMMIT"))));
+
+ String json = text(codec.serialize(new WsClientMsg.Request(10, new WsRequest.Batch(5, batch))));
+
+ assertThat(json).isEqualTo("{\"type\":\"request\",\"request_id\":10,"
+ + "\"request\":{\"type\":\"batch\",\"stream_id\":5,\"batch\":{\"steps\":["
+ + "{\"stmt\":{\"sql\":\"BEGIN\",\"want_rows\":true}},"
+ + "{\"condition\":{\"type\":\"ok\",\"step\":0},"
+ + "\"stmt\":{\"sql\":\"INSERT INTO t VALUES (?)\","
+ + "\"args\":[{\"type\":\"integer\",\"value\":\"1\"}],\"want_rows\":true}},"
+ + "{\"condition\":{\"type\":\"not\",\"cond\":{\"type\":\"is_autocommit\"}},"
+ + "\"stmt\":{\"sql\":\"COMMIT\",\"want_rows\":true}}]}}}");
+ }
+
+ @Test
+ void deserializesHelloOk() throws IOException {
+ assertThat(codec.deserialize(utf8("{\"type\":\"hello_ok\"}")))
+ .isEqualTo(WsServerMsg.HelloOk.INSTANCE);
+ }
+
+ @Test
+ void deserializesHelloError() throws IOException {
+ assertThat(codec.deserialize(utf8(
+ "{\"type\":\"hello_error\",\"error\":{\"message\":\"bad jwt\",\"code\":\"AUTH\"}}")))
+ .isEqualTo(new WsServerMsg.HelloError(new HranaError("bad jwt", "AUTH")));
+ }
+
+ @Test
+ void deserializesOpenAndCloseStreamResponses() throws IOException {
+ assertThat(codec.deserialize(utf8(
+ "{\"type\":\"response_ok\",\"request_id\":1,"
+ + "\"response\":{\"type\":\"open_stream\"}}")))
+ .isEqualTo(new WsServerMsg.ResponseOk(1, WsResponse.OpenStream.INSTANCE));
+
+ assertThat(codec.deserialize(utf8(
+ "{\"type\":\"response_ok\",\"request_id\":2,"
+ + "\"response\":{\"type\":\"close_stream\"}}")))
+ .isEqualTo(new WsServerMsg.ResponseOk(2, WsResponse.CloseStream.INSTANCE));
+ }
+
+ @Test
+ void deserializesExecuteResponse() throws IOException {
+ String json = """
+ {"type":"response_ok","request_id":11,"response":{"type":"execute","result":{
+ "cols":[{"name":"id","decltype":"INTEGER"},{"name":"name","decltype":"TEXT"}],
+ "rows":[[{"type":"integer","value":"1"},{"type":"text","value":"Alice"}],
+ [{"type":"null"},{"type":"blob","base64":"AQID"}]],
+ "affected_row_count":2,
+ "last_insert_rowid":"1",
+ "rows_read":5,
+ "rows_written":2,
+ "query_duration_ms":0.5
+ }}}""";
+
+ StmtResult expected = new StmtResult(
+ List.of(new Col("id", "INTEGER"), new Col("name", "TEXT")),
+ List.of(
+ new Row(List.of(new Value.Integer(1), new Value.Text("Alice"))),
+ new Row(List.of(Value.ofNull(), new Value.Blob(new byte[] {1, 2, 3})))),
+ 2,
+ 1L);
+
+ assertThat(codec.deserialize(utf8(json)))
+ .isEqualTo(new WsServerMsg.ResponseOk(11, new WsResponse.Execute(expected)));
+ }
+
+ @Test
+ void deserializesBatchResponse() throws IOException {
+ String json = """
+ {"type":"response_ok","request_id":12,"response":{"type":"batch","result":{
+ "step_results":[{"cols":[],"rows":[],"affected_row_count":1,"last_insert_rowid":null},null],
+ "step_errors":[null,{"message":"UNIQUE constraint failed: t.id","code":"SQLITE_CONSTRAINT"}]
+ }}}""";
+
+ BatchResult expected = new BatchResult(
+ Map.of(0, new StmtResult(List.of(), List.of(), 1, null)),
+ Map.of(1, new HranaError("UNIQUE constraint failed: t.id", "SQLITE_CONSTRAINT")));
+
+ assertThat(codec.deserialize(utf8(json)))
+ .isEqualTo(new WsServerMsg.ResponseOk(12, new WsResponse.Batch(expected)));
+ }
+
+ @Test
+ void deserializesExecuteResponseRegardlessOfFieldOrder() throws IOException {
+ String json = """
+ {"response":{"result":{"cols":[],"rows":[],"affected_row_count":0},
+ "type":"execute"},"request_id":14,"type":"response_ok"}""";
+
+ StmtResult expected = new StmtResult(List.of(), List.of(), 0, null);
+
+ assertThat(codec.deserialize(utf8(json)))
+ .isEqualTo(new WsServerMsg.ResponseOk(14, new WsResponse.Execute(expected)));
+ }
+
+ @Test
+ void deserializesResponseError() throws IOException {
+ assertThat(codec.deserialize(utf8(
+ "{\"type\":\"response_error\",\"request_id\":13,"
+ + "\"error\":{\"message\":\"no such table: t\",\"code\":\"SQLITE_ERROR\"}}")))
+ .isEqualTo(new WsServerMsg.ResponseError(
+ 13, new HranaError("no such table: t", "SQLITE_ERROR")));
+ }
+
+ @Test
+ void ignoresUnknownFields() throws IOException {
+ assertThat(codec.deserialize(utf8(
+ "{\"type\":\"hello_ok\",\"future_field\":{\"nested\":[1,2,3]}}")))
+ .isEqualTo(WsServerMsg.HelloOk.INSTANCE);
+ }
+
+ @Test
+ void rejectsUnknownMessageTypes() {
+ assertThatThrownBy(() -> codec.deserialize(utf8("{\"type\":\"mystery\"}")))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("mystery");
+ assertThatThrownBy(() -> codec.deserialize(utf8("{}")))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("type");
+ }
+}
diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java
index 1294a73..6025e8e 100644
--- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java
+++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/SqldContainerBase.java
@@ -45,4 +45,8 @@ public static void startContainer() {
public static String getHttpUrl() {
return "http://" + LIBSQL_CONTAINER.getHost() + ":" + LIBSQL_CONTAINER.getMappedPort(PORT);
}
+
+ public static String getWebSocketUrl() {
+ return "ws://" + LIBSQL_CONTAINER.getHost() + ":" + LIBSQL_CONTAINER.getMappedPort(PORT);
+ }
}
diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java
new file mode 100644
index 0000000..bd60e25
--- /dev/null
+++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketTransportIT.java
@@ -0,0 +1,120 @@
+package com.github.magnusp.libsql.itest;
+
+import com.github.magnusp.libsql.client.LibsqlClientConfig;
+import com.github.magnusp.libsql.jdbc.LibsqlUrlParser;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Properties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Drives the JDBC driver over Hrana 3 JSON on a WebSocket ({@code hrana3} subprotocol), which is
+ * the same server and the same protocol structures as the HTTP transport but a different envelope.
+ */
+public class WebSocketTransportIT extends SqldContainerBase {
+
+ private static Connection connect() throws SQLException {
+ return DriverManager.getConnection("jdbc:libsql:" + getWebSocketUrl());
+ }
+
+ @Test
+ void selectReturnsTypedValues() throws SQLException {
+ try (Connection connection = connect();
+ Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(
+ "SELECT 1 AS i, 1.5 AS f, 'hello' AS t, x'010203' AS b, NULL AS n")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt("i")).isEqualTo(1);
+ assertThat(resultSet.getDouble("f")).isEqualTo(1.5);
+ assertThat(resultSet.getString("t")).isEqualTo("hello");
+ assertThat(resultSet.getBytes("b")).containsExactly(1, 2, 3);
+ assertThat(resultSet.getObject("n")).isNull();
+ assertThat(resultSet.next()).isFalse();
+ }
+ }
+
+ @Test
+ void transactionCommitsAndRollsBackOnOneStream() throws SQLException {
+ try (Connection connection = connect()) {
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE ws_tx (id INTEGER PRIMARY KEY, name TEXT)");
+ }
+
+ connection.setAutoCommit(false);
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("INSERT INTO ws_tx VALUES (1, 'committed')");
+ }
+ connection.commit();
+
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("INSERT INTO ws_tx VALUES (2, 'rolled back')");
+ }
+ connection.rollback();
+ connection.setAutoCommit(true);
+
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery("SELECT id, name FROM ws_tx ORDER BY id")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt(1)).isEqualTo(1);
+ assertThat(resultSet.getString(2)).isEqualTo("committed");
+ assertThat(resultSet.next()).isFalse();
+ }
+ }
+ }
+
+ @Test
+ void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable() throws SQLException {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ assertThatThrownBy(() -> statement.executeQuery("SELECT * FROM ws_missing_table"))
+ .isInstanceOf(SQLException.class)
+ .hasMessageContaining("no such table");
+
+ try (ResultSet resultSet = statement.executeQuery("SELECT 40 + 2")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt(1)).isEqualTo(42);
+ }
+ }
+ }
+
+ @Test
+ void metadataAndBatchWorkOverWebSocket() throws SQLException {
+ try (Connection connection = connect()) {
+ DatabaseMetaData metaData = connection.getMetaData();
+ assertThat(metaData.getDatabaseProductName()).isEqualTo("SQLite (libSQL)");
+ assertThat(metaData.getURL()).isEqualTo(getWebSocketUrl());
+
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE ws_batch (id INTEGER PRIMARY KEY, name TEXT)");
+ statement.addBatch("INSERT INTO ws_batch VALUES (1, 'a')");
+ statement.addBatch("INSERT INTO ws_batch VALUES (2, 'b')");
+ assertThat(statement.executeBatch()).containsExactly(1, 1);
+ }
+
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM ws_batch")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt(1)).isEqualTo(2);
+ }
+ }
+ }
+
+ @Test
+ void urlParserAcceptsWebSocketSchemes() throws SQLException {
+ assertThat(LibsqlUrlParser.acceptsUrl("jdbc:libsql:ws://localhost:8080")).isTrue();
+ assertThat(LibsqlUrlParser.acceptsUrl("jdbc:libsql:wss://example.com/dev")).isTrue();
+ assertThat(LibsqlUrlParser.acceptsUrl("jdbc:sqlite:ws://localhost:8080")).isTrue();
+
+ LibsqlClientConfig config = LibsqlUrlParser.parse(
+ "jdbc:libsql:ws://localhost:8080/dev/tenant?tls=true", new Properties());
+ assertThat(config.url()).isEqualTo("wss://localhost:8080/dev/tenant");
+ }
+}
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java
index 4f1b211..4d12831 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java
@@ -1,21 +1,16 @@
package com.github.magnusp.libsql.jdbc;
+import com.github.magnusp.libsql.client.LibsqlClient;
import com.github.magnusp.libsql.client.LibsqlClientConfig;
import com.github.magnusp.libsql.client.LibsqlException;
-import com.github.magnusp.libsql.client.LibsqlHttpClient;
import com.github.magnusp.libsql.hrana.codec.Batch;
import com.github.magnusp.libsql.hrana.codec.BatchCond;
+import com.github.magnusp.libsql.hrana.codec.BatchResult;
import com.github.magnusp.libsql.hrana.codec.BatchStep;
import com.github.magnusp.libsql.hrana.codec.HranaError;
-import com.github.magnusp.libsql.hrana.codec.PipelineReqBody;
-import com.github.magnusp.libsql.hrana.codec.PipelineRespBody;
import com.github.magnusp.libsql.hrana.codec.Stmt;
import com.github.magnusp.libsql.hrana.codec.StmtResult;
-import com.github.magnusp.libsql.hrana.codec.StreamRequest;
-import com.github.magnusp.libsql.hrana.codec.StreamResponse;
-import com.github.magnusp.libsql.hrana.codec.StreamResult;
-import java.net.URI;
import java.sql.Array;
import java.sql.BatchUpdateException;
import java.sql.Blob;
@@ -44,9 +39,9 @@
* A JDBC connection whose state is serialized on one connection-level lock, the way sqlite-jdbc
* serializes every call on its {@code DB} object.
*
- * Hrana over HTTP requires serialized requests per stream and consumes a baton once, so each
- * pipeline round trip and each transaction transition ({@link #setAutoCommit(boolean)}, {@link
- * #commit()}, {@link #rollback()}, {@link #close()}) runs while holding {@link #stateLock}.
+ * Both transports serialize requests on one interactive stream, and Hrana over HTTP consumes a
+ * baton once, so each round trip and each transaction transition ({@link #setAutoCommit(boolean)},
+ * {@link #commit()}, {@link #rollback()}, {@link #close()}) runs while holding {@link #stateLock}.
* Without it, two statements on one non-auto-commit connection can read the same baton, a commit
* can land between another thread's begin and its send, and a close can leave a stream open.
*
@@ -60,9 +55,9 @@
public class LibsqlConnection implements Connection {
private final LibsqlClientConfig config;
- private final LibsqlHttpClient client;
+ private final LibsqlClient client;
- /** Guards every field below and every pipeline round trip. See the class javadoc. */
+ /** Guards every field below and every transport round trip. See the class javadoc. */
private final Object stateLock = new Object();
private boolean autoCommit = true;
@@ -103,11 +98,9 @@ public static TransactionMode from(String mode) {
}
}
- // Interactive stream state
- private String baton = null;
- private URI baseUrl = null;
+ // Interactive stream state lives in the client; see LibsqlClient.hasOpenStream().
- public LibsqlConnection(LibsqlClientConfig config, LibsqlHttpClient client) {
+ public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client) {
this.config = config;
this.client = client;
}
@@ -133,23 +126,7 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException {
return client.executeOneShot(stmt);
} else {
ensureTransactionStarted();
- PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt)));
- PipelineRespBody resp = client.sendPipeline(req, baseUrl);
- updateStreamState(resp);
-
- if (resp.results().isEmpty()) {
- throw new SQLException("Empty result received from server");
- }
- StreamResult sr = resp.results().get(0);
- return switch (sr) {
- case StreamResult.Ok ok -> {
- if (ok.response() instanceof StreamResponse.Execute exec) {
- yield exec.result();
- }
- throw new SQLException("Unexpected response: " + ok.response());
- }
- case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error());
- };
+ return client.executeOnStream(stmt);
}
} catch (LibsqlException e) {
throw LibsqlErrorMapper.toSQLException(e);
@@ -186,44 +163,25 @@ List Callers hold {@link #stateLock}.
*/
private boolean beginPending() {
- return !autoCommit && baton == null;
+ return !autoCommit && !client.hasOpenStream();
}
/** Callers hold {@link #stateLock}. */
private void beginTransaction() throws SQLException {
- PipelineRespBody resp;
try {
- PipelineReqBody req = new PipelineReqBody(null,
- List.of(new StreamRequest.Execute(new Stmt(transactionMode.beginSql()))));
- resp = client.sendPipeline(req, baseUrl);
+ client.executeOnStream(new Stmt(transactionMode.beginSql()));
} catch (LibsqlException e) {
+ // The begin failed, so the fresh stream holds no transaction state.
+ closeStreamQuietly();
throw LibsqlErrorMapper.toSQLException(e);
}
- updateStreamState(resp);
- if (resp.results().isEmpty()) {
- closeStreamSilently();
- throw new SQLException("Empty result received from server");
- }
- StreamResult result = resp.results().get(0);
- if (result instanceof StreamResult.Error error) {
- closeStreamSilently();
- throw LibsqlErrorMapper.toSQLException(error.error());
- }
- if (!(result instanceof StreamResult.Ok ok)
- || !(ok.response() instanceof StreamResponse.Execute)) {
- closeStreamSilently();
- throw new SQLException("Unexpected response: " + result);
- }
}
/** Callers hold {@link #stateLock}. */
- private void updateStreamState(PipelineRespBody resp) {
- this.baton = resp.baton();
- if (resp.baseUrl() != null && !resp.baseUrl().isBlank()) {
- this.baseUrl = URI.create(resp.baseUrl());
+ private void closeStreamQuietly() {
+ if (client == null) {
+ // Only a connection built directly with no transport (as some tests do) reaches this.
+ return;
}
- }
-
- /** Callers hold {@link #stateLock}. */
- private void closeStreamSilently() {
- if (baton != null) {
- try {
- client.sendPipeline(new PipelineReqBody(baton, List.of(StreamRequest.Close.INSTANCE)), baseUrl);
- } catch (Exception ignored) {
- }
- this.baton = null;
+ try {
+ client.closeStream();
+ } catch (LibsqlException ignored) {
+ // Closing a stream is best effort: the transaction is already over or the transport is
+ // gone, and the server reaps the stream either way.
}
}
@@ -417,33 +354,17 @@ private void endTransaction(String sql) throws SQLException {
return;
}
- PipelineRespBody resp;
try {
- PipelineReqBody req = new PipelineReqBody(baton,
- List.of(new StreamRequest.Execute(new Stmt(sql))));
- resp = client.sendPipeline(req, baseUrl);
+ client.executeOnStream(new Stmt(sql));
} catch (LibsqlException e) {
throw LibsqlErrorMapper.toSQLException(e);
}
- updateStreamState(resp);
-
- if (resp.results().isEmpty()) {
- throw new SQLException("Empty result received from server");
- }
- StreamResult result = resp.results().get(0);
- if (result instanceof StreamResult.Error error) {
- throw LibsqlErrorMapper.toSQLException(error.error());
- }
- if (!(result instanceof StreamResult.Ok ok)
- || !(ok.response() instanceof StreamResponse.Execute)) {
- throw new SQLException("Unexpected response: " + result);
- }
// The server committed or rolled back, so the stream has no state left worth holding.
// Closing it lets an idle connection drop its server-side stream. On an error above the
- // baton stays, so the caller can retry or roll back on the same stream.
+ // stream stays, so the caller can retry or roll back on the same stream.
clearSavepoints();
- closeStreamSilently();
+ closeStreamQuietly();
}
@Override
@@ -452,7 +373,10 @@ public void close() throws SQLException {
if (!closed) {
closed = true;
clearSavepoints();
- closeStreamSilently();
+ closeStreamQuietly();
+ if (client != null) {
+ client.close();
+ }
}
}
}
@@ -631,7 +555,7 @@ private Savepoint setSavepointInternal(String name) throws SQLException {
executeTransactionStatement(sql);
} catch (SQLException e) {
// No transaction was started, so the fresh stream holds no state.
- closeStreamSilently();
+ closeStreamQuietly();
throw e;
}
autoCommit = false;
@@ -686,7 +610,7 @@ public void releaseSavepoint(Savepoint savepoint) throws SQLException {
// new one, as after a commit.
transactionStartedBySavepoint = false;
savepointNames.clear();
- closeStreamSilently();
+ closeStreamQuietly();
}
}
}
@@ -694,25 +618,11 @@ public void releaseSavepoint(Savepoint savepoint) throws SQLException {
/** Callers hold {@link #stateLock}. */
private void executeTransactionStatement(String sql) throws SQLException {
- PipelineRespBody resp;
try {
- PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(new Stmt(sql))));
- resp = client.sendPipeline(req, baseUrl);
+ client.executeOnStream(new Stmt(sql));
} catch (LibsqlException e) {
throw LibsqlErrorMapper.toSQLException(e);
}
- updateStreamState(resp);
- if (resp.results().isEmpty()) {
- throw new SQLException("Empty result received from server");
- }
- StreamResult result = resp.results().get(0);
- if (result instanceof StreamResult.Error error) {
- throw LibsqlErrorMapper.toSQLException(error.error());
- }
- if (!(result instanceof StreamResult.Ok ok)
- || !(ok.response() instanceof StreamResponse.Execute)) {
- throw new SQLException("Unexpected response: " + result);
- }
}
private static String savepointSqlName(Savepoint savepoint) throws SQLException {
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java
index 93777bd..c13dc11 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java
@@ -1,7 +1,9 @@
package com.github.magnusp.libsql.jdbc;
+import com.github.magnusp.libsql.client.LibsqlClient;
import com.github.magnusp.libsql.client.LibsqlClientConfig;
import com.github.magnusp.libsql.client.LibsqlHttpClient;
+import com.github.magnusp.libsql.client.LibsqlWebSocketClient;
import java.sql.Connection;
import java.sql.Driver;
@@ -31,10 +33,16 @@ public Connection connect(String url, Properties info) throws SQLException {
return null;
}
LibsqlClientConfig config = LibsqlUrlParser.parse(url, info);
- LibsqlHttpClient client = new LibsqlHttpClient(config);
+ LibsqlClient client = isWebSocketUrl(config.url())
+ ? new LibsqlWebSocketClient(config)
+ : new LibsqlHttpClient(config);
return new LibsqlConnection(config, client);
}
+ private static boolean isWebSocketUrl(String url) {
+ return url.startsWith("ws://") || url.startsWith("wss://");
+ }
+
@Override
public boolean acceptsURL(String url) throws SQLException {
return LibsqlUrlParser.acceptsUrl(url);
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java
index 9db2e22..2dbe019 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java
@@ -17,7 +17,10 @@ public class LibsqlUrlParser {
public static boolean acceptsUrl(String url) {
if (url == null) return false;
if (url.startsWith(PREFIX)) return true;
- return url.startsWith("jdbc:sqlite:http://") || url.startsWith("jdbc:sqlite:https://");
+ return url.startsWith("jdbc:sqlite:http://")
+ || url.startsWith("jdbc:sqlite:https://")
+ || url.startsWith("jdbc:sqlite:ws://")
+ || url.startsWith("jdbc:sqlite:wss://");
}
public static LibsqlClientConfig parse(String url, Properties info) throws SQLException {
@@ -32,8 +35,11 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx
rawUriStr = url.substring(SQLITE_PREFIX.length());
}
- boolean hasHttpScheme = rawUriStr.startsWith("http://") || rawUriStr.startsWith("https://");
- if (!hasHttpScheme) {
+ boolean hasExplicitScheme = rawUriStr.startsWith("http://")
+ || rawUriStr.startsWith("https://")
+ || rawUriStr.startsWith("ws://")
+ || rawUriStr.startsWith("wss://");
+ if (!hasExplicitScheme) {
if (rawUriStr.startsWith("//")) {
rawUriStr = "http:" + rawUriStr;
} else {
@@ -60,10 +66,14 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx
authToken = info.getProperty("authToken", info.getProperty("password", null));
}
- // Check TLS override
+ // Check TLS override. A ws URL upgrades to wss just like http upgrades to https.
String scheme = uri.getScheme();
if ("true".equalsIgnoreCase(queryParams.get("tls")) || "true".equalsIgnoreCase(queryParams.get("ssl"))) {
- scheme = "https";
+ scheme = switch (scheme) {
+ case "http" -> "https";
+ case "ws" -> "wss";
+ default -> scheme;
+ };
}
// Timeouts
@@ -85,14 +95,14 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx
String host = uri.getHost();
String path = uri.getPath() != null ? uri.getPath() : "";
- StringBuilder httpUrl = new StringBuilder();
- httpUrl.append(scheme).append("://").append(host);
+ StringBuilder endpointUrl = new StringBuilder();
+ endpointUrl.append(scheme).append("://").append(host);
if (port > 0) {
- httpUrl.append(":").append(port);
+ endpointUrl.append(":").append(port);
}
- httpUrl.append(path);
+ endpointUrl.append(path);
- return new LibsqlClientConfig(httpUrl.toString(), authToken, connectTimeout, busyTimeout);
+ return new LibsqlClientConfig(endpointUrl.toString(), authToken, connectTimeout, busyTimeout);
}
private static Map