diff --git a/httpclient5-testing/pom.xml b/httpclient5-testing/pom.xml index 84e1ef5bba..2e6eea06d2 100644 --- a/httpclient5-testing/pom.xml +++ b/httpclient5-testing/pom.xml @@ -77,6 +77,11 @@ httpclient5-fluent test + + org.apache.httpcomponents.client5 + httpclient5-websocket + test + com.kohlschutter.junixsocket junixsocket-core diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/H2WebSocketEchoIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/H2WebSocketEchoIT.java new file mode 100644 index 0000000000..c9e7ccb57b --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/H2WebSocketEchoIT.java @@ -0,0 +1,222 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class H2WebSocketEchoIT { + + private WebSocketH2Server server; + + @BeforeEach + void setUp() throws Exception { + server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(0) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + }) + .create(); + server.start(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.initiateShutdown(); + server.stop(); + } + } + + @Test + void echoesOverHttp2ExtendedConnect() throws Exception { + final URI uri = URI.create("ws://localhost:" + server.getLocalPort() + "/echo"); + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference echo = new AtomicReference<>(); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echo.set(text.toString()); + done.countDown(); + ws.close(1000, "done"); + } + + @Override + public void onClose(final int code, final String reason) { + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + assertTrue(done.await(10, TimeUnit.SECONDS), "timed out waiting for echo"); + assertEquals("hello-h2", echo.get()); + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } + + @Test + void echoesOverHttp2ExtendedConnectWithPmce() throws Exception { + final URI uri = URI.create("ws://localhost:" + server.getLocalPort() + "/echo"); + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference echo = new AtomicReference<>(); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .enablePerMessageDeflate(true) + .offerClientMaxWindowBits(15) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2-pmce hello-h2-pmce hello-h2-pmce", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echo.set(text.toString()); + done.countDown(); + ws.close(1000, "done"); + } + + @Override + public void onClose(final int code, final String reason) { + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + assertTrue(done.await(10, TimeUnit.SECONDS), "timed out waiting for echo"); + assertEquals("hello-h2-pmce hello-h2-pmce hello-h2-pmce", echo.get()); + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java new file mode 100644 index 0000000000..34fcdc697b --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java @@ -0,0 +1,406 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.client5.http.websocket.client.WebSocketClients; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketServer; +import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +final class WebSocketClientTest { + + private WebSocketServer server; + private int port; + + @BeforeEach + void startServer() throws Exception { + server = WebSocketServerBootstrap.bootstrap() + .setListenerPort(0) + .setCanonicalHostName("localhost") + .register("/echo", EchoHandler::new) + .register("/pmce", PmceHandler::new) + .register("/interleave", InterleaveHandler::new) + .register("/abrupt", AbruptHandler::new) + .register("/too-big", TooBigHandler::new) + .create(); + server.start(); + port = server.getLocalPort(); + } + + @AfterEach + void stopServer() throws Exception { + if (server != null) { + server.stop(); + } + } + + private static URI uri(final int port, final String path) { + return URI.create("ws://localhost:" + port + path); + } + + private static CloseableWebSocketClient newClient() { + final CloseableWebSocketClient client = WebSocketClientBuilder.create().build(); + client.start(); // start reactor threads + return client; + } + + @Test + void echo_uncompressed() throws Exception { + final URI uri = uri(port, "/echo"); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClients.createDefault()) { + client.start(); + + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + final StringBuilder echoed = new StringBuilder(); + final AtomicReference wsRef = new AtomicReference<>(); + + System.out.println("[TEST] connecting: " + uri); + + final WebSocketListener listener = new WebSocketListener() { + + @Override + public void onOpen(final WebSocket ws) { + wsRef.set(ws); + final String payload = buildPayload(); + System.out.println("[TEST] open: " + uri); + final boolean sent = ws.sendText(payload, true); + System.out.println("[TEST] sent (chars=" + payload.length() + ") sent=" + sent); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echoed.append(text); + if (last) { + System.out.println("[TEST] text (chars=" + text.length() + "): " + + (text.length() > 80 ? text.subSequence(0, 80) + "…" : text)); + final WebSocket ws = wsRef.get(); + if (ws != null) { + ws.close(1000, "done"); + } + } + } + + @Override + public void onClose(final int code, final String reason) { + try { + System.out.println("[TEST] close: " + code + " " + reason); + assertEquals(1000, code); + assertTrue(echoed.length() > 0, "No text echoed back"); + } finally { + done.countDown(); + } + } + + @Override + public void onError(final Throwable ex) { + ex.printStackTrace(System.out); + errorRef.set(ex); + done.countDown(); + } + + private String buildPayload() { + final String base = "hello from hc5 WS @ " + Instant.now() + " — "; + final StringBuilder buf = new StringBuilder(); + for (int i = 0; i < 256; i++) { + buf.append(base); + } + return buf.toString(); + } + + }; + + final CompletableFuture future = client.connect(uri, listener, cfg, null); + future.whenComplete((ws, ex) -> { + if (ex != null) { + errorRef.set(ex); + done.countDown(); + } + }); + + assertTrue(done.await(10, TimeUnit.SECONDS), "WebSocket did not close in time"); + + final Throwable error = errorRef.get(); + if (error != null) { + Assertions.fail("WebSocket error: " + error.getMessage(), error); + } + } + } + + @Test + void echo_compressed_pmce() throws Exception { + final URI uri = uri(port, "/pmce"); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(true) + .offerServerNoContextTakeover(true) + .offerClientNoContextTakeover(true) + .offerClientMaxWindowBits(15) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClients.createDefault()) { + client.start(); + + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + final StringBuilder echoed = new StringBuilder(); + final AtomicReference wsRef = new AtomicReference<>(); + + final WebSocketListener listener = new WebSocketListener() { + + @Override + public void onOpen(final WebSocket ws) { + wsRef.set(ws); + final String payload = "pmce test " + Instant.now(); + ws.sendText(payload, true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echoed.append(text); + if (last) { + final WebSocket ws = wsRef.get(); + if (ws != null) { + ws.close(1000, "done"); + } + } + } + + @Override + public void onClose(final int code, final String reason) { + try { + assertEquals(1000, code); + assertTrue(echoed.length() > 0, "No text echoed back"); + } finally { + done.countDown(); + } + } + + @Override + public void onError(final Throwable ex) { + errorRef.set(ex); + done.countDown(); + } + }; + + client.connect(uri, listener, cfg, null); + assertTrue(done.await(10, TimeUnit.SECONDS), "WebSocket did not close in time"); + + final Throwable error = errorRef.get(); + if (error != null) { + Assertions.fail("WebSocket error: " + error.getMessage(), error); + } + } + } + + @Test + void ping_interleaved_fragmentation() throws Exception { + final CountDownLatch gotText = new CountDownLatch(1); + final CountDownLatch gotPong = new CountDownLatch(1); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + final URI u = uri(port, "/interleave"); + client.connect(u, new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + ws.ping(null); + final String prefix = "hello from hc5 WS @ " + Instant.now() + " — "; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 256; i++) { + sb.append(prefix); + } + ws.sendText(sb.toString(), true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + gotText.countDown(); + } + + @Override + public void onPong(final ByteBuffer payload) { + gotPong.countDown(); + } + + @Override + public void onClose(final int code, final String reason) { + // the servlet closes after echo + } + + @Override + public void onError(final Throwable ex) { + gotText.countDown(); + gotPong.countDown(); + } + }, cfg, null); + + assertTrue(gotPong.await(10, TimeUnit.SECONDS), "did not receive PONG"); + assertTrue(gotText.await(10, TimeUnit.SECONDS), "did not receive TEXT"); + } + } + + @Test + void max_message_1009() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + final AtomicReference codeRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + final int maxMessage = 2048; // 2 KiB + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setMaxMessageSize(maxMessage) + .enablePerMessageDeflate(false) + .build(); + + final URI u = uri(port, "/too-big"); + client.connect(u, new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + // Trigger the server to send an oversized text message. + ws.sendText("trigger-too-big", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + // We may or may not see some text before the 1009 close. + } + + @Override + public void onClose(final int code, final String reason) { + codeRef.set(code); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + errorRef.set(ex); + done.countDown(); + } + }, cfg, null); + + assertTrue(done.await(10, TimeUnit.SECONDS), "timeout waiting for 1009 close"); + + final Throwable error = errorRef.get(); + if (error != null) { + Assertions.fail("WebSocket error: " + error.getMessage(), error); + } + + assertEquals(Integer.valueOf(1009), codeRef.get(), "expected 1009 close code"); + } + } + + private static final class EchoHandler implements WebSocketHandler { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + session.close(1000, "done"); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + } + } + + private static final class PmceHandler implements WebSocketHandler { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + session.close(1000, "done"); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + } + } + + private static final class InterleaveHandler implements WebSocketHandler { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendPing(ByteBuffer.wrap(new byte[]{'p', 'i', 'n', 'g'})); + session.sendText(text); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + } + } + + private static final class AbruptHandler implements WebSocketHandler { + @Override + public void onOpen(final WebSocketSession session) { + // no-op + } + } + + private static final class TooBigHandler implements WebSocketHandler { + @Override + public void onText(final WebSocketSession session, final String text) { + final StringBuilder sb = new StringBuilder(); + final String chunk = "1234567890abcdef-"; + while (sb.length() <= 8192) { + sb.append(chunk); + } + final String big = sb.toString(); + try { + session.sendText(big); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + } + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfBatchRunner.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfBatchRunner.java new file mode 100644 index 0000000000..f78dbbb284 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfBatchRunner.java @@ -0,0 +1,101 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket.performance; + +import java.util.ArrayList; +import java.util.List; + +/** + * Batch runner for H1/H2 WebSocket performance scenarios. + */ +public final class WsPerfBatchRunner { + + private WsPerfBatchRunner() { + } + + public static void main(final String[] args) throws Exception { + final List scenarios = new ArrayList<>(); + + scenarios.add(new String[]{ + "protocol=h1", + "mode=THROUGHPUT", + "clients=8", + "durationSec=10", + "bytes=512", + "inflight=32", + "pmce=false", + "compressible=true" + }); + scenarios.add(new String[]{ + "protocol=h1", + "mode=THROUGHPUT", + "clients=8", + "durationSec=10", + "bytes=512", + "inflight=32", + "pmce=false", + "compressible=false" + }); + scenarios.add(new String[]{ + "protocol=h2", + "mode=THROUGHPUT", + "clients=8", + "durationSec=10", + "bytes=512", + "inflight=32", + "pmce=false", + "compressible=false" + }); + scenarios.add(new String[]{ + "protocol=h2", + "mode=THROUGHPUT", + "clients=8", + "durationSec=10", + "bytes=512", + "inflight=32", + "pmce=true", + "compressible=false" + }); + scenarios.add(new String[]{ + "protocol=h2", + "mode=LATENCY", + "clients=4", + "durationSec=10", + "bytes=64", + "inflight=4", + "pmce=false", + "compressible=false" + }); + + final int total = scenarios.size(); + for (int i = 0; i < total; i++) { + final String[] scenario = scenarios.get(i); + System.out.println("\n[PERF] Scenario " + (i + 1) + "/" + total + ": " + String.join(" ", scenario)); + WsPerfHarness.main(scenario); + } + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfEchoServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfEchoServer.java new file mode 100644 index 0000000000..b5d143fc51 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfEchoServer.java @@ -0,0 +1,102 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket.performance; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketServer; +import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap; + +public final class WsPerfEchoServer { + private WebSocketServer server; + private int port; + + public void start() throws Exception { + start(0); + } + + public void start(final int listenerPort) throws Exception { + server = WebSocketServerBootstrap.bootstrap() + .setListenerPort(listenerPort) + .setCanonicalHostName("127.0.0.1") + .register("/echo", EchoHandler::new) + .create(); + server.start(); + this.port = server.getLocalPort(); + } + + public void stop() throws Exception { + if (server != null) { + server.stop(); + } + } + + public String uri() { + return "ws://127.0.0.1:" + port + "/echo"; + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + final WsPerfEchoServer server = new WsPerfEchoServer(); + server.start(port); + System.out.println("[PERF] echo server started at " + server.uri()); + final CountDownLatch done = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + server.stop(); + } catch (final Exception ignore) { + } finally { + done.countDown(); + } + })); + done.await(); + } + + private static final class EchoHandler implements WebSocketHandler { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0)); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfHarness.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfHarness.java new file mode 100644 index 0000000000..cc063e16ef --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfHarness.java @@ -0,0 +1,424 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.websocket.performance; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; +import org.apache.hc.core5.websocket.server.WebSocketServer; +import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap; + +/** + * Simple H1/H2 WebSocket performance harness that starts a local echo server + * and drives multiple clients against it. + *

+ * Example: + * protocol=h1 mode=THROUGHPUT clients=8 durationSec=10 bytes=512 inflight=32 pmce=false compressible=true + * protocol=h2 mode=LATENCY clients=4 durationSec=10 bytes=64 inflight=4 pmce=false compressible=false + */ +public final class WsPerfHarness { + + private WsPerfHarness() { + } + + public static void main(final String[] args) throws Exception { + final Args a = Args.parse(args); + final HarnessServer server = a.uri != null ? null : startServer(a); + final String uri = a.uri != null ? a.uri : server.uri(); + + System.out.printf(Locale.ROOT, + "protocol=%s mode=%s uri=%s clients=%d durationSec=%d bytes=%d inflight=%d pmce=%s compressible=%s%n", + a.protocol, a.mode, uri, a.clients, a.durationSec, a.bytes, a.inflight, a.pmce, a.compressible); + + final ExecutorService pool = Executors.newFixedThreadPool(Math.min(a.clients, 64)); + final AtomicLong sends = new AtomicLong(); + final AtomicLong recvs = new AtomicLong(); + final AtomicLong errors = new AtomicLong(); + final ConcurrentLinkedQueue lats = new ConcurrentLinkedQueue<>(); + final CountDownLatch ready = new CountDownLatch(a.clients); + final CountDownLatch go = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(a.clients); + + final byte[] payload = a.compressible ? makeCompressible(a.bytes) : makeRandom(a.bytes); + final AtomicLong deadlineRef = new AtomicLong(); + + for (int i = 0; i < a.clients; i++) { + final int id = i; + pool.submit(() -> runClient(id, a, uri, payload, sends, recvs, errors, lats, ready, go, done, deadlineRef)); + } + + final long awaitMs = TimeUnit.SECONDS.toMillis(a.durationSec + 15L); + if (!ready.await(awaitMs, TimeUnit.MILLISECONDS)) { + System.out.println("[PERF] timeout waiting for clients to connect"); + } + deadlineRef.set(System.nanoTime() + TimeUnit.SECONDS.toNanos(a.durationSec)); + go.countDown(); + if (!done.await(awaitMs, TimeUnit.MILLISECONDS)) { + System.out.println("[PERF] timeout waiting for clients to finish"); + } + pool.shutdown(); + + final long totalRecv = recvs.get(); + final long totalSend = sends.get(); + final double secs = a.durationSec; + final double msgps = totalRecv / secs; + final double mbps = (totalRecv * (long) a.bytes) / (1024.0 * 1024.0) / secs; + + System.out.printf(Locale.ROOT, "sent=%d recv=%d errors=%d%n", totalSend, totalRecv, errors.get()); + System.out.printf(Locale.ROOT, "throughput: %.0f msg/s, %.2f MiB/s%n", msgps, mbps); + + if (!lats.isEmpty()) { + final long[] arr = lats.stream().mapToLong(Long::longValue).toArray(); + Arrays.sort(arr); + System.out.printf(Locale.ROOT, + "latency (ms): p50=%.3f p95=%.3f p99=%.3f max=%.3f samples=%d%n", + nsToMs(p(arr, 0.50)), nsToMs(p(arr, 0.95)), nsToMs(p(arr, 0.99)), nsToMs(arr[arr.length - 1]), arr.length); + } + + if (server != null) { + server.stop(); + } + } + + private static HarnessServer startServer(final Args a) throws Exception { + if (a.protocol == Protocol.H2) { + final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(a.port) + .setCanonicalHostName(a.host) + .register("/echo", EchoHandler::new) + .create(); + server.start(); + return new HarnessServer(server.getLocalPort(), server); + } + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .setListenerPort(a.port) + .setCanonicalHostName(a.host) + .register("/echo", EchoHandler::new) + .create(); + server.start(); + return new HarnessServer(server.getLocalPort(), server); + } + + private static void runClient( + final int id, final Args a, final String uri, final byte[] payload, + final AtomicLong sends, final AtomicLong recvs, final AtomicLong errors, + final ConcurrentLinkedQueue lats, + final CountDownLatch ready, final CountDownLatch go, + final CountDownLatch done, final AtomicLong deadlineRef) { + + final WebSocketClientConfig.Builder b = WebSocketClientConfig.custom() + .setConnectTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(5)) + .setCloseWaitTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(3)) + .setOutgoingChunkSize(4096) + .setAutoPong(true) + .enableHttp2(a.protocol == Protocol.H2); + + if (a.pmce) { + b.enablePerMessageDeflate(true) + .offerClientNoContextTakeover(false) + .offerServerNoContextTakeover(false) + .offerClientMaxWindowBits(null) + .offerServerMaxWindowBits(null); + } + final WebSocketClientConfig cfg = b.build(); + + try (final CloseableWebSocketClient client = + WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + client.start(); + waitForStart(client, id); + + final AtomicInteger inflight = new AtomicInteger(); + final AtomicBoolean open = new AtomicBoolean(false); + final AtomicBoolean readyCounted = new AtomicBoolean(false); + + System.out.printf(Locale.ROOT, "[PERF] client-%d connecting to %s%n", id, uri); + final CompletableFuture cf = client.connect( + URI.create(uri), + new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + open.set(true); + if (readyCounted.compareAndSet(false, true)) { + ready.countDown(); + } + for (int j = 0; j < a.inflight; j++) { + sendOne(ws, a, payload, sends, inflight); + } + } + + @Override + public void onBinary(final ByteBuffer p, final boolean last) { + final long t1 = System.nanoTime(); + if (a.mode == Mode.LATENCY) { + if (p.remaining() >= 8) { + final long t0 = p.getLong(p.position()); + lats.add(t1 - t0); + } + } + recvs.incrementAndGet(); + inflight.decrementAndGet(); + } + + @Override + public void onError(final Throwable ex) { + errors.incrementAndGet(); + open.set(false); + if (!(ex instanceof ConnectionClosedException)) { + System.out.printf(Locale.ROOT, "[PERF] client-%d error: %s%n", id, ex.toString()); + } + } + + @Override + public void onClose(final int code, final String reason) { + open.set(false); + } + }, cfg, HttpCoreContext.create()); + cf.whenComplete((ws, ex) -> { + if (ex != null) { + errors.incrementAndGet(); + if (!(ex instanceof ConnectionClosedException)) { + System.out.printf(Locale.ROOT, "[PERF] client-%d connect failed: %s%n", id, ex.toString()); + } + } + }); + + try { + final WebSocket ws = cf.get(15, TimeUnit.SECONDS); + if (!go.await(10, TimeUnit.SECONDS)) { + System.out.printf(Locale.ROOT, "[PERF] client-%d start timeout%n", id); + } + final long deadlineNanos = deadlineRef.get(); + + while (System.nanoTime() < deadlineNanos) { + while (open.get() && inflight.get() < a.inflight) { + sendOne(ws, a, payload, sends, inflight); + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + + Thread.sleep(200); + ws.close(1000, "bye"); + } catch (final Exception e) { + errors.incrementAndGet(); + System.out.printf(Locale.ROOT, "[PERF] client-%d connect timeout/failure: %s%n", id, e); + if (readyCounted.compareAndSet(false, true)) { + ready.countDown(); + } + } finally { + if (readyCounted.compareAndSet(false, true)) { + ready.countDown(); + } + done.countDown(); + } + } catch (final Exception ignore) { + } + } + + private static void waitForStart(final CloseableWebSocketClient client, final int id) { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (client.getStatus() != null && client.getStatus() == IOReactorStatus.ACTIVE) { + return; + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20)); + } + System.out.printf(Locale.ROOT, "[PERF] client-%d start timeout (status=%s)%n", id, client.getStatus()); + } + + private static void sendOne(final WebSocket ws, final Args a, final byte[] payload, + final AtomicLong sends, final AtomicInteger inflight) { + final ByteBuffer p = ByteBuffer.allocate(payload.length + 8); + final long t0 = System.nanoTime(); + p.putLong(t0).put(payload).flip(); + if (ws.sendBinary(p, true)) { + inflight.incrementAndGet(); + sends.incrementAndGet(); + } + } + + private enum Mode { THROUGHPUT, LATENCY } + + private enum Protocol { H1, H2 } + + private static final class Args { + Protocol protocol = Protocol.H1; + String uri; + String host = "127.0.0.1"; + int port = 0; + int clients = 8; + int durationSec = 15; + int bytes = 512; + int inflight = 32; + boolean pmce = false; + boolean compressible = true; + Mode mode = Mode.THROUGHPUT; + + static Args parse(final String[] a) { + final Args r = new Args(); + for (final String s : a) { + final String[] kv = s.split("=", 2); + if (kv.length != 2) { + continue; + } + switch (kv[0]) { + case "protocol": + r.protocol = Protocol.valueOf(kv[1].toUpperCase(Locale.ROOT)); + break; + case "uri": + r.uri = kv[1]; + break; + case "host": + r.host = kv[1]; + break; + case "port": + r.port = Integer.parseInt(kv[1]); + break; + case "clients": + r.clients = Integer.parseInt(kv[1]); + break; + case "durationSec": + r.durationSec = Integer.parseInt(kv[1]); + break; + case "bytes": + r.bytes = Integer.parseInt(kv[1]); + break; + case "inflight": + r.inflight = Integer.parseInt(kv[1]); + break; + case "pmce": + r.pmce = Boolean.parseBoolean(kv[1]); + break; + case "compressible": + r.compressible = Boolean.parseBoolean(kv[1]); + break; + case "mode": + r.mode = Mode.valueOf(kv[1].toUpperCase(Locale.ROOT)); + break; + } + } + return r; + } + } + + private static final class HarnessServer { + private final int port; + private final WebSocketServer h1; + private final WebSocketH2Server h2; + + HarnessServer(final int port, final WebSocketServer h1) { + this.port = port; + this.h1 = h1; + this.h2 = null; + } + + HarnessServer(final int port, final WebSocketH2Server h2) { + this.port = port; + this.h1 = null; + this.h2 = h2; + } + + String uri() { + return "ws://127.0.0.1:" + port + "/echo"; + } + + void stop() throws Exception { + if (h2 != null) { + h2.stop(); + } else if (h1 != null) { + h1.stop(); + } + } + } + + private static final class EchoHandler implements WebSocketHandler { + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0)); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + } + } + + private static byte[] makeCompressible(final int n) { + final byte[] b = new byte[n]; + Arrays.fill(b, (byte) 'A'); + return b; + } + + private static byte[] makeRandom(final int n) { + final byte[] b = new byte[n]; + ThreadLocalRandom.current().nextBytes(b); + return b; + } + + private static double nsToMs(final long ns) { + return ns / 1_000_000.0; + } + + private static long p(final long[] arr, final double q) { + final int i = (int) Math.min(arr.length - 1, Math.max(0, Math.round((arr.length - 1) * q))); + return arr[i]; + } +} diff --git a/httpclient5-websocket/pom.xml b/httpclient5-websocket/pom.xml new file mode 100644 index 0000000000..6b0a96afce --- /dev/null +++ b/httpclient5-websocket/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + org.apache.httpcomponents.client5 + httpclient5-parent + 5.7-alpha1-SNAPSHOT + + + httpclient5-websocket + Apache HttpClient WebSocket + WebSocket support for HttpClient + jar + + + org.apache.httpcomponents.client5.websocket + + + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.apache.httpcomponents.client5 + httpclient5-cache + + + org.apache.httpcomponents.core5 + httpcore5-h2 + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-slf4j-impl + true + test + + + org.apache.logging.log4j + log4j-core + test + + + org.junit.jupiter + junit-jupiter + test + + + org.apache.commons + commons-compress + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + com.github.siom79.japicmp + japicmp-maven-plugin + + true + + + + + \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java new file mode 100644 index 0000000000..10a72aa4ec --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java @@ -0,0 +1,144 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Client-side representation of a single WebSocket connection. + * + *

Instances of this interface are thread-safe. Outbound operations may be + * invoked from arbitrary application threads. Inbound events are delivered + * to the associated {@link WebSocketListener}.

+ * + *

Outbound calls return {@code true} when the frame has been accepted for + * transmission. They do not indicate that the peer has received or processed + * the message. Applications that require acknowledgements must implement them + * at the protocol layer.

+ * + *

The close handshake follows RFC 6455. Applications should call + * {@link #close(int, String)} and wait for the {@link WebSocketListener#onClose(int, String)} + * callback to consider the connection terminated.

+ * + * @since 5.7 + */ +public interface WebSocket { + + /** + * Returns {@code true} if the WebSocket is still open and not in the + * process of closing. + */ + boolean isOpen(); + + /** + * Sends a PING control frame with the given payload. + *

The payload size must not exceed 125 bytes.

+ * + * @param data optional payload buffer; may be {@code null}. + * @return {@code true} if the frame was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean ping(ByteBuffer data); + + /** + * Sends a PONG control frame with the given payload. + *

The payload size must not exceed 125 bytes.

+ * + * @param data optional payload buffer; may be {@code null}. + * @return {@code true} if the frame was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean pong(ByteBuffer data); + + /** + * Sends a text message fragment. + * + * @param data text data to send. Must not be {@code null}. + * @param finalFragment {@code true} if this is the final fragment of + * the message, {@code false} if more fragments + * will follow. + * @return {@code true} if the fragment was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendText(CharSequence data, boolean finalFragment); + + /** + * Sends a binary message fragment. + * + * @param data binary data to send. Must not be {@code null}. + * @param finalFragment {@code true} if this is the final fragment of + * the message, {@code false} if more fragments + * will follow. + * @return {@code true} if the fragment was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendBinary(ByteBuffer data, boolean finalFragment); + + /** + * Sends a batch of text fragments as a single message. + * + * @param fragments ordered list of fragments; must not be {@code null} + * or empty. + * @param finalFragment {@code true} if this batch completes the logical + * message, {@code false} if subsequent batches + * will follow. + * @return {@code true} if the batch was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendTextBatch(List fragments, boolean finalFragment); + + /** + * Sends a batch of binary fragments as a single message. + * + * @param fragments ordered list of fragments; must not be {@code null} + * or empty. + * @param finalFragment {@code true} if this batch completes the logical + * message, {@code false} if subsequent batches + * will follow. + * @return {@code true} if the batch was accepted for sending, + * {@code false} if the connection is closing or closed. + */ + boolean sendBinaryBatch(List fragments, boolean finalFragment); + + /** + * Initiates the WebSocket close handshake. + * + *

The returned future is completed once the close frame has been + * queued for sending. It does not wait for the peer's close + * frame or for the underlying TCP connection to be closed.

+ * + * @param statusCode close status code to send. + * @param reason optional close reason; may be {@code null}. + * @return a future that completes when the close frame has been + * enqueued, or completes exceptionally if the close + * could not be initiated. + */ + CompletableFuture close(int statusCode, String reason); +} + diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java new file mode 100644 index 0000000000..a25acb10c5 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java @@ -0,0 +1,613 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; + +/** + * Immutable configuration for {@link WebSocket} clients. + * + *

Instances are normally created via the associated builder. The + * configuration controls timeouts, maximum frame and message sizes, + * fragmentation behaviour and optional automatic responses to PING frames.

+ * + *

Unless explicitly overridden, reasonable defaults are selected for + * desktop and server environments. For mobile or memory-constrained + * deployments, consider adjusting buffer sizes and queue limits.

+ * + * @since 5.7 + */ +public final class WebSocketClientConfig { + + private final Timeout connectTimeout; + private final List subprotocols; + + // PMCE offer + private final boolean perMessageDeflateEnabled; + private final boolean offerServerNoContextTakeover; + private final boolean offerClientNoContextTakeover; + private final Integer offerClientMaxWindowBits; + private final Integer offerServerMaxWindowBits; + + // Framing / flow + private final int maxFrameSize; + private final int outgoingChunkSize; + private final int maxFramesPerTick; + + // Buffers + private final boolean directBuffers; + + // Behavior + private final boolean autoPong; + private final Timeout closeWaitTimeout; + private final long maxMessageSize; + private final boolean http2Enabled; + + // Outbound queue limits + private final int maxOutboundControlQueue; + private final long maxOutboundDataBytes; + + private WebSocketClientConfig( + final Timeout connectTimeout, + final List subprotocols, + final boolean perMessageDeflateEnabled, + final boolean offerServerNoContextTakeover, + final boolean offerClientNoContextTakeover, + final Integer offerClientMaxWindowBits, + final Integer offerServerMaxWindowBits, + final int maxFrameSize, + final int outgoingChunkSize, + final int maxFramesPerTick, + final boolean directBuffers, + final boolean autoPong, + final Timeout closeWaitTimeout, + final long maxMessageSize, + final int maxOutboundControlQueue, + final long maxOutboundDataBytes, + final boolean http2Enabled) { + + this.connectTimeout = connectTimeout; + this.subprotocols = subprotocols != null + ? Collections.unmodifiableList(new ArrayList<>(subprotocols)) + : Collections.emptyList(); + this.perMessageDeflateEnabled = perMessageDeflateEnabled; + this.offerServerNoContextTakeover = offerServerNoContextTakeover; + this.offerClientNoContextTakeover = offerClientNoContextTakeover; + this.offerClientMaxWindowBits = offerClientMaxWindowBits; + this.offerServerMaxWindowBits = offerServerMaxWindowBits; + this.maxFrameSize = maxFrameSize; + this.outgoingChunkSize = outgoingChunkSize; + this.maxFramesPerTick = maxFramesPerTick; + this.directBuffers = directBuffers; + this.autoPong = autoPong; + this.closeWaitTimeout = Args.notNull(closeWaitTimeout, "closeWaitTimeout"); + this.maxMessageSize = maxMessageSize; + this.maxOutboundControlQueue = maxOutboundControlQueue; + this.maxOutboundDataBytes = maxOutboundDataBytes; + this.http2Enabled = http2Enabled; + } + + /** + * Timeout used for establishing the initial TCP/TLS connection. + * + * @return connection timeout, may be {@code null} if the caller wants to rely on defaults + * @since 5.7 + */ + public Timeout getConnectTimeout() { + return connectTimeout; + } + + /** + * Ordered list of WebSocket subprotocols offered to the server via {@code Sec-WebSocket-Protocol}. + * + *

The server may select at most one. The client should treat a server-selected protocol that + * was not offered as a handshake failure.

+ * + * @return immutable list of offered subprotocols (never {@code null}) + * @since 5.7 + */ + public List getSubprotocols() { + return subprotocols; + } + + /** + * Whether the client offers the {@code permessage-deflate} extension during the handshake. + * + * @return {@code true} if PMCE is offered, {@code false} otherwise + * @since 5.7 + */ + public boolean isPerMessageDeflateEnabled() { + return perMessageDeflateEnabled; + } + + /** + * Whether the client offers the {@code server_no_context_takeover} PMCE parameter. + * + * @return {@code true} if the parameter is included in the offer + * @since 5.7 + */ + public boolean isOfferServerNoContextTakeover() { + return offerServerNoContextTakeover; + } + + /** + * Whether the client offers the {@code client_no_context_takeover} PMCE parameter. + * + * @return {@code true} if the parameter is included in the offer + * @since 5.7 + */ + public boolean isOfferClientNoContextTakeover() { + return offerClientNoContextTakeover; + } + + /** + * Optional value for {@code client_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. The client encoder + * currently supports only {@code 15} due to JDK Deflater limitations.

+ * + * @return offered {@code client_max_window_bits}, or {@code null} if not offered + * @since 5.7 + */ + public Integer getOfferClientMaxWindowBits() { + return offerClientMaxWindowBits; + } + + /** + * Optional value for {@code server_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. This value limits the + * server's compressor; the client decoder can accept any 8..15 value.

+ * + * @return offered {@code server_max_window_bits}, or {@code null} if not offered + * @since 5.7 + */ + public Integer getOfferServerMaxWindowBits() { + return offerServerMaxWindowBits; + } + + /** + * Maximum accepted WebSocket frame payload size. + * + *

If an incoming frame exceeds this limit, the implementation should treat it as a protocol + * violation and initiate a close with an appropriate close code.

+ * + * @return maximum frame payload size in bytes (must be > 0) + * @since 5.7 + */ + public int getMaxFrameSize() { + return maxFrameSize; + } + + /** + * Preferred outgoing fragmentation chunk size. + * + *

Outgoing messages larger than this value may be fragmented into multiple frames.

+ * + * @return outgoing chunk size in bytes (must be > 0) + * @since 5.7 + */ + public int getOutgoingChunkSize() { + return outgoingChunkSize; + } + + /** + * Limit of frames written per reactor "tick". + * + *

This is a fairness control to reduce the risk of starving the reactor thread when + * a large backlog exists.

+ * + * @return maximum frames per tick (must be > 0) + * @since 5.7 + */ + public int getMaxFramesPerTick() { + return maxFramesPerTick; + } + + /** + * Whether direct byte buffers are preferred for internal I/O. + * + * @return {@code true} for direct buffers, {@code false} for heap buffers + * @since 5.7 + */ + public boolean isDirectBuffers() { + return directBuffers; + } + + /** + * Whether the client automatically responds to PING frames with a PONG frame. + * + * @return {@code true} if auto-PONG is enabled + * @since 5.7 + */ + public boolean isAutoPong() { + return autoPong; + } + + /** + * Socket timeout used while waiting for the peer to complete the close handshake. + * + * @return close wait timeout (never {@code null}) + * @since 5.7 + */ + public Timeout getCloseWaitTimeout() { + return closeWaitTimeout; + } + + /** + * Maximum accepted message size after fragment reassembly (and after decompression if enabled). + * + * @return maximum message size in bytes (must be > 0) + * @since 5.7 + */ + public long getMaxMessageSize() { + return maxMessageSize; + } + + /** + * Maximum number of queued outbound control frames. + * + *

This bounds memory usage and prevents unbounded growth of control traffic under backpressure.

+ * + * @return maximum outbound control queue size (must be > 0) + * @since 5.7 + */ + public int getMaxOutboundControlQueue() { + return maxOutboundControlQueue; + } + + /** + * Upper bound for the total number of bytes queued for outbound data frames. + * + *

When this limit is exceeded, {@code sendText/sendBinary} return {@code false} + * and the data frame is rejected. A value of {@code 0} disables the limit.

+ * + * @return max queued bytes for outbound data frames + * @since 5.7 + */ + public long getMaxOutboundDataBytes() { + return maxOutboundDataBytes; + } + + /** + * Returns {@code true} if HTTP/2 Extended CONNECT (RFC 8441) is enabled. + * + * @since 5.7 + */ + public boolean isHttp2Enabled() { + return http2Enabled; + } + + /** + * Creates a new builder instance with default settings. + * + * @return builder + * @since 5.7 + */ + public static Builder custom() { + return new Builder(); + } + + /** + * Builder for {@link WebSocketClientConfig}. + * + *

The builder is mutable and not thread-safe.

+ * + * @since 5.7 + */ + public static final class Builder { + + private Timeout connectTimeout = Timeout.ofSeconds(10); + private List subprotocols = new ArrayList<>(); + + private boolean perMessageDeflateEnabled = true; + private boolean offerServerNoContextTakeover = true; + private boolean offerClientNoContextTakeover = true; + private Integer offerClientMaxWindowBits = 15; + private Integer offerServerMaxWindowBits = null; + + private int maxFrameSize = 64 * 1024; + private int outgoingChunkSize = 8 * 1024; + private int maxFramesPerTick = 1024; + + private boolean directBuffers = true; + + private boolean autoPong = true; + private Timeout closeWaitTimeout = Timeout.ofSeconds(10); + private long maxMessageSize = 8L * 1024 * 1024; + + private int maxOutboundControlQueue = 256; + private long maxOutboundDataBytes = 16L * 1024 * 1024; + private boolean http2Enabled; + + /** + * Sets the timeout used to establish the initial TCP/TLS connection. + * + * @param v timeout, may be {@code null} to rely on defaults + * @return this builder + * @since 5.7 + */ + public Builder setConnectTimeout(final Timeout v) { + this.connectTimeout = v; + return this; + } + + /** + * Sets the ordered list of subprotocols offered to the server. + * + * @param v list of subprotocol names, may be {@code null} to offer none + * @return this builder + * @since 5.7 + */ + public Builder setSubprotocols(final List v) { + this.subprotocols = v; + return this; + } + + /** + * Enables or disables offering {@code permessage-deflate} during the handshake. + * + * @param v {@code true} to offer PMCE, {@code false} otherwise + * @return this builder + * @since 5.7 + */ + public Builder enablePerMessageDeflate(final boolean v) { + this.perMessageDeflateEnabled = v; + return this; + } + + /** + * Offers {@code server_no_context_takeover} in the PMCE offer. + * + * @param v whether to include the parameter in the offer + * @return this builder + * @since 5.7 + */ + public Builder offerServerNoContextTakeover(final boolean v) { + this.offerServerNoContextTakeover = v; + return this; + } + + /** + * Offers {@code client_no_context_takeover} in the PMCE offer. + * + * @param v whether to include the parameter in the offer + * @return this builder + * @since 5.7 + */ + public Builder offerClientNoContextTakeover(final boolean v) { + this.offerClientNoContextTakeover = v; + return this; + } + + /** + * Offers {@code client_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. The client encoder + * currently supports only {@code 15} due to JDK Deflater limitations.

+ * + * @param v window bits, or {@code null} to omit the parameter + * @return this builder + * @since 5.7 + */ + public Builder offerClientMaxWindowBits(final Integer v) { + this.offerClientMaxWindowBits = v; + return this; + } + + /** + * Offers {@code server_max_window_bits} in the PMCE offer. + * + *

Valid values are in range 8..15 when non-null. This value limits the + * server's compressor; the client decoder can accept any 8..15 value.

+ * + * @param v window bits, or {@code null} to omit the parameter + * @return this builder + * @since 5.7 + */ + public Builder offerServerMaxWindowBits(final Integer v) { + this.offerServerMaxWindowBits = v; + return this; + } + + /** + * Sets the maximum accepted frame payload size. + * + * @param v maximum frame payload size in bytes (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxFrameSize(final int v) { + this.maxFrameSize = v; + return this; + } + + /** + * Sets the preferred outgoing fragmentation chunk size. + * + * @param v chunk size in bytes (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setOutgoingChunkSize(final int v) { + this.outgoingChunkSize = v; + return this; + } + + /** + * Sets the limit of frames written per reactor tick. + * + * @param v max frames per tick (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxFramesPerTick(final int v) { + this.maxFramesPerTick = v; + return this; + } + + /** + * Enables or disables the use of direct buffers for internal I/O. + * + * @param v {@code true} for direct buffers, {@code false} for heap buffers + * @return this builder + * @since 5.7 + */ + public Builder setDirectBuffers(final boolean v) { + this.directBuffers = v; + return this; + } + + /** + * Enables or disables automatic PONG replies for received PING frames. + * + * @param v {@code true} to auto-reply with PONG + * @return this builder + * @since 5.7 + */ + public Builder setAutoPong(final boolean v) { + this.autoPong = v; + return this; + } + + /** + * Sets the close handshake wait timeout. + * + * @param v close wait timeout, must not be {@code null} + * @return this builder + * @since 5.7 + */ + public Builder setCloseWaitTimeout(final Timeout v) { + this.closeWaitTimeout = v; + return this; + } + + /** + * Sets the maximum accepted message size. + * + * @param v max message size in bytes (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxMessageSize(final long v) { + this.maxMessageSize = v; + return this; + } + + /** + * Sets the maximum number of queued outbound control frames. + * + * @param v max control queue size (must be > 0) + * @return this builder + * @since 5.7 + */ + public Builder setMaxOutboundControlQueue(final int v) { + this.maxOutboundControlQueue = v; + return this; + } + + /** + * Sets the maximum number of bytes that can be queued for outbound data frames. + * + *

When the limit is exceeded, {@code sendText/sendBinary} return {@code false}. + * A value of {@code 0} disables the limit.

+ * + * @param v max queued bytes for outbound data frames + * @return this builder + * @since 5.7 + */ + public Builder setMaxOutboundDataBytes(final long v) { + this.maxOutboundDataBytes = v; + return this; + } + + /** + * Enables HTTP/2 Extended CONNECT (RFC 8441) for supported endpoints. + * + * @param enabled true to enable HTTP/2 WebSocket connections + * @return this builder + * @since 5.7 + */ + public Builder enableHttp2(final boolean enabled) { + this.http2Enabled = enabled; + return this; + } + + /** + * Builds an immutable {@link WebSocketClientConfig}. + * + * @return configuration instance + * @throws IllegalArgumentException if any parameter is invalid + * @since 5.7 + */ + public WebSocketClientConfig build() { + if (offerClientMaxWindowBits != null && (offerClientMaxWindowBits < 8 || offerClientMaxWindowBits > 15)) { + throw new IllegalArgumentException("offerClientMaxWindowBits must be in range [8..15]"); + } + if (offerServerMaxWindowBits != null && (offerServerMaxWindowBits < 8 || offerServerMaxWindowBits > 15)) { + throw new IllegalArgumentException("offerServerMaxWindowBits must be in range [8..15]"); + } + if (closeWaitTimeout == null) { + throw new IllegalArgumentException("closeWaitTimeout != null"); + } + if (maxFrameSize <= 0) { + throw new IllegalArgumentException("maxFrameSize > 0"); + } + if (outgoingChunkSize <= 0) { + throw new IllegalArgumentException("outgoingChunkSize > 0"); + } + if (maxFramesPerTick <= 0) { + throw new IllegalArgumentException("maxFramesPerTick > 0"); + } + if (maxMessageSize <= 0) { + throw new IllegalArgumentException("maxMessageSize > 0"); + } + if (maxOutboundControlQueue <= 0) { + throw new IllegalArgumentException("maxOutboundControlQueue > 0"); + } + if (maxOutboundDataBytes < 0) { + throw new IllegalArgumentException("maxOutboundDataBytes >= 0"); + } + return new WebSocketClientConfig( + connectTimeout, subprotocols, + perMessageDeflateEnabled, offerServerNoContextTakeover, offerClientNoContextTakeover, + offerClientMaxWindowBits, offerServerMaxWindowBits, + maxFrameSize, outgoingChunkSize, maxFramesPerTick, + directBuffers, + autoPong, closeWaitTimeout, maxMessageSize, + maxOutboundControlQueue, + maxOutboundDataBytes, + http2Enabled + ); + } + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java new file mode 100644 index 0000000000..c2ad5ca393 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java @@ -0,0 +1,103 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; + +/** + * Callback interface for receiving WebSocket events. + * + *

Implementations should be fast and non-blocking because callbacks + * are normally invoked on I/O dispatcher threads.

+ * + *

Exceptions thrown by callbacks are treated as errors and may result + * in the connection closing. Implementations should handle their own + * failures and avoid throwing unless they intend to abort the session.

+ * + * @since 5.7 + */ +public interface WebSocketListener { + + /** + * Invoked when the WebSocket connection has been established. + */ + default void onOpen(WebSocket webSocket) { + } + + /** + * Invoked when a complete text message has been received. + * + * @param data characters of the message; the buffer is only valid + * for the duration of the callback. + * @param last always {@code true} for now; reserved for future + * streaming support. + */ + default void onText(CharBuffer data, boolean last) { + } + + /** + * Invoked when a complete binary message has been received. + * + * @param data binary payload; the buffer is only valid for the + * duration of the callback. + * @param last always {@code true} for now; reserved for future + * streaming support. + */ + default void onBinary(ByteBuffer data, boolean last) { + } + + /** + * Invoked when a PING control frame is received. + */ + default void onPing(ByteBuffer data) { + } + + /** + * Invoked when a PONG control frame is received. + */ + default void onPong(ByteBuffer data) { + } + + /** + * Invoked when the WebSocket has been closed. + * + * @param statusCode close status code. + * @param reason close reason, never {@code null} but may be empty. + */ + default void onClose(int statusCode, String reason) { + } + + /** + * Invoked when a fatal error occurs on the WebSocket connection. + * + *

After this callback the connection is considered closed.

+ */ + default void onError(Throwable cause) { + } +} + diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java new file mode 100644 index 0000000000..aefc2615b5 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Public WebSocket API for client applications. + * + *

Types in this package are stable and intended for direct use: + * {@code WebSocket}, {@code WebSocketClientConfig}, and {@code WebSocketListener}.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.api; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java new file mode 100644 index 0000000000..622f951238 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java @@ -0,0 +1,123 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.io.ModalCloseable; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; + +/** + * Public WebSocket client API mirroring {@code CloseableHttpAsyncClient}'s shape. + * + *

Subclasses provide the actual connect implementation in + * {@link #doConnect(URI, WebSocketListener, WebSocketClientConfig, HttpContext)}. + * Overloads of {@code connect(...)} funnel into that single method.

+ * + *

This type is a {@link ModalCloseable}; use {@link #close(CloseMode)} to select + * graceful or immediate shutdown. A graceful close allows in-flight I/O to finish, + * while immediate close aborts active operations.

+ * + * @since 5.7 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public abstract class CloseableWebSocketClient implements WebSocketClient, ModalCloseable { + + /** + * Start underlying I/O. Safe to call once; subsequent calls are no-ops. + */ + public abstract void start(); + + /** + * Current I/O reactor status. + */ + public abstract IOReactorStatus getStatus(); + + /** + * Best-effort await of shutdown. + */ + public abstract void awaitShutdown(TimeValue waitTime) throws InterruptedException; + + /** + * Initiate shutdown (non-blocking). + */ + public abstract void initiateShutdown(); + + /** + * Core connect hook for subclasses. + * + * @param uri target WebSocket URI (ws:// or wss://) + * @param listener application callbacks + * @param cfg optional per-connection config (may be {@code null} for defaults) + * @param context optional HTTP context (may be {@code null}) + */ + protected abstract CompletableFuture doConnect( + URI uri, + WebSocketListener listener, + WebSocketClientConfig cfg, + HttpContext context); + + public final CompletableFuture connect( + final URI uri, + final WebSocketListener listener) { + Args.notNull(uri, "URI"); + Args.notNull(listener, "WebSocketListener"); + return connect(uri, listener, null, HttpCoreContext.create()); + } + + public final CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg) { + Args.notNull(uri, "URI"); + Args.notNull(listener, "WebSocketListener"); + return connect(uri, listener, cfg, HttpCoreContext.create()); + } + + @Override + public final CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + Args.notNull(uri, "URI"); + Args.notNull(listener, "WebSocketListener"); + return doConnect(uri, listener, cfg, context); + } + +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java new file mode 100644 index 0000000000..cbe660179a --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java @@ -0,0 +1,77 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Client for establishing WebSocket connections using the underlying + * asynchronous HttpClient infrastructure. + * + *

This interface represents the minimal contract for initiating + * WebSocket handshakes. Implementations are expected to be thread-safe.

+ * + * @since 5.7 + */ +public interface WebSocketClient { + + /** + * Initiates an asynchronous WebSocket connection to the given target URI. + * + *

The URI must use the {@code ws} or {@code wss} scheme. This method + * performs an HTTP/1.1 upgrade to the WebSocket protocol and, on success, + * creates a {@link WebSocket} associated with the supplied + * {@link WebSocketListener}.

+ * + *

The operation is fully asynchronous. The returned + * {@link CompletableFuture} completes when the opening WebSocket + * handshake has either succeeded or failed.

+ * + * @param uri target WebSocket URI, must not be {@code null}. + * @param listener callback that receives WebSocket events, must not be {@code null}. + * @param cfg optional per-connection configuration; if {@code null}, the + * client’s default configuration is used. + * @param context optional HTTP context for the underlying upgrade request; + * may be {@code null}. + * @return a future that completes with a connected {@link WebSocket} on + * success, or completes exceptionally if the connection attempt + * or protocol handshake fails. + * @since 5.7 + */ + CompletableFuture connect( + URI uri, + WebSocketListener listener, + WebSocketClientConfig cfg, + HttpContext context); + +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java new file mode 100644 index 0000000000..8de4cfdc4d --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java @@ -0,0 +1,480 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import java.util.concurrent.ThreadFactory; + +import org.apache.hc.client5.http.impl.DefaultClientConnectionReuseStrategy; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.client.impl.DefaultWebSocketClient; +import org.apache.hc.client5.http.websocket.client.impl.logging.WsLoggingExceptionCallback; +import org.apache.hc.core5.concurrent.DefaultThreadFactory; +import org.apache.hc.core5.function.Callback; +import org.apache.hc.core5.function.Decorator; +import org.apache.hc.core5.http.ConnectionReuseStrategy; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.impl.nio.ClientHttp1IOEventHandlerFactory; +import org.apache.hc.core5.http.impl.nio.ClientHttp1StreamDuplexerFactory; +import org.apache.hc.core5.http.nio.ssl.BasicClientTlsStrategy; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequesterBootstrap; +import org.apache.hc.core5.http2.impl.H2Processors; +import org.apache.hc.core5.pool.ConnPoolListener; +import org.apache.hc.core5.pool.DefaultDisposalCallback; +import org.apache.hc.core5.pool.LaxConnPool; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.pool.PoolConcurrencyPolicy; +import org.apache.hc.core5.pool.PoolReusePolicy; +import org.apache.hc.core5.pool.StrictConnPool; +import org.apache.hc.core5.reactor.IOEventHandlerFactory; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.IOReactorMetricsListener; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.IOSessionListener; +import org.apache.hc.core5.reactor.IOWorkerSelector; +import org.apache.hc.core5.util.Timeout; + +/** + * Builder for {@link CloseableWebSocketClient} instances. + * + *

This builder assembles a WebSocket client on top of the asynchronous + * HTTP/1.1 requester and connection pool infrastructure provided by + * HttpComponents Core. Unless otherwise specified, sensible defaults + * are used for all components.

+ * + *

The resulting {@link CloseableWebSocketClient} manages its own I/O + * reactor and connection pool and must be {@link java.io.Closeable#close() + * closed} when no longer needed.

+ * + *

Builders are mutable and not thread-safe. Configure the instance + * on a single thread and then call {@link #build()}.

+ * + * @since 5.7 + */ +public final class WebSocketClientBuilder { + + private IOReactorConfig ioReactorConfig; + private Http1Config http1Config; + private CharCodingConfig charCodingConfig; + private HttpProcessor httpProcessor; + private ConnectionReuseStrategy connStrategy; + private int defaultMaxPerRoute; + private int maxTotal; + private Timeout timeToLive; + private PoolReusePolicy poolReusePolicy; + private PoolConcurrencyPolicy poolConcurrencyPolicy; + private TlsStrategy tlsStrategy; + private Timeout handshakeTimeout; + private Decorator ioSessionDecorator; + private Callback exceptionCallback; + private IOSessionListener sessionListener; + private Http1StreamListener streamListener; + private H2Config h2Config; + private ConnPoolListener connPoolListener; + private ThreadFactory threadFactory; + + // Optional listeners for reactor metrics and worker selection. + private IOReactorMetricsListener reactorMetricsListener; + private IOWorkerSelector workerSelector; + private int maxPendingCommandsPerConnection; + + private WebSocketClientConfig defaultConfig = WebSocketClientConfig.custom().build(); + + private WebSocketClientBuilder() { + } + + /** + * Creates a new {@code WebSocketClientBuilder} instance. + * + * @return a new builder. + */ + public static WebSocketClientBuilder create() { + return new WebSocketClientBuilder(); + } + + /** + * Sets the default configuration applied to WebSocket connections + * created by the resulting client. + * + * @param defaultConfig default WebSocket configuration; if {@code null} + * the existing default is retained. + * @return this builder. + */ + public WebSocketClientBuilder defaultConfig(final WebSocketClientConfig defaultConfig) { + if (defaultConfig != null) { + this.defaultConfig = defaultConfig; + } + return this; + } + + /** + * Sets the I/O reactor configuration. + * + * @param ioReactorConfig I/O reactor configuration, or {@code null} + * to use {@link IOReactorConfig#DEFAULT}. + * @return this builder. + */ + public WebSocketClientBuilder setIOReactorConfig(final IOReactorConfig ioReactorConfig) { + this.ioReactorConfig = ioReactorConfig; + return this; + } + + /** + * Sets the HTTP/1.1 configuration for the underlying requester. + * + * @param http1Config HTTP/1.1 configuration, or {@code null} + * to use {@link Http1Config#DEFAULT}. + * @return this builder. + */ + public WebSocketClientBuilder setHttp1Config(final Http1Config http1Config) { + this.http1Config = http1Config; + return this; + } + + /** + * Sets the character coding configuration for HTTP message processing. + * + * @param charCodingConfig character coding configuration, or {@code null} + * to use {@link CharCodingConfig#DEFAULT}. + * @return this builder. + */ + public WebSocketClientBuilder setCharCodingConfig(final CharCodingConfig charCodingConfig) { + this.charCodingConfig = charCodingConfig; + return this; + } + + /** + * Sets a custom {@link HttpProcessor} for HTTP/1.1 requests. + * + * @param httpProcessor HTTP processor, or {@code null} to use + * {@link HttpProcessors#client()}. + * @return this builder. + */ + public WebSocketClientBuilder setHttpProcessor(final HttpProcessor httpProcessor) { + this.httpProcessor = httpProcessor; + return this; + } + + /** + * Sets the connection reuse strategy for persistent HTTP connections. + * + * @param connStrategy connection reuse strategy, or {@code null} + * to use {@link DefaultClientConnectionReuseStrategy}. + * @return this builder. + */ + public WebSocketClientBuilder setConnectionReuseStrategy(final ConnectionReuseStrategy connStrategy) { + this.connStrategy = connStrategy; + return this; + } + + /** + * Sets the default maximum number of connections per route. + * + * @param defaultMaxPerRoute maximum connections per route; values + * ≤ 0 cause the default of {@code 20} + * to be used. + * @return this builder. + */ + public WebSocketClientBuilder setDefaultMaxPerRoute(final int defaultMaxPerRoute) { + this.defaultMaxPerRoute = defaultMaxPerRoute; + return this; + } + + /** + * Sets the maximum total number of connections in the pool. + * + * @param maxTotal maximum total connections; values ≤ 0 cause + * the default of {@code 50} to be used. + * @return this builder. + */ + public WebSocketClientBuilder setMaxTotal(final int maxTotal) { + this.maxTotal = maxTotal; + return this; + } + + /** + * Sets the time-to-live for persistent connections in the pool. + * + * @param timeToLive connection time-to-live, or {@code null} to use + * {@link Timeout#DISABLED}. + * @return this builder. + */ + public WebSocketClientBuilder setTimeToLive(final Timeout timeToLive) { + this.timeToLive = timeToLive; + return this; + } + + /** + * Sets the reuse policy for connections in the pool. + * + * @param poolReusePolicy reuse policy, or {@code null} to use + * {@link PoolReusePolicy#LIFO}. + * @return this builder. + */ + public WebSocketClientBuilder setPoolReusePolicy(final PoolReusePolicy poolReusePolicy) { + this.poolReusePolicy = poolReusePolicy; + return this; + } + + /** + * Sets the concurrency policy for the connection pool. + * + * @param poolConcurrencyPolicy concurrency policy, or {@code null} + * to use {@link PoolConcurrencyPolicy#STRICT}. + * @return this builder. + */ + public WebSocketClientBuilder setPoolConcurrencyPolicy(final PoolConcurrencyPolicy poolConcurrencyPolicy) { + this.poolConcurrencyPolicy = poolConcurrencyPolicy; + return this; + } + + /** + * Sets the maximum number of pending commands per connection. + * + * @param maxPendingCommandsPerConnection maximum pending commands; values < 0 + * cause the default of {@code 0} to be used. + * @return this builder. + */ + public WebSocketClientBuilder setMaxPendingCommandsPerConnection(final int maxPendingCommandsPerConnection) { + this.maxPendingCommandsPerConnection = maxPendingCommandsPerConnection; + return this; + } + + /** + * Sets the TLS strategy used to establish HTTPS or WSS connections. + * + * @param tlsStrategy TLS strategy, or {@code null} to use + * {@link BasicClientTlsStrategy}. + * @return this builder. + */ + public WebSocketClientBuilder setTlsStrategy(final TlsStrategy tlsStrategy) { + this.tlsStrategy = tlsStrategy; + return this; + } + + /** + * Sets the timeout for the TLS handshake. + * + * @param handshakeTimeout handshake timeout, or {@code null} for no + * specific timeout. + * @return this builder. + */ + public WebSocketClientBuilder setTlsHandshakeTimeout(final Timeout handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + return this; + } + + /** + * Sets a decorator for low-level I/O sessions created by the reactor. + * + * @param ioSessionDecorator decorator, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setIOSessionDecorator(final Decorator ioSessionDecorator) { + this.ioSessionDecorator = ioSessionDecorator; + return this; + } + + /** + * Sets a callback to be notified of fatal I/O exceptions. + * + * @param exceptionCallback exception callback, or {@code null} to use + * {@link WsLoggingExceptionCallback#INSTANCE}. + * @return this builder. + */ + public WebSocketClientBuilder setExceptionCallback(final Callback exceptionCallback) { + this.exceptionCallback = exceptionCallback; + return this; + } + + /** + * Sets a listener for I/O session lifecycle events. + * + * @param sessionListener session listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setIOSessionListener(final IOSessionListener sessionListener) { + this.sessionListener = sessionListener; + return this; + } + + /** + * Sets a listener for HTTP/1.1 stream events. + * + * @param streamListener stream listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setStreamListener( + final Http1StreamListener streamListener) { + this.streamListener = streamListener; + return this; + } + + /** + * Sets a listener for connection pool events. + * + * @param connPoolListener pool listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setConnPoolListener(final ConnPoolListener connPoolListener) { + this.connPoolListener = connPoolListener; + return this; + } + + /** + * Sets the thread factory used to create the main I/O reactor thread. + * + * @param threadFactory thread factory, or {@code null} to use a + * {@link DefaultThreadFactory} named + * {@code "websocket-main"}. + * @return this builder. + */ + public WebSocketClientBuilder setThreadFactory(final ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + + /** + * Sets a metrics listener for the I/O reactor. + * + * @param reactorMetricsListener metrics listener, or {@code null} for none. + * @return this builder. + */ + public WebSocketClientBuilder setReactorMetricsListener( + final IOReactorMetricsListener reactorMetricsListener) { + this.reactorMetricsListener = reactorMetricsListener; + return this; + } + + /** + * Sets a worker selector for assigning I/O sessions to worker threads. + * + * @param workerSelector worker selector, or {@code null} for the default + * strategy. + * @return this builder. + */ + public WebSocketClientBuilder setWorkerSelector(final IOWorkerSelector workerSelector) { + this.workerSelector = workerSelector; + return this; + } + + /** + * Builds a new {@link CloseableWebSocketClient} instance using the + * current builder configuration. + * + *

The returned client owns its underlying I/O reactor and connection + * pool and must be closed to release system resources.

+ * + * @return a newly created {@link CloseableWebSocketClient}. + */ + public CloseableWebSocketClient build() { + + final PoolConcurrencyPolicy conc = poolConcurrencyPolicy != null + ? poolConcurrencyPolicy + : PoolConcurrencyPolicy.STRICT; + final PoolReusePolicy reuse = poolReusePolicy != null + ? poolReusePolicy + : PoolReusePolicy.LIFO; + final Timeout ttl = timeToLive != null ? timeToLive : Timeout.DISABLED; + + final ManagedConnPool connPool; + if (conc == PoolConcurrencyPolicy.LAX) { + connPool = new LaxConnPool<>( + defaultMaxPerRoute > 0 ? defaultMaxPerRoute : 20, + ttl, reuse, new DefaultDisposalCallback<>(), connPoolListener); + } else { + connPool = new StrictConnPool<>( + defaultMaxPerRoute > 0 ? defaultMaxPerRoute : 20, + maxTotal > 0 ? maxTotal : 50, + ttl, reuse, new DefaultDisposalCallback<>(), connPoolListener); + } + + final HttpProcessor proc = httpProcessor != null ? httpProcessor : HttpProcessors.client(); + final Http1Config h1 = http1Config != null ? http1Config : Http1Config.DEFAULT; + final CharCodingConfig coding = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT; + + final ConnectionReuseStrategy reuseStrategyCopy = connStrategy != null + ? connStrategy + : new DefaultClientConnectionReuseStrategy(); + + final ClientHttp1StreamDuplexerFactory duplexerFactory = + new ClientHttp1StreamDuplexerFactory( + proc, h1, coding, reuseStrategyCopy, null, null, streamListener); + + final TlsStrategy tls = tlsStrategy != null ? tlsStrategy : new BasicClientTlsStrategy(); + final IOEventHandlerFactory iohFactory = + new ClientHttp1IOEventHandlerFactory(duplexerFactory, tls, handshakeTimeout); + + final IOReactorMetricsListener metricsListener = reactorMetricsListener != null ? reactorMetricsListener : null; + final IOWorkerSelector selector = workerSelector != null ? workerSelector : null; + + final HttpAsyncRequester requester = new HttpAsyncRequester( + ioReactorConfig != null ? ioReactorConfig : IOReactorConfig.DEFAULT, + iohFactory, + ioSessionDecorator, + exceptionCallback != null ? exceptionCallback : WsLoggingExceptionCallback.INSTANCE, + sessionListener, + connPool, + tls, + handshakeTimeout, + metricsListener, + selector, + Math.max(maxPendingCommandsPerConnection, 0) + ); + + final H2MultiplexingRequester h2Requester = H2MultiplexingRequesterBootstrap.bootstrap() + .setIOReactorConfig(ioReactorConfig != null ? ioReactorConfig : IOReactorConfig.DEFAULT) + .setHttpProcessor(httpProcessor != null ? httpProcessor : H2Processors.client()) + .setH2Config(h2Config != null ? h2Config : H2Config.DEFAULT) + .setTlsStrategy(tls) + .setIOSessionDecorator(ioSessionDecorator) + .setExceptionCallback(exceptionCallback != null ? exceptionCallback : WsLoggingExceptionCallback.INSTANCE) + .setIOSessionListener(sessionListener) + .setIOReactorMetricsListener(metricsListener) + .create(); + + final ThreadFactory tf = threadFactory != null + ? threadFactory + : new DefaultThreadFactory("websocket-main", true); + + return new DefaultWebSocketClient( + requester, + connPool, + defaultConfig, + tf, + h2Requester + ); + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java new file mode 100644 index 0000000000..e7bcb91b7b --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java @@ -0,0 +1,81 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; + +/** + * Static factory methods for {@link CloseableWebSocketClient} instances. + * + *

This is a convenience entry point for typical client creation + * scenarios. For advanced configuration use + * {@link WebSocketClientBuilder} directly.

+ * + *

Clients created by these helpers own their I/O resources and must be + * closed when no longer needed.

+ * + * @since 5.7 + */ +public final class WebSocketClients { + + private WebSocketClients() { + } + + /** + * Creates a new {@link WebSocketClientBuilder} instance for + * custom client configuration. + * + * @return a new {@link WebSocketClientBuilder}. + */ + public static WebSocketClientBuilder custom() { + return WebSocketClientBuilder.create(); + } + + /** + * Creates a {@link CloseableWebSocketClient} instance with + * default configuration. + * + * @return a newly created {@link CloseableWebSocketClient} + * using default settings. + */ + public static CloseableWebSocketClient createDefault() { + return custom().build(); + } + + /** + * Creates a {@link CloseableWebSocketClient} instance using + * the given default WebSocket configuration. + * + * @param defaultConfig default configuration applied to + * WebSocket connections created by + * the client; must not be {@code null}. + * @return a newly created {@link CloseableWebSocketClient}. + */ + public static CloseableWebSocketClient createWith(final WebSocketClientConfig defaultConfig) { + return custom().defaultConfig(defaultConfig).build(); + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java new file mode 100644 index 0000000000..a25df57c2a --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java @@ -0,0 +1,124 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.core5.http.impl.bootstrap.AsyncRequester; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractWebSocketClient extends CloseableWebSocketClient { + + enum Status { READY, RUNNING, TERMINATED } + + private static final Logger LOG = LoggerFactory.getLogger(AbstractWebSocketClient.class); + + private final AsyncRequester primaryRequester; + private final AsyncRequester[] extraRequesters; + private final ExecutorService executorService; + private final AtomicReference status; + + AbstractWebSocketClient(final HttpAsyncRequester requester, final ThreadFactory threadFactory, final AsyncRequester... extraRequesters) { + super(); + this.primaryRequester = Args.notNull(requester, "requester"); + this.extraRequesters = extraRequesters != null ? extraRequesters : new AsyncRequester[0]; + final int threads = Math.max(1, 1 + this.extraRequesters.length); + this.executorService = Executors.newFixedThreadPool(threads, threadFactory); + this.status = new AtomicReference<>(Status.READY); + } + + @Override + public final void start() { + if (status.compareAndSet(Status.READY, Status.RUNNING)) { + executorService.execute(primaryRequester::start); + for (final AsyncRequester requester : extraRequesters) { + executorService.execute(requester::start); + } + } + } + + boolean isRunning() { + return status.get() == Status.RUNNING; + } + + @Override + public final IOReactorStatus getStatus() { + return primaryRequester.getStatus(); + } + + @Override + public final void awaitShutdown(final TimeValue waitTime) throws InterruptedException { + primaryRequester.awaitShutdown(waitTime); + for (final AsyncRequester requester : extraRequesters) { + requester.awaitShutdown(waitTime); + } + } + + @Override + public final void initiateShutdown() { + if (LOG.isDebugEnabled()) { + LOG.debug("Initiating shutdown"); + } + primaryRequester.initiateShutdown(); + for (final AsyncRequester requester : extraRequesters) { + requester.initiateShutdown(); + } + } + + void internalClose(final CloseMode closeMode) { + } + + @Override + public final void close(final CloseMode closeMode) { + if (LOG.isDebugEnabled()) { + LOG.debug("Shutdown {}", closeMode); + } + primaryRequester.initiateShutdown(); + primaryRequester.close(closeMode != null ? closeMode : CloseMode.IMMEDIATE); + for (final AsyncRequester requester : extraRequesters) { + requester.initiateShutdown(); + requester.close(closeMode != null ? closeMode : CloseMode.IMMEDIATE); + } + executorService.shutdownNow(); + internalClose(closeMode); + } + + @Override + public void close() { + close(CloseMode.GRACEFUL); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java new file mode 100644 index 0000000000..b1d94ba1d4 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java @@ -0,0 +1,53 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.util.concurrent.ThreadFactory; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.reactor.IOSession; + +@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL) +@Internal +public class DefaultWebSocketClient extends InternalWebSocketClientBase { + + public DefaultWebSocketClient( + final HttpAsyncRequester requester, + final ManagedConnPool connPool, + final WebSocketClientConfig defaultConfig, + final ThreadFactory threadFactory, + final H2MultiplexingRequester h2Requester) { + super(requester, connPool, defaultConfig, threadFactory, h2Requester); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java new file mode 100644 index 0000000000..d314ef3514 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java @@ -0,0 +1,209 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.net.URI; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ThreadFactory; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.impl.protocol.Http1UpgradeProtocol; +import org.apache.hc.client5.http.websocket.client.impl.protocol.Http2ExtendedConnectProtocol; +import org.apache.hc.client5.http.websocket.client.impl.protocol.WebSocketProtocolStrategy; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Args; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Minimal internal WS client: owns requester + pool, no extra closeables. + */ +@Internal +abstract class InternalWebSocketClientBase extends AbstractWebSocketClient { + + private static final Logger LOG = LoggerFactory.getLogger(InternalWebSocketClientBase.class); + + private final WebSocketClientConfig defaultConfig; + private final ManagedConnPool connPool; + + private final WebSocketProtocolStrategy h1; + private final WebSocketProtocolStrategy h2; + + InternalWebSocketClientBase( + final HttpAsyncRequester requester, + final ManagedConnPool connPool, + final WebSocketClientConfig defaultConfig, + final ThreadFactory threadFactory, + final H2MultiplexingRequester h2Requester) { + super(Args.notNull(requester, "requester"), threadFactory, h2Requester); + this.connPool = Args.notNull(connPool, "connPool"); + this.defaultConfig = defaultConfig != null ? defaultConfig : WebSocketClientConfig.custom().build(); + this.h1 = newH1Protocol(requester, connPool); + this.h2 = newH2Protocol(h2Requester); + } + + /** + * HTTP/1.1 Upgrade protocol. + */ + protected WebSocketProtocolStrategy newH1Protocol( + final HttpAsyncRequester requester, + final ManagedConnPool connPool) { + return new Http1UpgradeProtocol(requester, connPool); + } + + /** + * HTTP/2 Extended CONNECT protocol. + */ + protected WebSocketProtocolStrategy newH2Protocol(final H2MultiplexingRequester requester) { + return requester != null ? new Http2ExtendedConnectProtocol(requester) : null; + } + + @Override + protected CompletableFuture doConnect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfgOrNull, + final HttpContext context) { + + final WebSocketClientConfig cfg = cfgOrNull != null ? cfgOrNull : defaultConfig; + if (cfg.isHttp2Enabled() && h2 != null) { + final CompletableFuture result = new CompletableFuture<>(); + final AtomicBoolean h2Opened = new AtomicBoolean(false); + final AtomicReference suppressedError = new AtomicReference<>(); + final WebSocketListener h2Listener = new WebSocketListener() { + @Override + public void onOpen(final WebSocket webSocket) { + h2Opened.set(true); + listener.onOpen(webSocket); + } + + @Override + public void onText(final java.nio.CharBuffer data, final boolean last) { + listener.onText(data, last); + } + + @Override + public void onBinary(final java.nio.ByteBuffer data, final boolean last) { + listener.onBinary(data, last); + } + + @Override + public void onPing(final java.nio.ByteBuffer data) { + listener.onPing(data); + } + + @Override + public void onPong(final java.nio.ByteBuffer data) { + listener.onPong(data); + } + + @Override + public void onClose(final int statusCode, final String reason) { + listener.onClose(statusCode, reason); + } + + @Override + public void onError(final Throwable cause) { + if (!h2Opened.get() && !result.isDone()) { + suppressedError.compareAndSet(null, cause); + return; + } + listener.onError(cause); + } + }; + + h2.connect(uri, h2Listener, cfg, context).whenComplete((ws, ex) -> { + if (ex == null) { + result.complete(ws); + return; + } + final Throwable cause = unwrap(ex); + if (!h2Opened.get() && shouldFallbackToH1(cause)) { + if (LOG.isDebugEnabled()) { + LOG.debug("H2 WebSocket attempt failed, falling back to HTTP/1.1: {}", cause.getMessage()); + } + h1.connect(uri, listener, cfg, context).whenComplete((ws2, ex2) -> { + if (ex2 == null) { + result.complete(ws2); + } else { + result.completeExceptionally(unwrap(ex2)); + } + }); + } else { + if (!h2Opened.get()) { + final Throwable suppressed = suppressedError.get(); + if (suppressed != null) { + listener.onError(suppressed); + } + } + result.completeExceptionally(cause); + } + }); + return result; + } + return h1.connect(uri, listener, cfg, context); + } + + private static Throwable unwrap(final Throwable ex) { + if (ex instanceof CompletionException) { + return ex.getCause() != null ? ex.getCause() : ex; + } + return ex; + } + + private static boolean shouldFallbackToH1(final Throwable ex) { + if (ex instanceof CancellationException) { + return false; + } + return !(ex instanceof IllegalArgumentException); + } + + @Override + protected void internalClose(final CloseMode closeMode) { + try { + final CloseMode mode = closeMode != null ? closeMode : CloseMode.GRACEFUL; + connPool.close(mode); + } catch (final Exception ex) { + if (LOG.isWarnEnabled()) { + LOG.warn("Error closing pool: {}", ex.getMessage(), ex); + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java new file mode 100644 index 0000000000..635d81ca6e --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java @@ -0,0 +1,215 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.connector; + +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.ComplexFuture; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.nio.command.RequestExecutionCommand; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.pool.PoolEntry; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.EndpointParameters; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; + +/** + * Facade that leases an IOSession from the pool and exposes a ProtocolIOSession through AsyncClientEndpoint. + * + * @since 5.7 + */ +@Internal +public final class WebSocketEndpointConnector { + + private final HttpAsyncRequester requester; + private final ManagedConnPool connPool; + + public WebSocketEndpointConnector(final HttpAsyncRequester requester, final ManagedConnPool connPool) { + this.requester = Args.notNull(requester, "requester"); + this.connPool = Args.notNull(connPool, "connPool"); + } + + public final class ProtoEndpoint extends AsyncClientEndpoint { + + private final AtomicReference> poolEntryRef; + + ProtoEndpoint(final PoolEntry poolEntry) { + this.poolEntryRef = new AtomicReference<>(poolEntry); + } + + private PoolEntry getPoolEntryOrThrow() { + final PoolEntry pe = poolEntryRef.get(); + if (pe == null) { + throw new IllegalStateException("Endpoint has already been released"); + } + return pe; + } + + private IOSession getIOSessionOrThrow() { + final IOSession io = getPoolEntryOrThrow().getConnection(); + if (io == null) { + throw new IllegalStateException("I/O session is invalid"); + } + return io; + } + + /** + * Expose the ProtocolIOSession for protocol switching. + */ + public ProtocolIOSession getProtocolIOSession() { + final IOSession io = getIOSessionOrThrow(); + if (!(io instanceof ProtocolIOSession)) { + throw new IllegalStateException("Underlying IOSession is not a ProtocolIOSession: " + io); + } + return (ProtocolIOSession) io; + } + + @Override + public void execute(final AsyncClientExchangeHandler exchangeHandler, + final HandlerFactory pushHandlerFactory, + final HttpContext context) { + Args.notNull(exchangeHandler, "Exchange handler"); + final IOSession ioSession = getIOSessionOrThrow(); + ioSession.enqueue(new RequestExecutionCommand(exchangeHandler, pushHandlerFactory, null, context), Command.Priority.NORMAL); + if (!ioSession.isOpen()) { + try { + exchangeHandler.failed(new ConnectionClosedException()); + } finally { + exchangeHandler.releaseResources(); + } + } + } + + @Override + public boolean isConnected() { + final PoolEntry pe = poolEntryRef.get(); + final IOSession io = pe != null ? pe.getConnection() : null; + return io != null && io.isOpen(); + } + + @Override + public void releaseAndReuse() { + final PoolEntry pe = poolEntryRef.getAndSet(null); + if (pe != null) { + final IOSession io = pe.getConnection(); + connPool.release(pe, io != null && io.isOpen()); + } + } + + @Override + public void releaseAndDiscard() { + final PoolEntry pe = poolEntryRef.getAndSet(null); + if (pe != null) { + pe.discardConnection(CloseMode.GRACEFUL); + connPool.release(pe, false); + } + } + } + + public Future connect(final HttpHost host, + final Timeout timeout, + final Object attachment, + final FutureCallback callback) { + Args.notNull(host, "Host"); + Args.notNull(timeout, "Timeout"); + + final ComplexFuture resultFuture = new ComplexFuture<>(callback); + + final Future> leaseFuture = connPool.lease(host, null, timeout, + new FutureCallback>() { + @Override + public void completed(final PoolEntry poolEntry) { + final ProtoEndpoint endpoint = new ProtoEndpoint(poolEntry); + final IOSession ioSession = poolEntry.getConnection(); + if (ioSession != null && !ioSession.isOpen()) { + poolEntry.discardConnection(CloseMode.IMMEDIATE); + } + if (poolEntry.hasConnection()) { + resultFuture.completed(endpoint); + } else { + final Future future = requester.requestSession( + host, timeout, + new EndpointParameters(host, attachment), + new FutureCallback() { + @Override + public void completed(final IOSession session) { + session.setSocketTimeout(timeout); + poolEntry.assignConnection(session); + resultFuture.completed(endpoint); + } + + @Override + public void failed(final Exception cause) { + try { + resultFuture.failed(cause); + } finally { + endpoint.releaseAndDiscard(); + } + } + + @Override + public void cancelled() { + try { + resultFuture.cancel(); + } finally { + endpoint.releaseAndDiscard(); + } + } + }); + resultFuture.setDependency(future); + } + } + + @Override + public void failed(final Exception ex) { + resultFuture.failed(ex); + } + + @Override + public void cancelled() { + resultFuture.cancel(); + } + }); + + resultFuture.setDependency(leaseFuture); + return resultFuture; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java new file mode 100644 index 0000000000..11bfe7dfc2 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl.connector; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java new file mode 100644 index 0000000000..325440139c --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java @@ -0,0 +1,58 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.logging; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.function.Callback; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Internal +public class WsLoggingExceptionCallback implements Callback { + + /** + * Singleton instance of LoggingExceptionCallback. + */ + public static final WsLoggingExceptionCallback INSTANCE = new WsLoggingExceptionCallback(); + + private static final Logger LOG = LoggerFactory.getLogger("org.apache.hc.client5.http.websocket.client"); + + private WsLoggingExceptionCallback() { + } + + @Override + public void execute(final Exception ex) { + if (ex instanceof ConnectionClosedException) { + LOG.debug(ex.getMessage(), ex); + return; + } + LOG.error(ex.getMessage(), ex); + } + +} + diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java new file mode 100644 index 0000000000..47a68d46f5 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl.logging; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java new file mode 100644 index 0000000000..c569c74cc3 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Public WebSocket API for client applications. + * + *

Types in this package are stable and intended for direct use: + * {@code WebSocket}, {@code WebSocketClientConfig}, and {@code WebSocketListener}.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java new file mode 100644 index 0000000000..4ba3d57afe --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java @@ -0,0 +1,501 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; +import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.impl.connector.WebSocketEndpointConnector; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.extension.PerMessageDeflate; +import org.apache.hc.client5.http.websocket.transport.WebSocketUpgrader; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.RequestChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * HTTP/1.1 Upgrade (RFC 6455). Uses getters on WebSocketClientConfig. + */ +@Internal +public final class Http1UpgradeProtocol implements WebSocketProtocolStrategy { + + private static final Logger LOG = LoggerFactory.getLogger(Http1UpgradeProtocol.class); + private static final SecureRandom NONCE_RANDOM = new SecureRandom(); + + private final HttpAsyncRequester requester; + private final ManagedConnPool connPool; + + public Http1UpgradeProtocol(final HttpAsyncRequester requester, final ManagedConnPool connPool) { + this.requester = requester; + this.connPool = connPool; + } + + @Override + public CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + + Args.notNull(uri, "uri"); + Args.notNull(listener, "listener"); + Args.notNull(cfg, "cfg"); + + final boolean secure = "wss".equalsIgnoreCase(uri.getScheme()); + if (!secure && !"ws".equalsIgnoreCase(uri.getScheme())) { + final CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new IllegalArgumentException("Scheme must be ws or wss")); + return f; + } + + final String scheme = secure ? URIScheme.HTTPS.id : URIScheme.HTTP.id; + final int port = uri.getPort() > 0 ? uri.getPort() : secure ? 443 : 80; + final String host = Args.notBlank(uri.getHost(), "host"); + String path = uri.getRawPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } + final String fullPath = uri.getRawQuery() != null ? path + "?" + uri.getRawQuery() : path; + final HttpHost target = new HttpHost(scheme, host, port); + + final CompletableFuture result = new CompletableFuture<>(); + final WebSocketEndpointConnector wsRequester = new WebSocketEndpointConnector(requester, connPool); + + final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10); + + wsRequester.connect(target, timeout, null, + new FutureCallback() { + @Override + public void completed(final WebSocketEndpointConnector.ProtoEndpoint endpoint) { + try { + final String secKey = randomKey(); + final BasicHttpRequest req = new BasicHttpRequest(HttpGet.METHOD_NAME, target, fullPath); + + req.addHeader(HttpHeaders.CONNECTION, "Upgrade"); + req.addHeader(HttpHeaders.UPGRADE, "websocket"); + req.addHeader("Sec-WebSocket-Version", "13"); + req.addHeader("Sec-WebSocket-Key", secKey); + + // subprotocols + if (cfg.getSubprotocols() != null && !cfg.getSubprotocols().isEmpty()) { + final StringJoiner sj = new StringJoiner(", "); + for (final String p : cfg.getSubprotocols()) { + if (p != null && !p.isEmpty()) { + sj.add(p); + } + } + final String offered = sj.toString(); + if (!offered.isEmpty()) { + req.addHeader("Sec-WebSocket-Protocol", offered); + } + } + + // PMCE offer + if (cfg.isPerMessageDeflateEnabled()) { + final StringBuilder ext = new StringBuilder("permessage-deflate"); + if (cfg.isOfferServerNoContextTakeover()) { + ext.append("; server_no_context_takeover"); + } + if (cfg.isOfferClientNoContextTakeover()) { + ext.append("; client_no_context_takeover"); + } + if (cfg.getOfferClientMaxWindowBits() != null) { + ext.append("; client_max_window_bits=").append(cfg.getOfferClientMaxWindowBits()); + } + if (cfg.getOfferServerMaxWindowBits() != null) { + ext.append("; server_max_window_bits=").append(cfg.getOfferServerMaxWindowBits()); + } + req.addHeader("Sec-WebSocket-Extensions", ext.toString()); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Dispatching HTTP/1.1 Upgrade: GET {} with headers:", fullPath); + for (final Header h : req.getHeaders()) { + LOG.debug(" {}: {}", h.getName(), h.getValue()); + } + } + + final AtomicBoolean done = new AtomicBoolean(false); + + final AsyncClientExchangeHandler upgrade = new AsyncClientExchangeHandler() { + @Override + public void releaseResources() { + } + + @Override + public void failed(final Exception cause) { + if (done.compareAndSet(false, true)) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(cause); + } + } + + @Override + public void cancel() { + if (done.compareAndSet(false, true)) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.cancel(true); + } + } + + @Override + public void produceRequest(final RequestChannel ch, + final HttpContext hc) + throws IOException, HttpException { + ch.sendRequest(req, null, hc); + } + + @Override + public int available() { + return 0; + } + + @Override + public void produce(final DataStreamChannel channel) { + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) { + } + + @Override + public void consume(final ByteBuffer src) { + } + + @Override + public void streamEnd(final List trailers) { + } + + @Override + public void consumeInformation(final HttpResponse response, + final HttpContext hc) { + final int code = response.getCode(); + if (code == HttpStatus.SC_SWITCHING_PROTOCOLS && done.compareAndSet(false, true)) { + finishUpgrade(endpoint, response, secKey, listener, cfg, result); + } + } + + @Override + public void consumeResponse(final HttpResponse response, + final EntityDetails entity, + final HttpContext hc) { + final int code = response.getCode(); + if (code == HttpStatus.SC_SWITCHING_PROTOCOLS && done.compareAndSet(false, true)) { + finishUpgrade(endpoint, response, secKey, listener, cfg, result); + return; + } + failed(new IllegalStateException("Unexpected status: " + code)); + } + }; + + endpoint.execute(upgrade, null, context); + + } catch (final Exception ex) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(ex); + } + } + + @Override + public void failed(final Exception ex) { + result.completeExceptionally(ex); + } + + @Override + public void cancelled() { + result.cancel(true); + } + }); + + return result; + } + + private void finishUpgrade( + final WebSocketEndpointConnector.ProtoEndpoint endpoint, + final HttpResponse response, + final String secKey, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final CompletableFuture result) { + try { + final String accept = headerValue(response, "Sec-WebSocket-Accept"); + final String expected = expectedAccept(secKey); + final String acceptValue = accept != null ? accept.trim() : null; + if (!expected.equals(acceptValue)) { + throw new IllegalStateException("Bad Sec-WebSocket-Accept"); + } + + final String upgrade = headerValue(response, "Upgrade"); + if (upgrade == null || !"websocket".equalsIgnoreCase(upgrade.trim())) { + throw new IllegalStateException("Missing/invalid Upgrade header: " + upgrade); + } + if (!containsToken(response, "Connection", "Upgrade")) { + throw new IllegalStateException("Missing/invalid Connection header"); + } + + final String proto = headerValue(response, "Sec-WebSocket-Protocol"); + if (proto != null && !proto.isEmpty()) { + boolean matched = false; + if (cfg.getSubprotocols() != null) { + for (final String p : cfg.getSubprotocols()) { + if (p.equals(proto)) { + matched = true; + break; + } + } + } + if (!matched) { + throw new IllegalStateException("Server selected subprotocol not offered: " + proto); + } + } + + final ExtensionChain chain = buildExtensionChain(cfg, headerValue(response, "Sec-WebSocket-Extensions")); + + final ProtocolIOSession ioSession = endpoint.getProtocolIOSession(); + final WebSocketUpgrader upgrader = new WebSocketUpgrader(listener, cfg, chain, endpoint); + ioSession.registerProtocol("websocket", upgrader); + ioSession.switchProtocol("websocket", new FutureCallback() { + @Override + public void completed(final ProtocolIOSession s) { + s.setSocketTimeout(Timeout.DISABLED); + final WebSocket ws = upgrader.getWebSocket(); + try { + listener.onOpen(ws); + } catch (final Throwable ignore) { + } + result.complete(ws); + } + + @Override + public void failed(final Exception ex) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(ex); + } + + @Override + public void cancelled() { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.cancel(true); + } + }); + + } catch (final Exception ex) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + } + result.completeExceptionally(ex); + } + } + + private static String headerValue(final HttpResponse r, final String name) { + final Header h = r.getFirstHeader(name); + return h != null ? h.getValue() : null; + } + + private static boolean containsToken(final HttpResponse r, final String header, final String token) { + for (final Header h : r.getHeaders(header)) { + for (final String p : h.getValue().split(",")) { + if (p.trim().equalsIgnoreCase(token)) { + return true; + } + } + } + return false; + } + + private static String randomKey() { + final byte[] nonce = new byte[16]; + NONCE_RANDOM.nextBytes(nonce); + return Base64.getEncoder().encodeToString(nonce); + } + + private static String expectedAccept(final String key) throws Exception { + final MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII)); + return Base64.getEncoder().encodeToString(sha1.digest()); + } + + static ExtensionChain buildExtensionChain(final WebSocketClientConfig cfg, final String ext) { + final ExtensionChain chain = new ExtensionChain(); + if (ext == null || ext.isEmpty()) { + return chain; + } + boolean pmceSeen = false, serverNoCtx = false, clientNoCtx = false; + Integer clientBits = null, serverBits = null; + final boolean offerServerNoCtx = cfg.isOfferServerNoContextTakeover(); + final boolean offerClientNoCtx = cfg.isOfferClientNoContextTakeover(); + final Integer offerClientBits = cfg.getOfferClientMaxWindowBits(); + final Integer offerServerBits = cfg.getOfferServerMaxWindowBits(); + + final String[] tokens = ext.split(","); + for (final String raw0 : tokens) { + final String raw = raw0.trim(); + final String[] parts = raw.split(";"); + final String token = parts[0].trim().toLowerCase(); + + // Only permessage-deflate is supported + if (!"permessage-deflate".equals(token)) { + throw new IllegalStateException("Server selected unsupported extension: " + token); + } + if (pmceSeen) { + throw new IllegalStateException("Server selected permessage-deflate more than once"); + } + pmceSeen = true; + + for (int i = 1; i < parts.length; i++) { + final String p = parts[i].trim(); + final int eq = p.indexOf('='); + if (eq < 0) { + if ("server_no_context_takeover".equalsIgnoreCase(p)) { + if (!offerServerNoCtx) { + throw new IllegalStateException("Server selected server_no_context_takeover not offered"); + } + serverNoCtx = true; + } else if ("client_no_context_takeover".equalsIgnoreCase(p)) { + if (!offerClientNoCtx) { + throw new IllegalStateException("Server selected client_no_context_takeover not offered"); + } + clientNoCtx = true; + } else { + throw new IllegalStateException("Unsupported permessage-deflate parameter: " + p); + } + } else { + final String k = p.substring(0, eq).trim(); + String v = p.substring(eq + 1).trim(); + if (v.length() >= 2 && v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"') { + v = v.substring(1, v.length() - 1); // strip quotes if any + } + if ("client_max_window_bits".equalsIgnoreCase(k)) { + if (offerClientBits == null) { + throw new IllegalStateException("Server selected client_max_window_bits not offered"); + } + try { + if (v.isEmpty()) { + throw new IllegalStateException("client_max_window_bits must have a value"); + } + clientBits = Integer.parseInt(v); + if (clientBits < 8 || clientBits > 15) { + throw new IllegalStateException("client_max_window_bits out of range: " + clientBits); + } + } catch (final NumberFormatException nfe) { + throw new IllegalStateException("Invalid client_max_window_bits: " + v, nfe); + } + } else if ("server_max_window_bits".equalsIgnoreCase(k)) { + if (offerServerBits == null) { + throw new IllegalStateException("Server selected server_max_window_bits not offered"); + } + try { + if (v.isEmpty()) { + throw new IllegalStateException("server_max_window_bits must have a value"); + } + serverBits = Integer.parseInt(v); + if (serverBits < 8 || serverBits > 15) { + throw new IllegalStateException("server_max_window_bits out of range: " + serverBits); + } + } catch (final NumberFormatException nfe) { + throw new IllegalStateException("Invalid server_max_window_bits: " + v, nfe); + } + } else { + throw new IllegalStateException("Unsupported permessage-deflate parameter: " + k); + } + } + } + } + + if (pmceSeen) { + if (!cfg.isPerMessageDeflateEnabled()) { + throw new IllegalStateException("Server negotiated PMCE but client disabled it"); + } + if (clientBits != null) { + if (offerClientBits == null) { + throw new IllegalStateException("Server selected client_max_window_bits not offered"); + } + if (!clientBits.equals(offerClientBits)) { + throw new IllegalStateException("Unsupported client_max_window_bits: " + clientBits + + " (offered " + offerClientBits + ")"); + } + } + if (serverBits != null) { + if (offerServerBits != null && serverBits > offerServerBits) { + throw new IllegalStateException("server_max_window_bits exceeds offer: " + serverBits); + } + } + chain.add(new PerMessageDeflate(true, serverNoCtx, clientNoCtx, clientBits, serverBits)); + } + return chain; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java new file mode 100644 index 0000000000..6b6504622d --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java @@ -0,0 +1,894 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.transport.WebSocketFrameDecoder; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.DefaultThreadFactory; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.ProtocolException; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.BasicEntityDetails; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.RequestChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.extension.WebSocketExtensionChain; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter; +import org.apache.hc.core5.websocket.message.CloseCodec; + +/** + * RFC 8441 (HTTP/2 Extended CONNECT) placeholder. + */ +@Internal +public final class Http2ExtendedConnectProtocol implements WebSocketProtocolStrategy { + + private static final ScheduledExecutorService CLOSE_TIMER = + Executors.newSingleThreadScheduledExecutor(new DefaultThreadFactory("ws-h2-close", true)); + + public static final class H2NotAvailable extends RuntimeException { + public H2NotAvailable(final String msg) { + super(msg); + } + } + + private final H2MultiplexingRequester requester; + + public Http2ExtendedConnectProtocol(final H2MultiplexingRequester requester) { + this.requester = requester; + } + + @Override + public CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + + final CompletableFuture f = new CompletableFuture<>(); + + if (requester == null) { + f.completeExceptionally(new H2NotAvailable("HTTP/2 requester not configured")); + return f; + } + Args.notNull(uri, "uri"); + Args.notNull(listener, "listener"); + Args.notNull(cfg, "cfg"); + + final boolean secure = "wss".equalsIgnoreCase(uri.getScheme()); + if (!secure && !"ws".equalsIgnoreCase(uri.getScheme())) { + f.completeExceptionally(new IllegalArgumentException("Scheme must be ws or wss")); + return f; + } + + final String scheme = secure ? URIScheme.HTTPS.id : URIScheme.HTTP.id; + final int port = uri.getPort() > 0 ? uri.getPort() : (secure ? 443 : 80); + + final String host = Args.notBlank(uri.getHost(), "host"); + String path = uri.getRawPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } + final String fullPath = uri.getRawQuery() != null ? path + "?" + uri.getRawQuery() : path; + + final HttpHost target = new HttpHost(scheme, host, port); + + final BasicHttpRequest req = new BasicHttpRequest(Method.CONNECT.name(), target, fullPath); + req.addHeader(WebSocketConstants.PSEUDO_PROTOCOL, "websocket"); + req.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION_LOWER, "13"); + + if (cfg.getSubprotocols() != null && !cfg.getSubprotocols().isEmpty()) { + final StringBuilder sb = new StringBuilder(); + for (final String p : cfg.getSubprotocols()) { + if (p != null && !p.isEmpty()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(p); + } + } + if (sb.length() > 0) { + req.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER, sb.toString()); + } + } + + if (cfg.isPerMessageDeflateEnabled()) { + final StringBuilder ext = new StringBuilder("permessage-deflate"); + if (cfg.isOfferServerNoContextTakeover()) { + ext.append("; server_no_context_takeover"); + } + if (cfg.isOfferClientNoContextTakeover()) { + ext.append("; client_no_context_takeover"); + } + // Your implementation supports only 15 safely. + if (cfg.getOfferClientMaxWindowBits() != null && cfg.getOfferClientMaxWindowBits() == 15) { + ext.append("; client_max_window_bits=15"); + } + if (cfg.getOfferServerMaxWindowBits() != null) { + ext.append("; server_max_window_bits=").append(cfg.getOfferServerMaxWindowBits()); + } + req.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER, ext.toString()); + } + + final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10); + requester.execute(target, new H2WebSocketExchangeHandler(req, listener, cfg, f), null, timeout, context); + return f; + } + + private static final class H2WebSocketExchangeHandler implements AsyncClientExchangeHandler { + + private final BasicHttpRequest request; + private final WebSocketListener listener; + private final WebSocketClientConfig cfg; + private final CompletableFuture future; + + private final H2WebSocket webSocket; + private final WebSocketFrameWriter writer; + private WebSocketFrameDecoder decoder; + + private ByteBuffer inbuf = ByteBuffer.allocate(8192); + private final AtomicBoolean open = new AtomicBoolean(true); + private final AtomicBoolean outputPrimed = new AtomicBoolean(false); + + private ExtensionChain.EncodeChain encChain; + private ExtensionChain.DecodeChain decChain; + + private int assemblingOpcode = -1; + private boolean assemblingCompressed; + private ByteArrayOutputStream assemblingBytes; + private final AtomicLong assemblingBytesSize = new AtomicLong(); + + private volatile DataStreamChannel dataChannel; + + H2WebSocketExchangeHandler( + final BasicHttpRequest request, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final CompletableFuture future) { + this.request = request; + this.listener = listener; + this.cfg = cfg; + this.future = future; + this.writer = new WebSocketFrameWriter(); + this.webSocket = new H2WebSocket(); + } + + @Override + public void produceRequest(final RequestChannel channel, final HttpContext context) throws HttpException, IOException { + channel.sendRequest(request, new BasicEntityDetails(-1, null), context); + } + + @Override + public void consumeResponse(final HttpResponse response, final EntityDetails entityDetails, final HttpContext context) + throws HttpException, IOException { + + if (response.getCode() != HttpStatus.SC_OK) { + failFuture(new IllegalStateException("Unexpected status: " + response.getCode())); + return; + } + + try { + final String selectedProto = headerValue(response, WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER); + if (selectedProto != null && !selectedProto.isEmpty()) { + final List offered = cfg.getSubprotocols(); + if (offered == null || !offered.contains(selectedProto)) { + throw new ProtocolException("Server selected unsupported subprotocol: " + selectedProto); + } + } + + final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain( + cfg, headerValue(response, WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER)); + this.encChain = chain.isEmpty() ? null : chain.newEncodeChain(); + this.decChain = chain.isEmpty() ? null : chain.newDecodeChain(); + this.decoder = new WebSocketFrameDecoder(cfg.getMaxFrameSize(), chain.isEmpty(), false); + + if (!future.isDone()) { + future.complete(webSocket); + } + listener.onOpen(webSocket); + } catch (final Exception ex) { + failFuture(ex); + } + } + + @Override + public void consumeInformation(final HttpResponse response, final HttpContext context) throws HttpException, IOException { + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(final ByteBuffer src) throws IOException { + if (!open.get() || decoder == null) { + return; + } + appendToInbuf(src); + inbuf.flip(); + for (; ; ) { + final boolean has; + try { + has = decoder.decode(inbuf); + } catch (final RuntimeException ex) { + listener.onError(ex); + failFuture(ex); + open.set(false); + return; + } + if (!has) { + break; + } + handleFrame(); + if (!open.get()) { + break; + } + } + inbuf.compact(); + } + + @Override + public void streamEnd(final List trailers) throws HttpException, IOException { + open.set(false); + if (!future.isDone()) { + failFuture(new ProtocolException("Stream ended before handshake completed")); + } else if (!webSocket.isCloseReceived()) { + listener.onClose(1006, ""); + } + } + + @Override + public int available() { + if (dataChannel == null && outputPrimed.compareAndSet(false, true)) { + return 1; + } + return webSocket.endStreamPending() ? 1 : webSocket.available(); + } + + @Override + public void produce(final DataStreamChannel channel) throws IOException { + this.dataChannel = channel; + webSocket.produce(channel); + } + + @Override + public void failed(final Exception cause) { + listener.onError(cause); + failFuture(cause); + open.set(false); + } + + @Override + public void releaseResources() { + if (!future.isDone()) { + failFuture(new ProtocolException("WebSocket exchange released")); + } + open.set(false); + } + + @Override + public void cancel() { + if (!future.isDone()) { + failFuture(new ProtocolException("WebSocket exchange cancelled")); + } + open.set(false); + } + + private void handleFrame() { + final int op = decoder.opcode(); + final boolean fin = decoder.fin(); + final boolean r1 = decoder.rsv1(); + final boolean r2 = decoder.rsv2(); + final boolean r3 = decoder.rsv3(); + final ByteBuffer payload = decoder.payload(); + + if (r2 || r3) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "RSV2/RSV3 not supported"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (r1 && decChain == null) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "RSV1 without negotiated extension"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (FrameOpcode.isControl(op)) { + if (!fin) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "fragmented control frame"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (payload.remaining() > 125) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "control frame too large"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + } + + switch (op) { + case FrameOpcode.PING: + listener.onPing(payload.asReadOnlyBuffer()); + if (cfg.isAutoPong()) { + webSocket.pong(payload.asReadOnlyBuffer()); + } + break; + case FrameOpcode.PONG: + listener.onPong(payload.asReadOnlyBuffer()); + break; + case FrameOpcode.CLOSE: + int code = 1005; + String reason = ""; + if (payload.remaining() == 1) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Invalid close payload length"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } else if (payload.remaining() >= 2) { + final ByteBuffer dup = payload.slice(); + code = CloseCodec.readCloseCode(dup); + if (!CloseCodec.isValidToReceive(code)) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Invalid close code"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (dup.hasRemaining()) { + try { + reason = decodeTextStrict(dup.asReadOnlyBuffer()); + } catch (final WebSocketProtocolException ex) { + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + } + } + listener.onClose(code, reason); + if (!webSocket.isCloseSent()) { + final int replyCode = code == 1005 ? 1000 : code; + final String replyReason = code == 1005 ? "" : reason; + webSocket.sendCloseIfNeeded(replyCode, replyReason); + } + webSocket.onCloseReceived(); + break; + + case FrameOpcode.TEXT: + case FrameOpcode.BINARY: + if (assemblingOpcode != -1) { + final WebSocketProtocolException ex = + new WebSocketProtocolException(1002, "New data frame while fragmented message in progress"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (isTooLarge(payload.remaining())) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1009, "Message too big"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (!fin) { + assemblingOpcode = op; + assemblingCompressed = r1 && decChain != null; + assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining())); + assemblingBytesSize.set(0); + try { + appendPayload(payload); + } catch (final WebSocketProtocolException ex) { + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + } + return; + } + deliverSingle(op, payload, r1); + break; + + case FrameOpcode.CONT: + if (assemblingOpcode == -1) { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Unexpected continuation frame"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + try { + appendPayload(payload); + } catch (final WebSocketProtocolException ex) { + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + if (fin) { + final ByteBuffer full = ByteBuffer.wrap(assemblingBytes.toByteArray()); + final int opcode = assemblingOpcode; + final boolean compressed = assemblingCompressed; + assemblingOpcode = -1; + assemblingCompressed = false; + assemblingBytes = null; + assemblingBytesSize.set(0); + deliverSingle(opcode, full, compressed); + } + break; + + default: + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Unsupported opcode: " + op); + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + } + } + + private void deliverSingle(final int opcode, final ByteBuffer payload, final boolean rsv1) { + ByteBuffer data = payload.asReadOnlyBuffer(); + if (rsv1 && decChain != null) { + try { + final byte[] decoded = decChain.decode(toBytes(data)); + if (isTooLarge(decoded.length)) { + throw new WebSocketProtocolException(1009, "Message too big"); + } + data = ByteBuffer.wrap(decoded); + } catch (final Exception ex) { + // Treat inflate failures as protocol errors. + final WebSocketProtocolException wex = ex instanceof WebSocketProtocolException + ? (WebSocketProtocolException) ex + : new WebSocketProtocolException(1002, "Bad compressed payload"); + listener.onError(ex); + webSocket.sendCloseIfNeeded(wex.closeCode, wex.getMessage()); + failFuture(ex); + open.set(false); + return; + } + } + if (opcode == FrameOpcode.TEXT) { + try { + listener.onText(CharBuffer.wrap(decodeTextStrict(data)), true); + } catch (final WebSocketProtocolException ex) { + listener.onError(ex); + webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage()); + failFuture(ex); + open.set(false); + } + } else { + listener.onBinary(data, true); + } + } + + private void appendPayload(final ByteBuffer payload) { + if (assemblingBytes == null) { + assemblingBytes = new ByteArrayOutputStream(); + } + if (isTooLarge(assemblingBytesSize.get() + payload.remaining())) { + throw new WebSocketProtocolException(1009, "Message too big"); + } + final byte[] tmp = toBytes(payload); + assemblingBytes.write(tmp, 0, tmp.length); + assemblingBytesSize.addAndGet(tmp.length); + } + + private void appendToInbuf(final ByteBuffer src) { + if (src == null || !src.hasRemaining()) { + return; + } + if (inbuf.remaining() < src.remaining()) { + final int need = inbuf.position() + src.remaining(); + final int newCap = Math.max(inbuf.capacity() * 2, need); + final ByteBuffer bigger = ByteBuffer.allocate(newCap); + inbuf.flip(); + bigger.put(inbuf); + inbuf = bigger; + } + inbuf.put(src); + } + + private byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer b = buf.asReadOnlyBuffer(); + final byte[] out = new byte[b.remaining()]; + b.get(out); + return out; + } + + private static String headerValue(final HttpResponse r, final String name) { + final Header h = r.getFirstHeader(name); + return h != null ? h.getValue() : null; + } + + private static String decodeTextStrict(final ByteBuffer payload) throws WebSocketProtocolException { + try { + final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + return decoder.decode(payload.asReadOnlyBuffer()).toString(); + } catch (final CharacterCodingException ex) { + throw new WebSocketProtocolException(1007, "Invalid UTF-8 payload"); + } + } + + private void failFuture(final Exception ex) { + if (!future.isDone()) { + future.completeExceptionally(ex); + } + } + + private boolean isTooLarge(final long size) { + final long max = cfg.getMaxMessageSize(); + return max > 0 && size > max; + } + + private final class H2WebSocket implements WebSocket { + + private final ArrayDeque queue = new ArrayDeque<>(); + private int queuedBytes; + private final ReentrantLock queueLock = new ReentrantLock(); + private final ReentrantLock sendLock = new ReentrantLock(); + private int outOpcode = -1; + private final int outChunk = Math.max(256, cfg.getOutgoingChunkSize()); + + private final AtomicBoolean closeSent = new AtomicBoolean(false); + private volatile boolean endStreamAfterClose; + private volatile boolean closeReceived; + private volatile ScheduledFuture closeTimeoutFuture; + + @Override + public boolean isOpen() { + return open.get(); + } + + @Override + public boolean ping(final ByteBuffer data) { + return enqueue(writer.ping(data), false); + } + + @Override + public boolean pong(final ByteBuffer data) { + return enqueue(writer.pong(data), false); + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + if (closeSent.get()) { + return false; + } + final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(data.toString()); + return sendData(FrameOpcode.TEXT, utf8, finalFragment); + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + if (closeSent.get()) { + return false; + } + return sendData(FrameOpcode.BINARY, data, finalFragment); + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + if (!CloseCodec.isValidToSend(statusCode)) { + throw new IllegalArgumentException("Invalid close code: " + statusCode); + } + final CompletableFuture cf = new CompletableFuture<>(); + if (!open.get()) { + cf.completeExceptionally(new IllegalStateException("WebSocket is closed")); + return cf; + } + final boolean ok = sendCloseIfNeeded(statusCode, reason); + if (ok) { + cf.complete(null); + } else { + cf.completeExceptionally(new IllegalStateException("Close could not be initiated")); + } + return cf; + } + + @Override + public boolean sendTextBatch(final List fragments, final boolean finalFragment) { + if (fragments == null || fragments.isEmpty()) { + throw new IllegalArgumentException("fragments must not be empty"); + } + final StringBuilder sb = new StringBuilder(); + for (final CharSequence s : fragments) { + if (s != null) { + sb.append(s); + } + } + return sendText(sb, finalFragment); + } + + @Override + public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) { + if (fragments == null || fragments.isEmpty()) { + throw new IllegalArgumentException("fragments must not be empty"); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (final ByteBuffer b : fragments) { + if (b != null) { + final byte[] bytes = toBytes(b); + out.write(bytes, 0, bytes.length); + } + } + return sendBinary(ByteBuffer.wrap(out.toByteArray()), finalFragment); + } + + private boolean sendData(final int opcode, final ByteBuffer data, final boolean fin) { + sendLock.lock(); + try { + if (!open.get()) { + return false; + } + int currentOpcode = outOpcode == -1 ? opcode : FrameOpcode.CONT; + if (outOpcode == -1) { + outOpcode = opcode; + } + final ByteBuffer ro = data.asReadOnlyBuffer(); + boolean ok = true; + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + while (ro.hasRemaining()) { + final int n = Math.min(ro.remaining(), outChunk); + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + + final boolean lastSlice = !ro.hasRemaining() && fin; + if (!enqueueDataFrame(currentOpcode, slice, lastSlice, firstFragment)) { + ok = false; + break; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + + if (fin || !ok) { + outOpcode = -1; + } + return ok; + } finally { + sendLock.unlock(); + } + } + + private boolean enqueueDataFrame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean firstFragment) { + if (!open.get()) { + return false; + } + byte[] out = toBytes(payload); + int rsv = 0; + if (encChain != null) { + final WebSocketExtensionChain.Encoded encRes = encChain.encode(out, firstFragment, fin); + out = encRes.payload; + if (encRes.setRsvOnFirst && firstFragment) { + rsv = FrameHeaderBits.RSV1; + } + } + final ByteBuffer frame = writer.frameWithRSV(opcode, ByteBuffer.wrap(out), fin, true, rsv); + return enqueue(frame, false); + } + + private boolean enqueue(final ByteBuffer frame, final boolean closeAfter) { + if (!open.get()) { + return false; + } + queueLock.lock(); + try { + if (closeAfter) { + queue.clear(); + queuedBytes = 0; + } else { + final long maxBytes = cfg.getMaxOutboundDataBytes() > 0 + ? cfg.getMaxOutboundDataBytes() + : (long) cfg.getMaxOutboundControlQueue() * (long) cfg.getMaxFrameSize(); + if (maxBytes > 0 && (long) queuedBytes + (long) frame.remaining() > maxBytes) { + return false; + } + } + queue.add(frame); + queuedBytes += frame.remaining(); + } finally { + queueLock.unlock(); + } + if (closeAfter) { + closeSent.set(true); + scheduleCloseTimeout(); + } + final DataStreamChannel ch = dataChannel; + if (ch != null) { + ch.requestOutput(); + } + return true; + } + + int available() { + queueLock.lock(); + try { + return queuedBytes; + } finally { + queueLock.unlock(); + } + } + + boolean endStreamPending() { + return endStreamAfterClose; + } + + void produce(final DataStreamChannel channel) throws IOException { + for (; ; ) { + final ByteBuffer buf; + queueLock.lock(); + try { + buf = queue.peek(); + } finally { + queueLock.unlock(); + } + if (buf == null) { + if (endStreamAfterClose) { + endStreamAfterClose = false; + channel.endStream(null); + } + return; + } + final int n = channel.write(buf); + if (n == 0) { + channel.requestOutput(); + return; + } + queueLock.lock(); + try { + queuedBytes = Math.max(0, queuedBytes - n); + if (!buf.hasRemaining()) { + queue.poll(); + } else { + channel.requestOutput(); + return; + } + } finally { + queueLock.unlock(); + } + } + } + + boolean isCloseSent() { + return closeSent.get(); + } + + boolean isCloseReceived() { + return closeReceived; + } + + boolean sendCloseIfNeeded(final int code, final String reason) { + if (closeSent.get()) { + return true; + } + return enqueue(writer.close(code, reason), true); + } + + void onCloseReceived() { + closeReceived = true; + open.set(false); + cancelCloseTimeout(); + endStreamAfterClose = true; + final DataStreamChannel ch = dataChannel; + if (ch != null) { + ch.requestOutput(); + } + } + + private void scheduleCloseTimeout() { + final Timeout t = cfg.getCloseWaitTimeout(); + if (t == null || t.isDisabled()) { + return; + } + if (closeTimeoutFuture != null) { + closeTimeoutFuture.cancel(false); + } + closeTimeoutFuture = CLOSE_TIMER.schedule(() -> { + open.set(false); + endStreamAfterClose = true; + final DataStreamChannel ch = dataChannel; + if (ch != null) { + ch.requestOutput(); + } + }, t.toMilliseconds(), TimeUnit.MILLISECONDS); + } + + private void cancelCloseTimeout() { + if (closeTimeoutFuture != null) { + closeTimeoutFuture.cancel(false); + closeTimeoutFuture = null; + } + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java new file mode 100644 index 0000000000..deebeddec1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java @@ -0,0 +1,59 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Minimal pluggable protocol strategy. One impl for H1 (RFC6455), + * one for H2 Extended CONNECT (RFC8441). + */ +@Internal +public interface WebSocketProtocolStrategy { + + /** + * Establish a WebSocket connection using a specific HTTP transport/protocol. + * + * @param uri ws:// or wss:// target + * @param listener user listener for WS events + * @param cfg client config (timeouts, subprotocols, PMCE offer, etc.) + * @param context optional HttpContext (may be {@code null}) + * @return future completing with a connected {@link WebSocket} or exceptionally on failure + */ + CompletableFuture connect( + URI uri, + WebSocketListener listener, + WebSocketClientConfig cfg, + HttpContext context); +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java new file mode 100644 index 0000000000..5820f61daa --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java new file mode 100644 index 0000000000..e650658364 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * High-level asynchronous WebSocket client. + * + *

Provides {@code WebSocketClient}, which performs the HTTP/1.1 upgrade + * (RFC 6455) and exposes an application-level {@code WebSocket}.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.client; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java new file mode 100644 index 0000000000..f7b59da2f1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Client-side WebSocket support built on top of Apache HttpClient. + * + *

This package provides the public API for establishing and using + * WebSocket connections according to RFC 6455. WebSocket sessions + * are created by upgrading an HTTP request and are backed internally + * by the non-blocking I/O reactor used by the HttpClient async APIs.

+ * + *

Core abstractions

+ *
    + *
  • {@link org.apache.hc.client5.http.websocket.api.WebSocket WebSocket} – + * application view of a single WebSocket connection, used to send + * text and binary messages and initiate the close handshake.
  • + *
  • {@link org.apache.hc.client5.http.websocket.api.WebSocketListener WebSocketListener} – + * callback interface that receives inbound messages, pings, pongs, + * errors, and close notifications.
  • + *
  • {@link org.apache.hc.client5.http.websocket.api.WebSocketClientConfig WebSocketClientConfig} – + * immutable configuration for timeouts, maximum frame and message + * sizes, auto-pong behaviour, and buffer management.
  • + *
  • {@link org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient CloseableWebSocketClient} – + * high-level client for establishing WebSocket connections.
  • + *
  • {@link org.apache.hc.client5.http.websocket.client.WebSocketClients WebSocketClients} and + * {@link org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder WebSocketClientBuilder} – + * factory and builder for creating and configuring WebSocket clients.
  • + *
+ * + *

Threading model

+ *

Outbound operations on {@code WebSocket} are thread-safe and may be + * invoked from arbitrary application threads. Inbound callbacks on + * {@code WebSocketListener} are normally executed on I/O dispatcher + * threads; listeners should avoid long blocking operations.

+ * + *

Close handshake

+ *

The implementation follows the close handshake defined in RFC 6455. + * Applications should initiate shutdown via + * {@link org.apache.hc.client5.http.websocket.api.WebSocket#close(int, String)} + * and treat receipt of a close frame as a terminal event. The configured + * {@code closeWaitTimeout} controls how long the client will wait for the + * peer's close frame before the underlying connection is closed.

+ * + *

Classes in {@code org.apache.hc.core5.websocket} subpackages and + * {@code org.apache.hc.client5.http.websocket.transport} are internal + * implementation details and are not intended for direct use.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java new file mode 100644 index 0000000000..d552bad1ef --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java @@ -0,0 +1,172 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.annotation.Internal; + +@Internal +public final class WebSocketFrameDecoder { + private final int maxFrameSize; + private final boolean strictNoExtensions; + + private int opcode; + private boolean fin; + private boolean rsv1, rsv2, rsv3; + private ByteBuffer payload = ByteBuffer.allocate(0); + private final boolean expectMasked; + + + + public WebSocketFrameDecoder(final int maxFrameSize, final boolean strictNoExtensions) { + this(maxFrameSize, strictNoExtensions, false); + } + + public WebSocketFrameDecoder(final int maxFrameSize) { + this(maxFrameSize, true, false); + } + + public WebSocketFrameDecoder(final int maxFrameSize, + final boolean strictNoExtensions, + final boolean expectMasked) { + this.maxFrameSize = maxFrameSize; + this.strictNoExtensions = strictNoExtensions; + this.expectMasked = expectMasked; + } + + public boolean decode(final ByteBuffer in) { + in.mark(); + if (in.remaining() < 2) { + in.reset(); + return false; + } + + final int b0 = in.get() & 0xFF; + final int b1 = in.get() & 0xFF; + + fin = (b0 & 0x80) != 0; + rsv1 = (b0 & 0x40) != 0; + rsv2 = (b0 & 0x20) != 0; + rsv3 = (b0 & 0x10) != 0; + + if (strictNoExtensions && (rsv1 || rsv2 || rsv3)) { + throw new WebSocketProtocolException(1002, "RSV bits set without extension"); + } + + opcode = b0 & 0x0F; + + if (opcode != 0 && opcode != 1 && opcode != 2 && opcode != 8 && opcode != 9 && opcode != 10) { + throw new WebSocketProtocolException(1002, "Reserved/unknown opcode: " + opcode); + } + + final boolean masked = (b1 & 0x80) != 0; + long len = b1 & 0x7F; + + // Mode-aware masking rule + if (masked != expectMasked) { + if (expectMasked) { + // server decoding client frames: clients MUST mask + throw new WebSocketProtocolException(1002, "Client frame is not masked"); + } else { + // client decoding server frames: servers MUST NOT mask + throw new WebSocketProtocolException(1002, "Server frame is masked"); + } + } + + if (len == 126) { + if (in.remaining() < 2) { + in.reset(); + return false; + } + len = in.getShort() & 0xFFFF; + } else if (len == 127) { + if (in.remaining() < 8) { + in.reset(); + return false; + } + final long l = in.getLong(); + if (l < 0) { + throw new WebSocketProtocolException(1002, "Negative length"); + } + len = l; + } + + if (FrameOpcode.isControl(opcode)) { + if (!fin) { + throw new WebSocketProtocolException(1002, "fragmented control frame"); + } + if (len > 125) { + throw new WebSocketProtocolException(1002, "control frame too large"); + } + // (RSV checks above already cover RSV!=0) + } + + if (len > Integer.MAX_VALUE || maxFrameSize > 0 && len > maxFrameSize) { + throw new WebSocketProtocolException(1009, "Frame too large: " + len); + } + + if (in.remaining() < len) { + in.reset(); + return false; + } + + final ByteBuffer data = ByteBuffer.allocate((int) len); + for (int i = 0; i < len; i++) { + data.put(in.get()); + } + data.flip(); + payload = data.asReadOnlyBuffer(); + return true; + } + + public int opcode() { + return opcode; + } + + public boolean fin() { + return fin; + } + + public boolean rsv1() { + return rsv1; + } + + public boolean rsv2() { + return rsv2; + } + + public boolean rsv3() { + return rsv3; + } + + public ByteBuffer payload() { + return payload.asReadOnlyBuffer(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java new file mode 100644 index 0000000000..849d3d7494 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java @@ -0,0 +1,440 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeoutException; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.message.CloseCodec; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Timeout; + +/** + * Inbound path: decoding, validation, fragment assembly, close handshake. + */ +@Internal +final class WebSocketInbound { + + private final WebSocketSessionState s; + private final WebSocketOutbound out; + + WebSocketInbound(final WebSocketSessionState state, final WebSocketOutbound outbound) { + this.s = state; + this.out = outbound; + } + + // ---- lifecycle ---- + void onConnected(final IOSession ioSession) { + ioSession.setSocketTimeout(Timeout.DISABLED); + ioSession.setEventMask(EventMask.READ | EventMask.WRITE); + } + + void onTimeout(final IOSession ioSession, final Timeout timeout) { + try { + final String msg = "I/O timeout: " + (timeout != null ? timeout : Timeout.ZERO_MILLISECONDS); + s.listener.onError(new TimeoutException(msg)); + } catch (final Throwable ignore) { + } + } + + void onException(final IOSession ioSession, final Exception cause) { + try { + s.listener.onError(cause); + } catch (final Throwable ignore) { + } + } + + void onDisconnected(final IOSession ioSession) { + if (s.open.getAndSet(false)) { + try { + s.listener.onClose(1006, "abnormal closure"); + } catch (final Throwable ignore) { + } + } + out.drainAndRelease(); + ioSession.clearEvent(EventMask.READ | EventMask.WRITE); + } + + void onInputReady(final IOSession ioSession, final ByteBuffer src) { + try { + if (!s.open.get() && !s.closeSent.get()) { + return; + } + + final int size = Math.max(8192, s.outChunk); + + ByteBuffer rb = s.readBuf; + if (rb == null) { + rb = s.cfg.isDirectBuffers() + ? ByteBuffer.allocateDirect(size) + : ByteBuffer.allocate(size); + s.readBuf = rb; + } + + if (src != null && src.hasRemaining()) { + appendToInbuf(src); + } + + int n; + do { + rb.clear(); + n = ioSession.read(rb); + if (n > 0) { + rb.flip(); + appendToInbuf(rb); + } + } while (n > 0); + + if (n < 0) { + onDisconnected(ioSession); + return; + } + + s.inbuf.flip(); + for (; ; ) { + final boolean has; + try { + has = s.decoder.decode(s.inbuf); + } catch (final RuntimeException rte) { + final int code = rte instanceof WebSocketProtocolException + ? ((WebSocketProtocolException) rte).closeCode + : 1002; + initiateCloseAndWait(ioSession, code, rte.getMessage()); + s.inbuf.clear(); + return; + } + if (!has) { + break; + } + + final int op = s.decoder.opcode(); + final boolean fin = s.decoder.fin(); + final boolean r1 = s.decoder.rsv1(); + final boolean r2 = s.decoder.rsv2(); + final boolean r3 = s.decoder.rsv3(); + final ByteBuffer payload = s.decoder.payload(); + + if (r2 || r3) { + initiateCloseAndWait(ioSession, 1002, "RSV2/RSV3 not supported"); + s.inbuf.clear(); + return; + } + if (r1 && s.decChain == null) { + initiateCloseAndWait(ioSession, 1002, "RSV1 without negotiated extension"); + s.inbuf.clear(); + return; + } + + if (s.closeSent.get() && op != FrameOpcode.CLOSE) { + continue; + } + + if (FrameOpcode.isControl(op)) { + if (!fin) { + initiateCloseAndWait(ioSession, 1002, "fragmented control frame"); + s.inbuf.clear(); + return; + } + if (payload.remaining() > 125) { + initiateCloseAndWait(ioSession, 1002, "control frame too large"); + s.inbuf.clear(); + return; + } + } + + switch (op) { + case FrameOpcode.PING: { + try { + s.listener.onPing(payload.asReadOnlyBuffer()); + } catch (final Throwable ignore) { + } + if (s.cfg.isAutoPong()) { + out.enqueueCtrl(out.pooledFrame(FrameOpcode.PONG, payload.asReadOnlyBuffer(), true, false)); + } + break; + } + case FrameOpcode.PONG: { + try { + s.listener.onPong(payload.asReadOnlyBuffer()); + } catch (final Throwable ignore) { + } + break; + } + case FrameOpcode.CLOSE: { + final ByteBuffer ro = payload.asReadOnlyBuffer(); + int code = 1005; + String reason = ""; + final int len = ro.remaining(); + + if (len == 1) { + initiateCloseAndWait(ioSession, 1002, "Close frame length of 1 is invalid"); + s.inbuf.clear(); + return; + } else if (len >= 2) { + final ByteBuffer dup = ro.slice(); + code = CloseCodec.readCloseCode(dup); + + if (!CloseCodec.isValidToReceive(code)) { + initiateCloseAndWait(ioSession, 1002, "Invalid close code: " + code); + s.inbuf.clear(); + return; + } + + if (dup.hasRemaining()) { + final CharsetDecoder dec = StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + reason = dec.decode(dup.asReadOnlyBuffer()).toString(); + } catch (final CharacterCodingException badUtf8) { + initiateCloseAndWait(ioSession, 1007, "Invalid UTF-8 in close reason"); + s.inbuf.clear(); + return; + } + } + } + + notifyCloseOnce(code, reason); + + s.closeReceived.set(true); + + if (!s.closeSent.get()) { + out.enqueueCtrl(out.pooledCloseEcho(ro)); + } + + s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout()); + s.closeAfterFlush = true; + ioSession.clearEvent(EventMask.READ); + ioSession.setEvent(EventMask.WRITE); + s.inbuf.clear(); + return; + } + case FrameOpcode.CONT: { + if (s.assemblingOpcode == -1) { + initiateCloseAndWait(ioSession, 1002, "Unexpected continuation frame"); + s.inbuf.clear(); + return; + } + if (r1) { + initiateCloseAndWait(ioSession, 1002, "RSV1 set on continuation"); + s.inbuf.clear(); + return; + } + appendToMessage(payload, ioSession); + if (fin) { + deliverAssembledMessage(); + } + break; + } + case FrameOpcode.TEXT: + case FrameOpcode.BINARY: { + if (s.assemblingOpcode != -1) { + initiateCloseAndWait(ioSession, 1002, "New data frame while fragmented message in progress"); + s.inbuf.clear(); + return; + } + if (!fin) { + startMessage(op, payload, r1, ioSession); + break; + } + if (s.cfg.getMaxMessageSize() > 0 && payload.remaining() > s.cfg.getMaxMessageSize()) { + initiateCloseAndWait(ioSession, 1009, "Message too big"); + break; + } + if (r1 && s.decChain != null) { + final byte[] comp = toBytes(payload); + final byte[] plain; + try { + plain = s.decChain.decode(comp); + } catch (final Exception e) { + initiateCloseAndWait(ioSession, 1007, "Extension decode failed"); + s.inbuf.clear(); + return; + } + deliverSingle(op, ByteBuffer.wrap(plain)); + } else { + deliverSingle(op, payload.asReadOnlyBuffer()); + } + break; + } + default: { + initiateCloseAndWait(ioSession, 1002, "Unsupported opcode: " + op); + s.inbuf.clear(); + return; + } + } + } + s.inbuf.compact(); + } catch (final Exception ex) { + onException(ioSession, ex); + ioSession.close(CloseMode.GRACEFUL); + } + } + + private void appendToInbuf(final ByteBuffer src) { + if (src == null || !src.hasRemaining()) { + return; + } + if (s.inbuf.remaining() < src.remaining()) { + final int need = s.inbuf.position() + src.remaining(); + final int newCap = Math.max(s.inbuf.capacity() * 2, need); + final ByteBuffer bigger = ByteBuffer.allocate(newCap); + s.inbuf.flip(); + bigger.put(s.inbuf); + s.inbuf = bigger; + } + s.inbuf.put(src); + } + + private void startMessage(final int opcode, final ByteBuffer payload, final boolean rsv1, final IOSession ioSession) { + s.assemblingOpcode = opcode; + s.assemblingCompressed = rsv1 && s.decChain != null; + s.assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining())); + s.assemblingSize = 0L; + appendToMessage(payload, ioSession); + } + + private void appendToMessage(final ByteBuffer payload, final IOSession ioSession) { + final ByteBuffer dup = payload.asReadOnlyBuffer(); + final int n = dup.remaining(); + s.assemblingSize += n; + if (s.cfg.getMaxMessageSize() > 0 && s.assemblingSize > s.cfg.getMaxMessageSize()) { + initiateCloseAndWait(ioSession, 1009, "Message too big"); + return; + } + final byte[] tmp = new byte[n]; + dup.get(tmp); + s.assemblingBytes.write(tmp, 0, n); + } + + private void deliverAssembledMessage() { + final byte[] body = s.assemblingBytes.toByteArray(); + final int op = s.assemblingOpcode; + final boolean compressed = s.assemblingCompressed; + + s.assemblingOpcode = -1; + s.assemblingCompressed = false; + s.assemblingBytes = null; + s.assemblingSize = 0L; + + byte[] data = body; + if (compressed && s.decChain != null) { + try { + data = s.decChain.decode(body); + } catch (final Exception e) { + try { + s.listener.onError(e); + } catch (final Throwable ignore) { + } + return; + } + } + + if (op == FrameOpcode.TEXT) { + final CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + final CharBuffer cb = dec.decode(ByteBuffer.wrap(data)); + try { + s.listener.onText(cb, true); + } catch (final Throwable ignore) { + } + } catch (final CharacterCodingException cce) { + initiateCloseAndWait(s.session, 1007, "Invalid UTF-8 in text message"); + } + } else if (op == FrameOpcode.BINARY) { + try { + s.listener.onBinary(ByteBuffer.wrap(data).asReadOnlyBuffer(), true); + } catch (final Throwable ignore) { + } + } + } + + private void deliverSingle(final int op, final ByteBuffer payloadRO) { + if (op == FrameOpcode.TEXT) { + final CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + final CharBuffer cb = dec.decode(payloadRO); + try { + s.listener.onText(cb, true); + } catch (final Throwable ignore) { + } + } catch (final CharacterCodingException cce) { + initiateCloseAndWait(s.session, 1007, "Invalid UTF-8 in text message"); + } + } else if (op == FrameOpcode.BINARY) { + try { + s.listener.onBinary(payloadRO, true); + } catch (final Throwable ignore) { + } + } + } + + private static byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer b = buf.asReadOnlyBuffer(); + final byte[] out = new byte[b.remaining()]; + b.get(out); + return out; + } + + private void initiateCloseAndWait(final IOSession ioSession, final int code, final String reason) { + if (!s.closeSent.get()) { + try { + final String truncated = CloseCodec.truncateReasonUtf8(reason); + final byte[] payloadBytes = CloseCodec.encode(code, truncated); + out.enqueueCtrl(out.pooledFrame(FrameOpcode.CLOSE, ByteBuffer.wrap(payloadBytes), true, false)); + } catch (final Throwable ignore) { + } + s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout()); + } + notifyCloseOnce(code, reason); + } + + private void notifyCloseOnce(final int code, final String reason) { + if (s.open.getAndSet(false)) { + try { + s.listener.onClose(code, reason == null ? "" : reason); + } catch (final Throwable ignore) { + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java new file mode 100644 index 0000000000..b50e4b7740 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java @@ -0,0 +1,121 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.http.nio.command.ShutdownCommand; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Timeout; + +/** + * RFC6455/7692 WebSocket handler front-end. Delegates to WsInbound / WsOutbound. + */ +@Internal +public final class WebSocketIoHandler implements IOEventHandler { + + private final WebSocketSessionState state; + private final WebSocketInbound inbound; + private final WebSocketOutbound outbound; + private final AsyncClientEndpoint endpoint; + private final AtomicBoolean endpointReleased; + + public WebSocketIoHandler(final ProtocolIOSession session, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain, + final AsyncClientEndpoint endpoint) { + this.state = new WebSocketSessionState(session, listener, cfg, chain); + this.outbound = new WebSocketOutbound(state); + this.inbound = new WebSocketInbound(state, outbound); + this.endpoint = endpoint; + this.endpointReleased = new AtomicBoolean(false); + } + + /** + * Expose the application WebSocket facade. + */ + public WebSocket exposeWebSocket() { + return outbound.facade(); + } + + // ---- IOEventHandler ---- + @Override + public void connected(final IOSession ioSession) { + inbound.onConnected(ioSession); + } + + @Override + public void inputReady(final IOSession ioSession, final ByteBuffer src) { + inbound.onInputReady(ioSession, src); + } + + @Override + public void outputReady(final IOSession ioSession) { + outbound.onOutputReady(ioSession); + } + + @Override + public void timeout(final IOSession ioSession, final Timeout timeout) { + inbound.onTimeout(ioSession, timeout); + // Best-effort graceful close on timeout + ioSession.close(CloseMode.GRACEFUL); + } + + @Override + public void exception(final IOSession ioSession, final Exception cause) { + inbound.onException(ioSession, cause); + ioSession.close(CloseMode.GRACEFUL); + } + + @Override + public void disconnected(final IOSession ioSession) { + inbound.onDisconnected(ioSession); + ioSession.clearEvent(EventMask.READ | EventMask.WRITE); + // Ensure the underlying protocol session does not linger + state.session.enqueue(new ShutdownCommand(CloseMode.GRACEFUL), Command.Priority.IMMEDIATE); + if (endpoint != null && endpointReleased.compareAndSet(false, true)) { + try { + endpoint.releaseAndDiscard(); + } catch (final Throwable ignore) { + // best effort + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java new file mode 100644 index 0000000000..6c6fefb132 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java @@ -0,0 +1,586 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.core5.websocket.extension.WebSocketExtensionChain; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.message.CloseCodec; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.Args; + +/** + * Outbound path: frame building, queues, writing, and the app-facing WebSocket facade. + */ +@Internal +final class WebSocketOutbound { + + static final class OutFrame { + + final ByteBuffer buf; + final boolean pooled; + final boolean dataFrame; + final int size; + + OutFrame(final ByteBuffer buf, final boolean pooled, final boolean dataFrame) { + this.buf = buf; + this.pooled = pooled; + this.dataFrame = dataFrame; + this.size = buf.remaining(); + } + } + + private final WebSocketSessionState s; + private final WebSocket facade; + + WebSocketOutbound(final WebSocketSessionState s) { + this.s = s; + this.facade = new Facade(); + } + + WebSocket facade() { + return facade; + } + + // ---------------------------------------------------- IO writing --------- + + void onOutputReady(final IOSession ioSession) { + try { + int framesThisTick = 0; + + while (framesThisTick < s.maxFramesPerTick) { + + if (s.activeWrite != null && s.activeWrite.buf.hasRemaining()) { + final int written = ioSession.write(s.activeWrite.buf); + if (written == 0) { + ioSession.setEvent(EventMask.WRITE); + return; + } + if (!s.activeWrite.buf.hasRemaining()) { + if (s.activeWrite.dataFrame) { + s.dataQueuedBytes.addAndGet(-s.activeWrite.size); + } + release(s.activeWrite); + s.activeWrite = null; + framesThisTick++; + } else { + ioSession.setEvent(EventMask.WRITE); + return; + } + continue; + } + + final OutFrame ctrl = s.ctrlOutbound.poll(); + if (ctrl != null) { + s.activeWrite = ctrl; + continue; + } + + final OutFrame data = s.dataOutbound.poll(); + if (data != null) { + s.activeWrite = data; + continue; + } + + ioSession.clearEvent(EventMask.WRITE); + if (s.closeAfterFlush && s.activeWrite == null && s.ctrlOutbound.isEmpty() && s.dataOutbound.isEmpty()) { + ioSession.close(CloseMode.GRACEFUL); + } + return; + } + + if (s.activeWrite != null && s.activeWrite.buf.hasRemaining()) { + ioSession.setEvent(EventMask.WRITE); + } else { + ioSession.clearEvent(EventMask.WRITE); + } + + if (s.closeAfterFlush && s.activeWrite == null && s.ctrlOutbound.isEmpty() && s.dataOutbound.isEmpty()) { + ioSession.close(CloseMode.GRACEFUL); + } + + } catch (final Exception ex) { + try { + s.listener.onError(ex); + } finally { + s.session.close(CloseMode.GRACEFUL); + } + } + } + + private void release(final OutFrame frame) { + // No-op: buffers are not pooled. + } + + boolean enqueueCtrl(final OutFrame frame) { + final boolean closeFrame = isCloseFrame(frame.buf); + + if (!closeFrame && (!s.open.get() || s.closeSent.get())) { + release(frame); + return false; + } + + if (closeFrame) { + if (!s.closeSent.compareAndSet(false, true)) { + release(frame); + return false; + } + } else { + final int max = s.cfg.getMaxOutboundControlQueue(); + if (max > 0 && s.ctrlOutbound.size() >= max) { + release(frame); + return false; + } + } + s.ctrlOutbound.offer(frame); + s.session.setEvent(EventMask.WRITE); + return true; + } + + + boolean enqueueData(final OutFrame frame) { + if (!s.open.get() || s.closeSent.get()) { + release(frame); + return false; + } + final long limit = s.cfg.getMaxOutboundDataBytes(); + final long newSize = s.dataQueuedBytes.addAndGet(frame.size); + if (limit > 0 && newSize > limit) { + s.dataQueuedBytes.addAndGet(-frame.size); + release(frame); + return false; + } + s.dataOutbound.offer(frame); + s.session.setEvent(EventMask.WRITE); + return true; + } + + private static boolean isCloseFrame(final ByteBuffer buf) { + if (buf.remaining() < 2) { + return false; + } + final int pos = buf.position(); + final byte b1 = buf.get(pos); + final int opcode = b1 & 0x0F; + return opcode == FrameOpcode.CLOSE; + } + + // package-private so WebSocketInbound can use them + OutFrame pooledFrame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean dataFrame) { + final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + final int len = ro.remaining(); + + final int headerEstimate; + if (len <= 125) { + headerEstimate = 2 + 4; // 2-byte header + 4-byte mask + } else if (len <= 0xFFFF) { + headerEstimate = 4 + 4; // 4-byte header + 4-byte mask + } else { + headerEstimate = 10 + 4; // 10-byte header + 4-byte mask + } + + final int totalSize = headerEstimate + len; + + final ByteBuffer buf = s.cfg.isDirectBuffers() + ? ByteBuffer.allocateDirect(totalSize) + : ByteBuffer.allocate(totalSize); + final boolean pooled = false; + + buf.clear(); + // opcode (int), payload (ByteBuffer), fin (boolean), mask (boolean), out (ByteBuffer) + s.writer.frameInto(opcode, ro, fin, true, buf); + buf.flip(); + + return new OutFrame(buf, pooled, dataFrame); + } + + // package-private for outbound compression (RSV1 when negotiated) + OutFrame pooledFrameWithRsv(final int opcode, final ByteBuffer payload, final boolean fin, final int rsvBits, final boolean dataFrame) { + final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + final int len = ro.remaining(); + + final int headerEstimate; + if (len <= 125) { + headerEstimate = 2 + 4; + } else if (len <= 0xFFFF) { + headerEstimate = 4 + 4; + } else { + headerEstimate = 10 + 4; + } + + final int totalSize = headerEstimate + len; + + final ByteBuffer buf = s.cfg.isDirectBuffers() + ? ByteBuffer.allocateDirect(totalSize) + : ByteBuffer.allocate(totalSize); + final boolean pooled = false; + + buf.clear(); + s.writer.frameIntoWithRSV(opcode, ro, fin, true, rsvBits, buf); + buf.flip(); + + return new OutFrame(buf, pooled, dataFrame); + } + + // package-private so WebSocketInbound can use it for close echo + OutFrame pooledCloseEcho(final ByteBuffer payload) { + final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + final int len = ro.remaining(); + + final int headerEstimate; + if (len <= 125) { + headerEstimate = 2 + 4; + } else if (len <= 0xFFFF) { + headerEstimate = 4 + 4; + } else { + headerEstimate = 10 + 4; + } + + final int totalSize = headerEstimate + len; + + final ByteBuffer buf = s.cfg.isDirectBuffers() + ? ByteBuffer.allocateDirect(totalSize) + : ByteBuffer.allocate(totalSize); + final boolean pooled = false; + + buf.clear(); + s.writer.frameInto(FrameOpcode.CLOSE, ro, true, true, buf); + buf.flip(); + + return new OutFrame(buf, pooled, false); + } + + // package-private: used by WebSocketInbound.onDisconnected() + void drainAndRelease() { + if (s.activeWrite != null) { + release(s.activeWrite); + s.activeWrite = null; + } + OutFrame f; + while ((f = s.ctrlOutbound.poll()) != null) { + release(f); + } + while ((f = s.dataOutbound.poll()) != null) { + if (f.dataFrame) { + s.dataQueuedBytes.addAndGet(-f.size); + } + release(f); + } + } + + // --------------------------------------------------------- Facade -------- + + private final class Facade implements WebSocket { + + @Override + public boolean isOpen() { + return s.open.get() && !s.closeSent.get(); + } + + @Override + public boolean ping(final ByteBuffer data) { + if (!s.open.get() || s.closeSent.get()) { + return false; + } + final ByteBuffer ro = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + if (ro.remaining() > 125) { + return false; + } + return enqueueCtrl(pooledFrame(FrameOpcode.PING, ro, true, false)); + } + + @Override + public boolean pong(final ByteBuffer data) { + if (!s.open.get() || s.closeSent.get()) { + return false; + } + final ByteBuffer ro = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + if (ro.remaining() > 125) { + return false; + } + return enqueueCtrl(pooledFrame(FrameOpcode.PONG, ro, true, false)); + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || data == null) { + return false; + } + final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(data.toString()); + return sendData(FrameOpcode.TEXT, utf8, finalFragment); + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || data == null) { + return false; + } + return sendData(FrameOpcode.BINARY, data, finalFragment); + } + + private boolean sendData(final int opcode, final ByteBuffer data, final boolean fin) { + s.writeLock.lock(); + try { + if (s.encChain != null && s.outOpcode == -1 && fin) { + // Compress the whole message, then fragment the compressed payload. + final byte[] plain = toBytes(data); + final WebSocketExtensionChain.Encoded enc = + s.encChain.encode(plain, true, true); + ByteBuffer ro = ByteBuffer.wrap(enc.payload); + int currentOpcode = opcode; + boolean firstFragment = true; + if (!ro.hasRemaining()) { + ro = ByteBuffer.allocate(0); + } + do { + if (!s.open.get() || s.closeSent.get()) { + return false; + } + final int n = Math.min(ro.remaining(), s.outChunk); + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + final boolean lastSlice = !ro.hasRemaining(); + final int rsv = enc.setRsvOnFirst && firstFragment ? s.rsvMask : 0; + if (!enqueueData(pooledFrameWithRsv(currentOpcode, slice, lastSlice, rsv, true))) { + return false; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } while (ro.hasRemaining()); + return true; + } + + int currentOpcode = s.outOpcode == -1 ? opcode : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = opcode; + } + + final ByteBuffer ro = data.asReadOnlyBuffer(); + boolean ok = true; + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + while (ro.hasRemaining()) { + if (!s.open.get() || s.closeSent.get()) { + ok = false; + break; + } + + final int n = Math.min(ro.remaining(), s.outChunk); + + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + + final boolean lastSlice = !ro.hasRemaining() && fin; + if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) { + ok = false; + break; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + + if (fin || !ok) { + s.outOpcode = -1; + } + return ok; + } finally { + s.writeLock.unlock(); + } + } + + + @Override + public boolean sendTextBatch(final List fragments, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) { + return false; + } + s.writeLock.lock(); + try { + int currentOpcode = s.outOpcode == -1 ? FrameOpcode.TEXT : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = FrameOpcode.TEXT; + } + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + for (int i = 0; i < fragments.size(); i++) { + final CharSequence part = Args.notNull(fragments.get(i), "fragment"); + final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(part.toString()); + final ByteBuffer ro = utf8.asReadOnlyBuffer(); + + while (ro.hasRemaining()) { + if (!s.open.get() || s.closeSent.get()) { + s.outOpcode = -1; + return false; + } + final int n = Math.min(ro.remaining(), s.outChunk); + + final int oldLimit = ro.limit(); + final int newLimit = ro.position() + n; + ro.limit(newLimit); + final ByteBuffer slice = ro.slice(); + ro.limit(oldLimit); + ro.position(newLimit); + + final boolean isLastFragment = i == fragments.size() - 1; + final boolean lastSlice = !ro.hasRemaining() && isLastFragment && finalFragment; + + if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) { + s.outOpcode = -1; + return false; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + } + + if (finalFragment) { + s.outOpcode = -1; + } + return true; + } finally { + s.writeLock.unlock(); + } + } + + @Override + public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) { + if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) { + return false; + } + s.writeLock.lock(); + try { + int currentOpcode = s.outOpcode == -1 ? FrameOpcode.BINARY : FrameOpcode.CONT; + if (s.outOpcode == -1) { + s.outOpcode = FrameOpcode.BINARY; + } + boolean firstFragment = currentOpcode != FrameOpcode.CONT; + + for (int i = 0; i < fragments.size(); i++) { + final ByteBuffer src = Args.notNull(fragments.get(i), "fragment").asReadOnlyBuffer(); + + while (src.hasRemaining()) { + if (!s.open.get() || s.closeSent.get()) { + s.outOpcode = -1; + return false; + } + final int n = Math.min(src.remaining(), s.outChunk); + + final int oldLimit = src.limit(); + final int newLimit = src.position() + n; + src.limit(newLimit); + final ByteBuffer slice = src.slice(); + src.limit(oldLimit); + src.position(newLimit); + + final boolean isLastFragment = i == fragments.size() - 1; + final boolean lastSlice = !src.hasRemaining() && isLastFragment && finalFragment; + + if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) { + s.outOpcode = -1; + return false; + } + currentOpcode = FrameOpcode.CONT; + firstFragment = false; + } + } + + if (finalFragment) { + s.outOpcode = -1; + } + return true; + } finally { + s.writeLock.unlock(); + } + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + final CompletableFuture future = new CompletableFuture<>(); + + if (!s.open.get()) { + future.completeExceptionally( + new IllegalStateException("WebSocket is already closed")); + return future; + } + + if (!CloseCodec.isValidToSend(statusCode)) { + future.completeExceptionally( + new IllegalArgumentException("Invalid close status code: " + statusCode)); + return future; + } + + final String truncated = CloseCodec.truncateReasonUtf8(reason); + final byte[] payloadBytes = CloseCodec.encode(statusCode, truncated); + final ByteBuffer payload = ByteBuffer.wrap(payloadBytes); + + if (!enqueueCtrl(pooledFrame(FrameOpcode.CLOSE, payload, true, false))) { + future.completeExceptionally( + new IllegalStateException("WebSocket is closing or already closed")); + return future; + } + + // cfg.getCloseWaitTimeout() is a Timeout, IOSession.setSocketTimeout(Timeout) + s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout()); + future.complete(null); + return future; + } + } + + private OutFrame buildDataFrame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean firstFragment) { + if (s.encChain == null) { + return pooledFrame(opcode, payload, fin, true); + } + final byte[] plain = toBytes(payload); + final WebSocketExtensionChain.Encoded enc = + s.encChain.encode(plain, firstFragment, fin); + final int rsv = enc.setRsvOnFirst && firstFragment ? s.rsvMask : 0; + return pooledFrameWithRsv(opcode, ByteBuffer.wrap(enc.payload), fin, rsv, true); + } + + private static byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer b = buf.asReadOnlyBuffer(); + final byte[] out = new byte[b.remaining()]; + b.get(out); + return out; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java new file mode 100644 index 0000000000..40f292ab04 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java @@ -0,0 +1,117 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.reactor.ProtocolIOSession; + +/** + * Shared state & resources. + */ +@Internal +final class WebSocketSessionState { + + // External + final ProtocolIOSession session; + final WebSocketListener listener; + final WebSocketClientConfig cfg; + + // Extensions + final ExtensionChain.EncodeChain encChain; + final ExtensionChain.DecodeChain decChain; + final int rsvMask; + + // Buffers & codec + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final WebSocketFrameDecoder decoder; + + // Read side + ByteBuffer readBuf; + ByteBuffer inbuf = ByteBuffer.allocate(4096); + + // Outbound queues + final ConcurrentLinkedQueue ctrlOutbound = new ConcurrentLinkedQueue<>(); + final ConcurrentLinkedQueue dataOutbound = new ConcurrentLinkedQueue<>(); + WebSocketOutbound.OutFrame activeWrite = null; + final AtomicLong dataQueuedBytes = new AtomicLong(); + + // Flags / locks + final AtomicBoolean open = new AtomicBoolean(true); + final AtomicBoolean closeSent = new AtomicBoolean(false); + final AtomicBoolean closeReceived = new AtomicBoolean(false); + volatile boolean closeAfterFlush = false; + final ReentrantLock writeLock = new ReentrantLock(); + + // Message assembly + int assemblingOpcode = -1; + boolean assemblingCompressed = false; + ByteArrayOutputStream assemblingBytes = null; + long assemblingSize = 0L; + + // Outbound fragmentation + int outOpcode = -1; + final int outChunk; + final int maxFramesPerTick; + + WebSocketSessionState(final ProtocolIOSession session, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain) { + this.session = session; + this.listener = listener; + this.cfg = cfg; + + this.decoder = new WebSocketFrameDecoder(cfg.getMaxFrameSize(), false); + + this.outChunk = Math.max(256, cfg.getOutgoingChunkSize()); + this.maxFramesPerTick = Math.max(1, cfg.getMaxFramesPerTick()); + + if (chain != null && !chain.isEmpty()) { + this.encChain = chain.newEncodeChain(); + this.decChain = chain.newDecodeChain(); + this.rsvMask = chain.rsvMask(); + } else { + this.encChain = null; + this.decChain = null; + this.rsvMask = 0; + } + + final int poolBufSize = Math.max(8192, this.outChunk); + this.readBuf = cfg.isDirectBuffers() ? ByteBuffer.allocateDirect(poolBufSize) : ByteBuffer.allocate(poolBufSize); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java new file mode 100644 index 0000000000..08be8953ae --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java @@ -0,0 +1,114 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Bridges HttpCore protocol upgrade to a WebSocket {@link WebSocketIoHandler}. + * + *

IMPORTANT: This class does NOT call {@link WebSocketListener#onOpen(WebSocket)}. + * The caller performs notification after {@code switchProtocol(...)} completes.

+ */ +@Internal +public final class WebSocketUpgrader implements ProtocolUpgradeHandler { + + private static final Logger LOG = LoggerFactory.getLogger(WebSocketUpgrader.class); + + private final WebSocketListener listener; + private final WebSocketClientConfig cfg; + private final ExtensionChain chain; + private final AsyncClientEndpoint endpoint; + + /** + * The WebSocket facade created during {@link #upgrade}. + */ + private volatile WebSocket webSocket; + + public WebSocketUpgrader( + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain) { + this(listener, cfg, chain, null); + } + + public WebSocketUpgrader( + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final ExtensionChain chain, + final AsyncClientEndpoint endpoint) { + this.listener = listener; + this.cfg = cfg; + this.chain = chain; + this.endpoint = endpoint; + } + + /** + * Returns the {@link WebSocket} created during {@link #upgrade}. + */ + public WebSocket getWebSocket() { + return webSocket; + } + + @Override + public void upgrade(final ProtocolIOSession ioSession, + final FutureCallback callback) { + try { + if (LOG.isDebugEnabled()) { + LOG.debug("Installing WsHandler on {}", ioSession); + } + + final WebSocketIoHandler handler = new WebSocketIoHandler(ioSession, listener, cfg, chain, endpoint); + ioSession.upgrade(handler); + + this.webSocket = handler.exposeWebSocket(); + + if (callback != null) { + callback.completed(ioSession); + } + } catch (final Exception ex) { + if (LOG.isDebugEnabled()) { + LOG.debug("WebSocket upgrade failed", ex); + } + if (callback != null) { + callback.failed(ex); + } else { + throw ex; + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java new file mode 100644 index 0000000000..67cff0dbea --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java @@ -0,0 +1,37 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Integration with Apache HttpCore I/O reactor. + * + *

Protocol upgrade hooks and the reactor {@code IOEventHandler} that + * implements RFC 6455/7692 on top of HttpCore. Internal API — subject + * to change without notice.

+ * + * @since 5.7 + */ +package org.apache.hc.client5.http.websocket.transport; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java new file mode 100644 index 0000000000..0a4c3a0e07 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java @@ -0,0 +1,181 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +public final class PerMessageDeflateExtension implements WebSocketExtension { + + private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF}; + private static final int MIN_WINDOW_BITS = 8; + private static final int MAX_WINDOW_BITS = 15; + + private final boolean serverNoContextTakeover; + private final boolean clientNoContextTakeover; + private final Integer clientMaxWindowBits; + private final Integer serverMaxWindowBits; + + private final Inflater inflater = new Inflater(true); + private final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true); + private boolean decodingMessage; + + public PerMessageDeflateExtension() { + this(false, false, MAX_WINDOW_BITS, MAX_WINDOW_BITS); + } + + PerMessageDeflateExtension( + final boolean serverNoContextTakeover, + final boolean clientNoContextTakeover, + final Integer clientMaxWindowBits, + final Integer serverMaxWindowBits) { + this.serverNoContextTakeover = serverNoContextTakeover; + this.clientNoContextTakeover = clientNoContextTakeover; + this.clientMaxWindowBits = clientMaxWindowBits; + this.serverMaxWindowBits = serverMaxWindowBits; + this.decodingMessage = false; + } + + @Override + public String getName() { + return "permessage-deflate"; + } + + @Override + public boolean usesRsv1() { + return true; + } + + @Override + public ByteBuffer decode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) throws WebSocketException { + if (!isDataFrame(type) && type != WebSocketFrameType.CONTINUATION) { + throw new WebSocketException("Unsupported frame type for permessage-deflate: " + type); + } + if (type == WebSocketFrameType.CONTINUATION && !decodingMessage) { + throw new WebSocketException("Unexpected continuation frame for permessage-deflate"); + } + final byte[] input = toByteArray(payload); + final byte[] withTail; + if (fin) { + withTail = new byte[input.length + TAIL.length]; + System.arraycopy(input, 0, withTail, 0, input.length); + System.arraycopy(TAIL, 0, withTail, input.length, TAIL.length); + } else { + withTail = input; + } + inflater.setInput(withTail); + final ByteArrayOutputStream out = new ByteArrayOutputStream(input.length); + final byte[] buffer = new byte[8192]; + try { + while (!inflater.needsInput()) { + final int count = inflater.inflate(buffer); + if (count == 0 && inflater.needsInput()) { + break; + } + out.write(buffer, 0, count); + } + } catch (final Exception ex) { + throw new WebSocketException("Unable to inflate payload", ex); + } + if (fin) { + decodingMessage = false; + if (clientNoContextTakeover) { + inflater.reset(); + } + } else { + decodingMessage = true; + } + return ByteBuffer.wrap(out.toByteArray()); + } + + @Override + public ByteBuffer encode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) throws WebSocketException { + if (!isDataFrame(type) && type != WebSocketFrameType.CONTINUATION) { + throw new WebSocketException("Unsupported frame type for permessage-deflate: " + type); + } + final byte[] input = toByteArray(payload); + deflater.setInput(input); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, input.length / 2)); + final byte[] buffer = new byte[8192]; + while (!deflater.needsInput()) { + final int count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH); + if (count > 0) { + out.write(buffer, 0, count); + } else { + break; + } + } + final byte[] data = out.toByteArray(); + final ByteBuffer encoded; + if (data.length >= 4) { + encoded = ByteBuffer.wrap(data, 0, data.length - 4); + } else { + encoded = ByteBuffer.wrap(data); + } + if (fin && serverNoContextTakeover) { + deflater.reset(); + } + return encoded; + } + + @Override + public WebSocketExtensionData getResponseData() { + final Map params = new LinkedHashMap<>(); + if (serverNoContextTakeover) { + params.put("server_no_context_takeover", null); + } + if (clientNoContextTakeover) { + params.put("client_no_context_takeover", null); + } + if (clientMaxWindowBits != null) { + params.put("client_max_window_bits", Integer.toString(clientMaxWindowBits)); + } + if (serverMaxWindowBits != null) { + params.put("server_max_window_bits", Integer.toString(serverMaxWindowBits)); + } + return new WebSocketExtensionData(getName(), params); + } + + private static boolean isDataFrame(final WebSocketFrameType type) { + return type == WebSocketFrameType.TEXT || type == WebSocketFrameType.BINARY; + } + + static boolean isValidWindowBits(final Integer bits) { + return bits == null || bits >= MIN_WINDOW_BITS && bits <= MAX_WINDOW_BITS; + } + + private static byte[] toByteArray(final ByteBuffer payload) { + final ByteBuffer buffer = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0); + final byte[] data = new byte[buffer.remaining()]; + buffer.get(data); + return data; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java new file mode 100644 index 0000000000..6166efe842 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java @@ -0,0 +1,96 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +/** + * Factory for {@code permessage-deflate} extensions (RFC 7692). + * + *

Note: the JDK {@link java.util.zip.Deflater} / {@link java.util.zip.Inflater} + * only support a 15-bit window size. This factory therefore accepts + * {@code client_max_window_bits} / {@code server_max_window_bits} only when + * they are either absent or explicitly set to {@code 15}. Other values are + * rejected during negotiation.

+ */ +public final class PerMessageDeflateExtensionFactory implements WebSocketExtensionFactory { + + @Override + public String getName() { + return "permessage-deflate"; + } + + @Override + public WebSocketExtension create(final WebSocketExtensionData request, final boolean server) { + if (request == null) { + return null; + } + final String name = request.getName(); + if (!"permessage-deflate".equals(name)) { + return null; + } + final boolean serverNoContextTakeover = request.getParameters().containsKey("server_no_context_takeover"); + final boolean clientNoContextTakeover = request.getParameters().containsKey("client_no_context_takeover"); + final boolean clientMaxBitsPresent = request.getParameters().containsKey("client_max_window_bits"); + final boolean serverMaxBitsPresent = request.getParameters().containsKey("server_max_window_bits"); + Integer clientMaxWindowBits = parseWindowBits(request.getParameters().get("client_max_window_bits")); + Integer serverMaxWindowBits = parseWindowBits(request.getParameters().get("server_max_window_bits")); + if (clientMaxBitsPresent && clientMaxWindowBits == null) { + clientMaxWindowBits = 15; + } + if (serverMaxBitsPresent && serverMaxWindowBits == null) { + serverMaxWindowBits = 15; + } + + if (!PerMessageDeflateExtension.isValidWindowBits(clientMaxWindowBits) + || !PerMessageDeflateExtension.isValidWindowBits(serverMaxWindowBits)) { + return null; + } + // JDK Deflater/Inflater only supports 15-bit window size. + if (!isSupportedWindowBits(clientMaxWindowBits) || !isSupportedWindowBits(serverMaxWindowBits)) { + return null; + } + return new PerMessageDeflateExtension( + serverNoContextTakeover, + clientNoContextTakeover, + clientMaxWindowBits, + serverMaxWindowBits); + } + + private static Integer parseWindowBits(final String value) { + if (value == null || value.isEmpty()) { + return null; + } + try { + return Integer.parseInt(value); + } catch (final NumberFormatException ignore) { + return null; + } + } + + private static boolean isSupportedWindowBits(final Integer bits) { + return bits == null || bits == 15; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java new file mode 100644 index 0000000000..e1c5677de1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java @@ -0,0 +1,55 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +public enum WebSocketCloseStatus { + + NORMAL(1000), + GOING_AWAY(1001), + PROTOCOL_ERROR(1002), + UNSUPPORTED_DATA(1003), + NO_STATUS_RECEIVED(1005), + ABNORMAL_CLOSURE(1006), + INVALID_PAYLOAD(1007), + POLICY_VIOLATION(1008), + MESSAGE_TOO_BIG(1009), + MANDATORY_EXTENSION(1010), + INTERNAL_ERROR(1011), + SERVICE_RESTART(1012), + TRY_AGAIN_LATER(1013), + BAD_GATEWAY(1014); + + private final int code; + + WebSocketCloseStatus(final int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java new file mode 100644 index 0000000000..589275e486 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import org.apache.hc.core5.util.Args; + +public final class WebSocketConfig { + + public static final WebSocketConfig DEFAULT = WebSocketConfig.custom().build(); + + private final int maxFramePayloadSize; + private final int maxMessageSize; + + private WebSocketConfig(final Builder builder) { + this.maxFramePayloadSize = builder.maxFramePayloadSize; + this.maxMessageSize = builder.maxMessageSize; + } + + public int getMaxFramePayloadSize() { + return maxFramePayloadSize; + } + + public int getMaxMessageSize() { + return maxMessageSize; + } + + public static Builder custom() { + return new Builder(); + } + + public static final class Builder { + + private int maxFramePayloadSize = 16 * 1024 * 1024; + private int maxMessageSize = 64 * 1024 * 1024; + + public Builder setMaxFramePayloadSize(final int maxFramePayloadSize) { + this.maxFramePayloadSize = Args.positive(maxFramePayloadSize, "Max frame payload size"); + return this; + } + + public Builder setMaxMessageSize(final int maxMessageSize) { + this.maxMessageSize = Args.positive(maxMessageSize, "Max message size"); + return this; + } + + public WebSocketConfig build() { + return new WebSocketConfig(this); + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java new file mode 100644 index 0000000000..34ae7cb4b3 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java @@ -0,0 +1,47 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +public final class WebSocketConstants { + + public static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + public static final String SEC_WEBSOCKET_KEY = "Sec-WebSocket-Key"; + public static final String SEC_WEBSOCKET_VERSION = "Sec-WebSocket-Version"; + public static final String SEC_WEBSOCKET_ACCEPT = "Sec-WebSocket-Accept"; + public static final String SEC_WEBSOCKET_PROTOCOL = "Sec-WebSocket-Protocol"; + public static final String SEC_WEBSOCKET_EXTENSIONS = "Sec-WebSocket-Extensions"; + public static final String SEC_WEBSOCKET_VERSION_LOWER = "sec-websocket-version"; + public static final String SEC_WEBSOCKET_PROTOCOL_LOWER = "sec-websocket-protocol"; + public static final String SEC_WEBSOCKET_EXTENSIONS_LOWER = "sec-websocket-extensions"; + /** + * RFC 8441 extended CONNECT pseudo-header. + */ + public static final String PSEUDO_PROTOCOL = ":protocol"; + + private WebSocketConstants() { + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java new file mode 100644 index 0000000000..4641e29f41 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java @@ -0,0 +1,42 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.io.IOException; + +public class WebSocketException extends IOException { + + private static final long serialVersionUID = 1L; + + public WebSocketException(final String message) { + super(message); + } + + public WebSocketException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java new file mode 100644 index 0000000000..73988651cd --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java @@ -0,0 +1,64 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.nio.ByteBuffer; + +public interface WebSocketExtension { + + String getName(); + + default boolean usesRsv1() { + return false; + } + + default boolean usesRsv2() { + return false; + } + + default boolean usesRsv3() { + return false; + } + + default ByteBuffer decode( + final WebSocketFrameType type, + final boolean fin, + final ByteBuffer payload) throws WebSocketException { + return payload; + } + + default ByteBuffer encode( + final WebSocketFrameType type, + final boolean fin, + final ByteBuffer payload) throws WebSocketException { + return payload; + } + + default WebSocketExtensionData getResponseData() { + return new WebSocketExtensionData(getName(), null); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java new file mode 100644 index 0000000000..082fea84dd --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TextUtils; + +public final class WebSocketExtensionData { + + private final String name; + private final Map parameters; + + public WebSocketExtensionData(final String name, final Map parameters) { + this.name = Args.notBlank(name, "Extension name"); + if (parameters != null && !parameters.isEmpty()) { + this.parameters = new LinkedHashMap<>(parameters); + } else { + this.parameters = Collections.emptyMap(); + } + } + + public String getName() { + return name; + } + + public Map getParameters() { + return parameters; + } + + public String format() { + final StringBuilder buffer = new StringBuilder(name); + for (final Map.Entry entry : parameters.entrySet()) { + buffer.append("; ").append(entry.getKey()); + if (!TextUtils.isBlank(entry.getValue())) { + buffer.append('=').append(entry.getValue()); + } + } + return buffer.toString(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java new file mode 100644 index 0000000000..ebbb1712aa --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java @@ -0,0 +1,34 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +public interface WebSocketExtensionFactory { + + String getName(); + + WebSocketExtension create(WebSocketExtensionData request, boolean server) throws WebSocketException; +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java new file mode 100644 index 0000000000..4acf9779ce --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java @@ -0,0 +1,61 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.util.Collections; +import java.util.List; +import java.util.StringJoiner; + + +public final class WebSocketExtensionNegotiation { + + private final List extensions; + private final List responseData; + + WebSocketExtensionNegotiation( + final List extensions, + final List responseData) { + this.extensions = extensions != null ? extensions : Collections.emptyList(); + this.responseData = responseData != null ? responseData : Collections.emptyList(); + } + + public List getExtensions() { + return extensions; + } + + public List getResponseData() { + return responseData; + } + + public String formatResponseHeader() { + final StringJoiner joiner = new StringJoiner(", "); + for (final WebSocketExtensionData data : responseData) { + joiner.add(data.format()); + } + return joiner.length() > 0 ? joiner.toString() : null; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java new file mode 100644 index 0000000000..12fa259f3f --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java @@ -0,0 +1,74 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.util.Args; + +public final class WebSocketExtensionRegistry { + + private final Map factories; + + public WebSocketExtensionRegistry() { + this.factories = new LinkedHashMap<>(); + } + + public WebSocketExtensionRegistry register(final WebSocketExtensionFactory factory) { + Args.notNull(factory, "Extension factory"); + factories.put(factory.getName(), factory); + return this; + } + + public WebSocketExtensionNegotiation negotiate( + final List requested, + final boolean server) throws WebSocketException { + final List extensions = new ArrayList<>(); + final List responseData = new ArrayList<>(); + if (requested != null) { + for (final WebSocketExtensionData request : requested) { + final WebSocketExtensionFactory factory = factories.get(request.getName()); + if (factory != null) { + final WebSocketExtension extension = factory.create(request, server); + if (extension != null) { + extensions.add(extension); + responseData.add(extension.getResponseData()); + } + } + } + } + return new WebSocketExtensionNegotiation(extensions, responseData); + } + + public static WebSocketExtensionRegistry createDefault() { + return new WebSocketExtensionRegistry() + .register(new PerMessageDeflateExtensionFactory()); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java new file mode 100644 index 0000000000..6c853b50fb --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java @@ -0,0 +1,63 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HeaderElement; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.util.TextUtils; + +public final class WebSocketExtensions { + + private WebSocketExtensions() { + } + + public static List parse(final Header header) { + final List extensions = new ArrayList<>(); + if (header == null) { + return extensions; + } + for (final HeaderElement element : MessageSupport.parseElements(header)) { + final String name = element.getName(); + if (TextUtils.isBlank(name)) { + continue; + } + final Map params = new LinkedHashMap<>(); + for (final NameValuePair param : element.getParameters()) { + params.put(param.getName(), param.getValue()); + } + extensions.add(new WebSocketExtensionData(name, params)); + } + return extensions; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java new file mode 100644 index 0000000000..1460628f39 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.nio.ByteBuffer; + +import org.apache.hc.core5.util.Args; + +public final class WebSocketFrame { + + private final boolean fin; + private final boolean rsv1; + private final boolean rsv2; + private final boolean rsv3; + private final WebSocketFrameType type; + private final ByteBuffer payload; + + public WebSocketFrame( + final boolean fin, + final boolean rsv1, + final boolean rsv2, + final boolean rsv3, + final WebSocketFrameType type, + final ByteBuffer payload) { + this.fin = fin; + this.rsv1 = rsv1; + this.rsv2 = rsv2; + this.rsv3 = rsv3; + this.type = Args.notNull(type, "Frame type"); + this.payload = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0).asReadOnlyBuffer(); + } + + public boolean isFin() { + return fin; + } + + public boolean isRsv1() { + return rsv1; + } + + public boolean isRsv2() { + return rsv2; + } + + public boolean isRsv3() { + return rsv3; + } + + public WebSocketFrameType getType() { + return type; + } + + public ByteBuffer getPayload() { + return payload.asReadOnlyBuffer(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java new file mode 100644 index 0000000000..af14ff2518 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java @@ -0,0 +1,166 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; + +import org.apache.hc.core5.util.Args; + +class WebSocketFrameReader { + + private final WebSocketConfig config; + private final InputStream inputStream; + private final List extensions; + private final WebSocketExtension rsv1Extension; + private final WebSocketExtension rsv2Extension; + private final WebSocketExtension rsv3Extension; + private boolean continuationCompressed; + + WebSocketFrameReader(final WebSocketConfig config, final InputStream inputStream, final List extensions) { + this.config = Args.notNull(config, "WebSocket config"); + this.inputStream = Args.notNull(inputStream, "Input stream"); + this.extensions = extensions != null ? extensions : Collections.emptyList(); + this.rsv1Extension = selectExtension(WebSocketExtension::usesRsv1); + this.rsv2Extension = selectExtension(WebSocketExtension::usesRsv2); + this.rsv3Extension = selectExtension(WebSocketExtension::usesRsv3); + this.continuationCompressed = false; + } + + WebSocketFrame readFrame() throws IOException { + final int b1 = inputStream.read(); + if (b1 == -1) { + return null; + } + final int b2 = readByte(); + final boolean fin = (b1 & 0x80) != 0; + final boolean rsv1 = (b1 & 0x40) != 0; + final boolean rsv2 = (b1 & 0x20) != 0; + final boolean rsv3 = (b1 & 0x10) != 0; + final int opcode = b1 & 0x0F; + final WebSocketFrameType type = WebSocketFrameType.fromOpcode(opcode); + if (type == null) { + throw new WebSocketException("Unsupported opcode: " + opcode); + } + if (type.isControl() && (rsv1 || rsv2 || rsv3)) { + throw new WebSocketException("Invalid RSV bits for control frame"); + } + if (type == WebSocketFrameType.CONTINUATION && rsv1) { + throw new WebSocketException("RSV1 must be 0 on continuation frames"); + } + if (rsv1 && rsv1Extension == null) { + throw new WebSocketException("Unexpected RSV1 bit"); + } + if (rsv2 && rsv2Extension == null) { + throw new WebSocketException("Unexpected RSV2 bit"); + } + if (rsv3 && rsv3Extension == null) { + throw new WebSocketException("Unexpected RSV3 bit"); + } + final boolean masked = (b2 & 0x80) != 0; + if (!masked) { + throw new WebSocketException("Client frame is not masked"); + } + long len = b2 & 0x7F; + if (len == 126) { + len = ((readByte() & 0xFF) << 8) | (readByte() & 0xFF); + } else if (len == 127) { + len = 0; + for (int i = 0; i < 8; i++) { + len = (len << 8) | (readByte() & 0xFF); + } + } + if (len > Integer.MAX_VALUE) { + throw new WebSocketException("Frame payload too large: " + len); + } + if (len > config.getMaxFramePayloadSize()) { + throw new WebSocketException("Frame payload exceeds limit: " + len); + } + final byte[] maskKey = new byte[4]; + readFully(maskKey); + final byte[] payload = new byte[(int) len]; + readFully(payload); + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (payload[i] ^ maskKey[i % 4]); + } + ByteBuffer data = ByteBuffer.wrap(payload); + if (rsv1 && rsv1Extension != null) { + data = rsv1Extension.decode(type, fin, data); + continuationCompressed = !fin && (type == WebSocketFrameType.TEXT || type == WebSocketFrameType.BINARY); + } else if (type == WebSocketFrameType.CONTINUATION && continuationCompressed && rsv1Extension != null) { + data = rsv1Extension.decode(type, fin, data); + if (fin) { + continuationCompressed = false; + } + } else if (type == WebSocketFrameType.CONTINUATION && fin) { + continuationCompressed = false; + } + if (rsv2 && rsv2Extension != null) { + data = rsv2Extension.decode(type, fin, data); + } + if (rsv3 && rsv3Extension != null) { + data = rsv3Extension.decode(type, fin, data); + } + return new WebSocketFrame(fin, false, false, false, type, data); + } + + private WebSocketExtension selectExtension(final Predicate predicate) { + WebSocketExtension selected = null; + for (final WebSocketExtension extension : extensions) { + if (predicate.test(extension)) { + if (selected != null) { + throw new IllegalStateException("Multiple extensions use the same RSV bit"); + } + selected = extension; + } + } + return selected; + } + + private int readByte() throws IOException { + final int b = inputStream.read(); + if (b == -1) { + throw new IOException("Unexpected end of stream"); + } + return b; + } + + private void readFully(final byte[] buffer) throws IOException { + int off = 0; + while (off < buffer.length) { + final int read = inputStream.read(buffer, off, buffer.length - off); + if (read == -1) { + throw new IOException("Unexpected end of stream"); + } + off += read; + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java new file mode 100644 index 0000000000..e57dd8908c --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java @@ -0,0 +1,60 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +public enum WebSocketFrameType { + + CONTINUATION(0x0), + TEXT(0x1), + BINARY(0x2), + CLOSE(0x8), + PING(0x9), + PONG(0xA); + + private final int opcode; + + WebSocketFrameType(final int opcode) { + this.opcode = opcode; + } + + public int getOpcode() { + return opcode; + } + + public boolean isControl() { + return opcode >= 0x8; + } + + public static WebSocketFrameType fromOpcode(final int opcode) { + for (final WebSocketFrameType type : values()) { + if (type.opcode == opcode) { + return type; + } + } + return null; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java new file mode 100644 index 0000000000..69ef31e5b8 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.util.Args; + +class WebSocketFrameWriter { + + private final OutputStream outputStream; + private final List extensions; + + WebSocketFrameWriter(final OutputStream outputStream, final List extensions) { + this.outputStream = Args.notNull(outputStream, "Output stream"); + this.extensions = extensions != null ? extensions : Collections.emptyList(); + } + + void writeText(final String text) throws IOException, WebSocketException { + Args.notNull(text, "Text"); + writeDataFrame(WebSocketFrameType.TEXT, ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8))); + } + + void writeBinary(final ByteBuffer payload) throws IOException, WebSocketException { + writeDataFrame(WebSocketFrameType.BINARY, payload); + } + + void writePing(final ByteBuffer payload) throws IOException { + writeFrame(WebSocketFrameType.PING, payload, false, false, false); + } + + void writePong(final ByteBuffer payload) throws IOException { + writeFrame(WebSocketFrameType.PONG, payload, false, false, false); + } + + void writeClose(final int statusCode, final String reason) throws IOException { + final byte[] reasonBytes = reason != null ? reason.getBytes(StandardCharsets.UTF_8) : new byte[0]; + final int len = 2 + reasonBytes.length; + final ByteBuffer buffer = ByteBuffer.allocate(len); + buffer.put((byte) ((statusCode >> 8) & 0xFF)); + buffer.put((byte) (statusCode & 0xFF)); + buffer.put(reasonBytes); + buffer.flip(); + writeFrame(WebSocketFrameType.CLOSE, buffer, false, false, false); + } + + private void writeDataFrame(final WebSocketFrameType type, final ByteBuffer payload) throws IOException, WebSocketException { + ByteBuffer data = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0); + boolean rsv1 = false; + boolean rsv2 = false; + boolean rsv3 = false; + for (final WebSocketExtension extension : extensions) { + if (extension.usesRsv1()) { + rsv1 = true; + } + if (extension.usesRsv2()) { + rsv2 = true; + } + if (extension.usesRsv3()) { + rsv3 = true; + } + data = extension.encode(type, true, data); + } + writeFrame(type, data, rsv1, rsv2, rsv3); + } + + private void writeFrame( + final WebSocketFrameType type, + final ByteBuffer payload, + final boolean rsv1, + final boolean rsv2, + final boolean rsv3) throws IOException { + Args.notNull(type, "Frame type"); + final ByteBuffer buffer = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0); + final int payloadLen = buffer.remaining(); + int firstByte = 0x80 | (type.getOpcode() & 0x0F); + if (rsv1) { + firstByte |= 0x40; + } + if (rsv2) { + firstByte |= 0x20; + } + if (rsv3) { + firstByte |= 0x10; + } + outputStream.write(firstByte); + if (payloadLen <= 125) { + outputStream.write(payloadLen); + } else if (payloadLen <= 0xFFFF) { + outputStream.write(126); + outputStream.write((payloadLen >> 8) & 0xFF); + outputStream.write(payloadLen & 0xFF); + } else { + outputStream.write(127); + for (int i = 7; i >= 0; i--) { + outputStream.write((int) (((long) payloadLen >> (i * 8)) & 0xFF)); + } + } + if (payloadLen > 0) { + final byte[] data = new byte[payloadLen]; + buffer.get(data); + outputStream.write(data); + } + outputStream.flush(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java new file mode 100644 index 0000000000..1383dbf4a1 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java @@ -0,0 +1,58 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.nio.ByteBuffer; +import java.util.List; + +public interface WebSocketHandler { + + default String selectSubprotocol(final List offered) { + return null; + } + + default void onOpen(final WebSocketSession session) { + } + + default void onText(final WebSocketSession session, final String text) throws WebSocketException { + } + + default void onBinary(final WebSocketSession session, final ByteBuffer data) throws WebSocketException { + } + + default void onPing(final WebSocketSession session, final ByteBuffer data) throws WebSocketException { + } + + default void onPong(final WebSocketSession session, final ByteBuffer data) throws WebSocketException { + } + + default void onClose(final WebSocketSession session, final int statusCode, final String reason) { + } + + default void onError(final WebSocketSession session, final Exception cause) { + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java new file mode 100644 index 0000000000..e2dac5f982 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java @@ -0,0 +1,107 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TextUtils; + +public final class WebSocketHandshake { + + private static final String SUPPORTED_VERSION = "13"; + + private WebSocketHandshake() { + } + + public static boolean isWebSocketUpgrade(final HttpRequest request) { + if (request == null || request.getMethod() == null) { + return false; + } + if (!Method.GET.isSame(request.getMethod())) { + return false; + } + if (!containsToken(request, HttpHeaders.CONNECTION, "Upgrade")) { + return false; + } + final Header upgradeHeader = request.getFirstHeader(HttpHeaders.UPGRADE); + if (upgradeHeader == null || !"websocket".equalsIgnoreCase(upgradeHeader.getValue())) { + return false; + } + final Header versionHeader = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION); + if (versionHeader == null || !SUPPORTED_VERSION.equals(versionHeader.getValue())) { + return false; + } + final Header keyHeader = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_KEY); + return keyHeader != null && !TextUtils.isBlank(keyHeader.getValue()); + } + + public static String createAcceptKey(final String key) throws WebSocketException { + try { + Args.notBlank(key, "WebSocket key"); + final MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + final String acceptSource = key.trim() + WebSocketConstants.WEBSOCKET_GUID; + final byte[] digest = sha1.digest(acceptSource.getBytes(StandardCharsets.ISO_8859_1)); + return Base64.getEncoder().encodeToString(digest); + } catch (final Exception ex) { + throw new WebSocketException("Unable to compute Sec-WebSocket-Accept", ex); + } + } + + public static List parseSubprotocols(final Header header) { + final List protocols = new ArrayList<>(); + if (header == null) { + return protocols; + } + for (final String token : MessageSupport.parseTokens(header)) { + if (!TextUtils.isBlank(token)) { + protocols.add(token); + } + } + return protocols; + } + + private static boolean containsToken(final HttpRequest request, final String headerName, final String token) { + for (final Header hdr : request.getHeaders(headerName)) { + for (final String value : MessageSupport.parseTokens(hdr)) { + if (token.equalsIgnoreCase(value)) { + return true; + } + } + } + return false; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java new file mode 100644 index 0000000000..4fe9e78f60 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java @@ -0,0 +1,162 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; + +public final class WebSocketSession { + + private final WebSocketConfig config; + private final InputStream inputStream; + private final OutputStream outputStream; + private final SocketAddress remoteAddress; + private final SocketAddress localAddress; + private final WebSocketFrameReader reader; + private final WebSocketFrameWriter writer; + private final ReentrantLock writeLock = new ReentrantLock(); + private volatile boolean closeSent; + + public WebSocketSession( + final WebSocketConfig config, + final InputStream inputStream, + final OutputStream outputStream, + final SocketAddress remoteAddress, + final SocketAddress localAddress, + final List extensions) { + this.config = config != null ? config : WebSocketConfig.DEFAULT; + this.inputStream = Args.notNull(inputStream, "Input stream"); + this.outputStream = Args.notNull(outputStream, "Output stream"); + this.remoteAddress = remoteAddress; + this.localAddress = localAddress; + final List negotiated = extensions != null ? extensions : Collections.emptyList(); + this.reader = new WebSocketFrameReader(this.config, this.inputStream, negotiated); + this.writer = new WebSocketFrameWriter(this.outputStream, negotiated); + this.closeSent = false; + } + + public SocketAddress getRemoteAddress() { + return remoteAddress; + } + + public SocketAddress getLocalAddress() { + return localAddress; + } + + public WebSocketFrame readFrame() throws IOException { + return reader.readFrame(); + } + + public void sendText(final String text) throws IOException, WebSocketException { + Args.notNull(text, "Text"); + writeLock.lock(); + try { + writer.writeText(text); + } finally { + writeLock.unlock(); + } + } + + public void sendBinary(final ByteBuffer data) throws IOException, WebSocketException { + Args.notNull(data, "Binary payload"); + writeLock.lock(); + try { + writer.writeBinary(data); + } finally { + writeLock.unlock(); + } + } + + public void sendPing(final ByteBuffer data) throws IOException { + writeLock.lock(); + try { + writer.writePing(data); + } finally { + writeLock.unlock(); + } + } + + public void sendPong(final ByteBuffer data) throws IOException { + writeLock.lock(); + try { + writer.writePong(data); + } finally { + writeLock.unlock(); + } + } + + public void close(final int statusCode, final String reason) throws IOException { + writeLock.lock(); + try { + if (!closeSent) { + writer.writeClose(statusCode, reason); + closeSent = true; + } + } finally { + writeLock.unlock(); + } + } + + public void close(final WebSocketCloseStatus status) throws IOException { + final int code = status != null ? status.getCode() : WebSocketCloseStatus.NORMAL.getCode(); + close(code, ""); + } + + public void closeQuietly() { + try { + close(WebSocketCloseStatus.NORMAL.getCode(), ""); + } catch (final IOException ignore) { + // ignore + } + } + + public static String decodeText(final ByteBuffer payload) throws WebSocketException { + try { + final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + final CharBuffer chars = decoder.decode(payload.asReadOnlyBuffer()); + return chars.toString(); + } catch (final CharacterCodingException ex) { + throw new WebSocketProtocolException(1007, "Invalid UTF-8 payload"); + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java new file mode 100644 index 0000000000..85d708d9d9 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.exceptions; + + +import org.apache.hc.core5.annotation.Internal; + +@Internal +public final class WebSocketProtocolException extends RuntimeException { + + public final int closeCode; + + public WebSocketProtocolException(final int closeCode, final String message) { + super(message); + this.closeCode = closeCode; + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java new file mode 100644 index 0000000000..d68edcf812 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * WebSocket-specific exception types. + * + *

Primarily used to signal protocol-level errors with associated + * close codes.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.exceptions; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java new file mode 100644 index 0000000000..03190a9eb4 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.extension; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Simple single-step chain; if multiple extensions are added they are applied in order. + * Only the FIRST extension can contribute the RSV bit (RSV1 in practice). + */ +@Internal +public final class ExtensionChain { + private final List exts = new ArrayList<>(); + + public void add(final WebSocketExtensionChain e) { + if (e != null) { + exts.add(e); + } + } + + public boolean isEmpty() { + return exts.isEmpty(); + } + + /** + * RSV bits used by the first extension in the chain (if any). + * Only the first extension may contribute RSV bits. + */ + public int rsvMask() { + if (exts.isEmpty()) { + return 0; + } + return exts.get(0).rsvMask(); + } + + /** + * App-thread encoder chain. + */ + public EncodeChain newEncodeChain() { + final List encs = new ArrayList<>(exts.size()); + for (final WebSocketExtensionChain e : exts) { + encs.add(e.newEncoder()); + } + return new EncodeChain(encs); + } + + /** + * I/O-thread decoder chain. + */ + public DecodeChain newDecodeChain() { + final List decs = new ArrayList<>(exts.size()); + for (final WebSocketExtensionChain e : exts) { + decs.add(e.newDecoder()); + } + return new DecodeChain(decs); + } + + // ---------------------- + + public static final class EncodeChain { + private final List encs; + + public EncodeChain(final List encs) { + this.encs = encs; + } + + /** + * Encode one fragment through the chain; note RSV flag for the first extension. + * Returns {@link WebSocketExtensionChain.Encoded}. + */ + public WebSocketExtensionChain.Encoded encode(final byte[] data, final boolean first, final boolean fin) { + if (encs.isEmpty()) { + return new WebSocketExtensionChain.Encoded(data, false); + } + byte[] out = data; + boolean setRsv1 = false; + boolean firstExt = true; + for (final WebSocketExtensionChain.Encoder e : encs) { + final WebSocketExtensionChain.Encoded res = e.encode(out, first, fin); + out = res.payload; + if (first && firstExt && res.setRsvOnFirst) { + setRsv1 = true; + } + firstExt = false; + } + return new WebSocketExtensionChain.Encoded(out, setRsv1); + } + } + + public static final class DecodeChain { + private final List decs; + + public DecodeChain(final List decs) { + this.decs = decs; + } + + /** + * Decode a full message (reverse order if stacking). + */ + public byte[] decode(final byte[] data) throws Exception { + byte[] out = data; + for (int i = decs.size() - 1; i >= 0; i--) { + out = decs.get(i).decode(out); + } + return out; + } + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java new file mode 100644 index 0000000000..a8ee077433 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java @@ -0,0 +1,195 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.extension; + +import java.io.ByteArrayOutputStream; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; + +/** + * permessage-deflate (RFC 7692). + * + *

Window bit parameters are negotiated during the handshake: + * {@code client_max_window_bits} limits the client's compression window (client->server), + * while {@code server_max_window_bits} limits the server's compression window (server->client). + * The decoder can accept any server window size (8..15). The encoder currently requires + * {@code client_max_window_bits} to be 15, due to JDK Deflater limitations.

+ */ +@Internal +public final class PerMessageDeflate implements WebSocketExtensionChain { + private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF}; + + private final boolean enabled; + private final boolean serverNoContextTakeover; + private final boolean clientNoContextTakeover; + private final Integer clientMaxWindowBits; // negotiated or null + private final Integer serverMaxWindowBits; // negotiated or null + + public PerMessageDeflate(final boolean enabled, + final boolean serverNoContextTakeover, + final boolean clientNoContextTakeover, + final Integer clientMaxWindowBits, + final Integer serverMaxWindowBits) { + this.enabled = enabled; + this.serverNoContextTakeover = serverNoContextTakeover; + this.clientNoContextTakeover = clientNoContextTakeover; + this.clientMaxWindowBits = clientMaxWindowBits; + this.serverMaxWindowBits = serverMaxWindowBits; + } + + @Override + public int rsvMask() { + return FrameHeaderBits.RSV1; + } + + @Override + public Encoder newEncoder() { + if (!enabled) { + return (data, first, fin) -> new Encoded(data, false); + } + return new Encoder() { + private final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true); // raw DEFLATE + + @Override + public Encoded encode(final byte[] data, final boolean first, final boolean fin) { + final byte[] out = first && fin + ? compressMessage(data) + : compressFragment(data, fin); + // RSV1 on first compressed data frame only + return new Encoded(out, first); + } + + private byte[] compressMessage(final byte[] data) { + return doDeflate(data, true, true, clientNoContextTakeover); + } + + private byte[] compressFragment(final byte[] data, final boolean fin) { + return doDeflate(data, fin, true, fin && clientNoContextTakeover); + } + + private byte[] doDeflate(final byte[] data, + final boolean fin, + final boolean stripTail, + final boolean maybeReset) { + if (data == null || data.length == 0) { + if (fin && maybeReset) { + def.reset(); + } + return new byte[0]; + } + def.setInput(data); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, data.length / 2)); + final byte[] buf = new byte[8192]; + while (!def.needsInput()) { + final int n = def.deflate(buf, 0, buf.length, Deflater.SYNC_FLUSH); + if (n > 0) { + out.write(buf, 0, n); + } else { + break; + } + } + byte[] all = out.toByteArray(); + if (stripTail && all.length >= 4) { + final int newLen = all.length - 4; // strip 00 00 FF FF + if (newLen <= 0) { + all = new byte[0]; + } else { + final byte[] trimmed = new byte[newLen]; + System.arraycopy(all, 0, trimmed, 0, newLen); + all = trimmed; + } + } + if (fin && maybeReset) { + def.reset(); + } + return all; + } + }; + } + + @Override + public Decoder newDecoder() { + if (!enabled) { + return payload -> payload; + } + return new Decoder() { + private final Inflater inf = new Inflater(true); + + @Override + public byte[] decode(final byte[] compressedMessage) throws Exception { + final byte[] withTail; + if (compressedMessage == null || compressedMessage.length == 0) { + withTail = TAIL.clone(); + } else { + withTail = new byte[compressedMessage.length + 4]; + System.arraycopy(compressedMessage, 0, withTail, 0, compressedMessage.length); + System.arraycopy(TAIL, 0, withTail, compressedMessage.length, 4); + } + + inf.setInput(withTail); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, withTail.length * 2)); + final byte[] buf = new byte[8192]; + while (!inf.needsInput()) { + final int n = inf.inflate(buf); + if (n > 0) { + out.write(buf, 0, n); + } else { + break; + } + } + if (serverNoContextTakeover) { + inf.reset(); + } + return out.toByteArray(); + } + }; + } + + // optional getters for logging/tests + public boolean isEnabled() { + return enabled; + } + + public boolean isServerNoContextTakeover() { + return serverNoContextTakeover; + } + + public boolean isClientNoContextTakeover() { + return clientNoContextTakeover; + } + + public Integer getClientMaxWindowBits() { + return clientMaxWindowBits; + } + + public Integer getServerMaxWindowBits() { + return serverMaxWindowBits; + } +} \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java new file mode 100644 index 0000000000..006ee8eeab --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.extension; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Generic extension hook for payload transform (e.g., permessage-deflate). + * Implementations may return RSV mask (usually RSV1) and indicate whether + * the first frame of a message should set RSV. + */ +@Internal +public interface WebSocketExtensionChain { + + /** + * RSV bits this extension uses on the first data frame (e.g. 0x40 for RSV1). + */ + int rsvMask(); + + /** + * Create a thread-confined encoder instance (app thread). + */ + Encoder newEncoder(); + + /** + * Create a thread-confined decoder instance (I/O thread). + */ + Decoder newDecoder(); + + /** + * Encoded fragment result. + */ + final class Encoded { + public final byte[] payload; + public final boolean setRsvOnFirst; + + public Encoded(final byte[] payload, final boolean setRsvOnFirst) { + this.payload = payload; + this.setRsvOnFirst = setRsvOnFirst; + } + } + + interface Encoder { + /** + * Encode one fragment; return transformed payload and whether to set RSV on FIRST frame. + */ + Encoded encode(byte[] data, boolean first, boolean fin); + } + + interface Decoder { + /** + * Decode a full message produced with this extension. + */ + byte[] decode(byte[] payload) throws Exception; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java new file mode 100644 index 0000000000..ab6f4cae5a --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * WebSocket extension SPI and implementations. + * + *

Includes the generic {@code Extension} SPI, chaining support, and a + * permessage-deflate (RFC 7692) implementation.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.extension; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java new file mode 100644 index 0000000000..7b72942d48 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java @@ -0,0 +1,49 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.frame; + +import org.apache.hc.core5.annotation.Internal; + +/** + * WebSocket frame header bit masks (RFC 6455 §5.2). + */ +@Internal +public final class FrameHeaderBits { + private FrameHeaderBits() { + } + + // First header byte + public static final int FIN = 0x80; + public static final int RSV1 = 0x40; + public static final int RSV2 = 0x20; + public static final int RSV3 = 0x10; + // low 4 bits (0x0F) are opcode + + // Second header byte + public static final int MASK_BIT = 0x80; // client->server payload mask bit + // low 7 bits (0x7F) are payload len indicator +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java new file mode 100644 index 0000000000..8bcdb9aaa9 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java @@ -0,0 +1,88 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.frame; + +import org.apache.hc.core5.annotation.Internal; + +/** + * RFC 6455 opcode constants + helpers. + */ +@Internal +public final class FrameOpcode { + public static final int CONT = 0x0; + public static final int TEXT = 0x1; + public static final int BINARY = 0x2; + public static final int CLOSE = 0x8; + public static final int PING = 0x9; + public static final int PONG = 0xA; + + private FrameOpcode() { + } + + /** + * Control frames have the high bit set in the low nibble (0x8–0xF). + */ + public static boolean isControl(final int opcode) { + return (opcode & 0x08) != 0; + } + + /** + * Data opcodes (not continuation). + */ + public static boolean isData(final int opcode) { + return opcode == TEXT || opcode == BINARY; + } + + /** + * Continuation opcode. + */ + public static boolean isContinuation(final int opcode) { + return opcode == CONT; + } + + /** + * Optional: human-readable name for debugging. + */ + public static String name(final int opcode) { + switch (opcode) { + case CONT: + return "CONT"; + case TEXT: + return "TEXT"; + case BINARY: + return "BINARY"; + case CLOSE: + return "CLOSE"; + case PING: + return "PING"; + case PONG: + return "PONG"; + default: + return "0x" + Integer.toHexString(opcode); + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java new file mode 100644 index 0000000000..a279217b19 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java @@ -0,0 +1,189 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.frame; + +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.FIN; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.MASK_BIT; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV1; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV2; +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV3; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.websocket.message.CloseCodec; + +/** + * RFC 6455 frame writer with helpers to build into an existing target buffer. + * + * @since 5.7 + */ +@Internal +public final class WebSocketFrameWriter { + + // -- Text/Binary ----------------------------------------------------------- + + public ByteBuffer text(final CharSequence data, final boolean fin) { + final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) + : StandardCharsets.UTF_8.encode(data.toString()); + // Client → server MUST be masked + return frame(FrameOpcode.TEXT, payload, fin, true); + } + + public ByteBuffer binary(final ByteBuffer data, final boolean fin) { + final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer(); + return frame(FrameOpcode.BINARY, payload, fin, true); + } + + // -- Control frames (FIN=true, payload ≤ 125, never compressed) ----------- + + public ByteBuffer ping(final ByteBuffer payloadOrNull) { + final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("PING payload > 125 bytes"); + } + return frame(FrameOpcode.PING, p, true, true); + } + + public ByteBuffer pong(final ByteBuffer payloadOrNull) { + final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("PONG payload > 125 bytes"); + } + return frame(FrameOpcode.PONG, p, true, true); + } + + public ByteBuffer close(final int code, final String reason) { + if (!CloseCodec.isValidToSend(code)) { + throw new IllegalArgumentException("Invalid close code to send: " + code); + } + final String safeReason = CloseCodec.truncateReasonUtf8(reason); + final ByteBuffer reasonBuf = safeReason.isEmpty() + ? ByteBuffer.allocate(0) + : StandardCharsets.UTF_8.encode(safeReason); + + if (reasonBuf.remaining() > 123) { + throw new IllegalArgumentException("Close reason too long (UTF-8 bytes > 123)"); + } + + final ByteBuffer p = ByteBuffer.allocate(2 + reasonBuf.remaining()); + p.put((byte) (code >> 8 & 0xFF)); + p.put((byte) (code & 0xFF)); + if (reasonBuf.hasRemaining()) { + p.put(reasonBuf); + } + p.flip(); + return frame(FrameOpcode.CLOSE, p, true, true); + } + + public ByteBuffer closeEcho(final ByteBuffer payload) { + final ByteBuffer p = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer(); + if (p.remaining() > 125) { + throw new IllegalArgumentException("Close payload > 125 bytes"); + } + return frame(FrameOpcode.CLOSE, p, true, true); + } + + // -- Core framing ---------------------------------------------------------- + + public ByteBuffer frame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean mask) { + return frameWithRSV(opcode, payload, fin, mask, 0); + } + + public ByteBuffer frameWithRSV(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final int rsvBits) { + final int len = payload == null ? 0 : payload.remaining(); + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final int maskLen = mask ? 4 : 0; + final ByteBuffer out = ByteBuffer.allocate(2 + hdrExtra + maskLen + len).order(ByteOrder.BIG_ENDIAN); + frameIntoWithRSV(opcode, payload, fin, mask, rsvBits, out); + out.flip(); + return out; + } + + public ByteBuffer frameInto(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final ByteBuffer out) { + return frameIntoWithRSV(opcode, payload, fin, mask, 0, out); + } + + public ByteBuffer frameIntoWithRSV(final int opcode, final ByteBuffer payload, final boolean fin, + final boolean mask, final int rsvBits, final ByteBuffer out) { + final int len = payload == null ? 0 : payload.remaining(); + + if (FrameOpcode.isControl(opcode)) { + if (!fin) { + throw new IllegalArgumentException("Control frames must not be fragmented (FIN=false)"); + } + if (len > 125) { + throw new IllegalArgumentException("Control frame payload > 125 bytes"); + } + if ((rsvBits & (RSV1 | RSV2 | RSV3)) != 0) { + throw new IllegalArgumentException("RSV bits must be 0 on control frames"); + } + } + + final int finBit = fin ? FIN : 0; + out.put((byte) (finBit | rsvBits & (RSV1 | RSV2 | RSV3) | opcode & 0x0F)); + + if (len <= 125) { + out.put((byte) ((mask ? MASK_BIT : 0) | len)); + } else if (len <= 0xFFFF) { + out.put((byte) ((mask ? MASK_BIT : 0) | 126)); + out.putShort((short) len); + } else { + out.put((byte) ((mask ? MASK_BIT : 0) | 127)); + out.putLong(len & 0x7FFF_FFFF_FFFF_FFFFL); + } + + int[] mkey = null; + if (mask) { + mkey = new int[]{rnd(), rnd(), rnd(), rnd()}; + out.put((byte) mkey[0]).put((byte) mkey[1]).put((byte) mkey[2]).put((byte) mkey[3]); + } + + if (len > 0) { + final ByteBuffer src = payload.asReadOnlyBuffer(); + int i = 0; // simpler, safer mask index + while (src.hasRemaining()) { + int b = src.get() & 0xFF; + if (mask) { + b ^= mkey[i & 3]; + i++; + } + out.put((byte) b); + } + } + return out; + } + + private static int rnd() { + return ThreadLocalRandom.current().nextInt(256); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java new file mode 100644 index 0000000000..d50e6211ee --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Low-level WebSocket frame helpers. + * + *

Opcode constants, header bit masks, and frame writer utilities aligned + * with RFC 6455.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.frame; \ No newline at end of file diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java new file mode 100644 index 0000000000..b7deeaea06 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java @@ -0,0 +1,190 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.message; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Helpers for RFC6455 CLOSE parsing & validation. + */ +@Internal +public final class CloseCodec { + + private CloseCodec() { + } + + + /** + * Reads the close status code from the payload buffer, if present. + * Returns {@code 1005} (“no status code present”) when the payload + * does not contain at least two bytes. + */ + public static int readCloseCode(final ByteBuffer payloadRO) { + if (payloadRO == null || payloadRO.remaining() < 2) { + return 1005; // “no status code present” + } + final int b1 = payloadRO.get() & 0xFF; + final int b2 = payloadRO.get() & 0xFF; + return (b1 << 8) | b2; + } + + /** + * Reads the close reason from the remaining bytes of the payload + * as UTF-8. Returns an empty string if there is no payload left. + */ + public static String readCloseReason(final ByteBuffer payloadRO) { + if (payloadRO == null || !payloadRO.hasRemaining()) { + return ""; + } + final ByteBuffer dup = payloadRO.slice(); + return StandardCharsets.UTF_8.decode(dup).toString(); + } + + // ---- RFC validation (sender & receiver) --------------------------------- + + /** + * RFC 6455 §7.4.2: MUST NOT appear on the wire. + */ + private static boolean isForbiddenOnWire(final int code) { + return code == 1005 || code == 1006 || code == 1015; + } + + /** + * Codes defined by RFC 6455 to send (and likewise valid to receive). + */ + private static boolean isRfcDefined(final int code) { + switch (code) { + case 1000: // normal + case 1001: // going away + case 1002: // protocol error + case 1003: // unsupported data + case 1007: // invalid payload data + case 1008: // policy violation + case 1009: // message too big + case 1010: // mandatory extension + case 1011: // internal error + return true; + default: + return false; + } + } + + /** + * Application/reserved range that may be sent by endpoints. + */ + private static boolean isAppRange(final int code) { + return code >= 3000 && code <= 4999; + } + + /** + * Validate a code we intend to PUT ON THE WIRE (sender-side). + */ + public static boolean isValidToSend(final int code) { + if (code < 0) { + return false; + } + if (isForbiddenOnWire(code)) { + return false; + } + return isRfcDefined(code) || isAppRange(code); + } + + /** + * Validate a code we PARSED FROM THE WIRE (receiver-side). + */ + public static boolean isValidToReceive(final int code) { + // 1005, 1006, 1015 must not appear on the wire + if (isForbiddenOnWire(code)) { + return false; + } + // Same allowed sets otherwise + return isRfcDefined(code) || isAppRange(code); + } + + // ---- Reason handling: max 123 bytes (2 bytes used by code) -------------- + + /** + * Returns a UTF-8 string truncated to ≤ 123 bytes, preserving code-points. + * This ensures that a CLOSE frame payload (2-byte status code + reason) + * never exceeds the 125-byte control frame limit. + */ + public static String truncateReasonUtf8(final String reason) { + if (reason == null || reason.isEmpty()) { + return ""; + } + final byte[] bytes = reason.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= 123) { + return reason; + } + int i = 0; + int byteCount = 0; + while (i < reason.length()) { + final int cp = reason.codePointAt(i); + final int charCount = Character.charCount(cp); + final int extra = new String(Character.toChars(cp)) + .getBytes(StandardCharsets.UTF_8).length; + if (byteCount + extra > 123) { + break; + } + byteCount += extra; + i += charCount; + } + return reason.substring(0, i); + } + + // ---- Encoding ----------------------------------------------------------- + + /** + * Encodes a close status code and reason into a payload suitable for a + * CLOSE control frame: + * + *
+     *   payload[0] = high-byte of status code
+     *   payload[1] = low-byte of status code
+     *   payload[2..] = UTF-8 bytes of the (possibly truncated) reason
+     * 
+ *

+ * The reason is internally truncated to ≤ 123 UTF-8 bytes to ensure the + * resulting payload never exceeds the 125-byte control frame limit. + *

+ * The caller is expected to have already validated the status code with + * {@link #isValidToSend(int)}. + */ + public static byte[] encode(final int statusCode, final String reason) { + final String truncated = truncateReasonUtf8(reason); + final byte[] reasonBytes = truncated.getBytes(StandardCharsets.UTF_8); + // 2 bytes for the status code + final byte[] payload = new byte[2 + reasonBytes.length]; + payload[0] = (byte) ((statusCode >>> 8) & 0xFF); + payload[1] = (byte) (statusCode & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + return payload; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java new file mode 100644 index 0000000000..9169316865 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java @@ -0,0 +1,36 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Message-level helpers and codecs. + * + *

Utilities for parsing and validating message semantics (e.g., CLOSE + * status code and reason handling).

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket.message; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java new file mode 100644 index 0000000000..3394c70dd2 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java @@ -0,0 +1,37 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * WebSocket protocol support and helpers. + * + *

Subpackages such as {@code frame}, {@code extension}, {@code message}, + * {@code util}, and {@code exceptions} are internal implementation details + * and may change without notice.

+ * + * @since 5.7 + */ +package org.apache.hc.core5.websocket; diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java new file mode 100644 index 0000000000..e006c9196b --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java @@ -0,0 +1,35 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +public final class WebSocketContextKeys { + + public static final String CONNECTION = "httpcore.websocket.connection"; + + private WebSocketContextKeys() { + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java new file mode 100644 index 0000000000..0eb40d366c --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java @@ -0,0 +1,100 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ExecutionException; + +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.ListenerEndpoint; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; + +public final class WebSocketH2Server { + + private final HttpAsyncServer server; + private final InetAddress localAddress; + private final int port; + private final URIScheme scheme; + private volatile ListenerEndpoint endpoint; + + WebSocketH2Server(final HttpAsyncServer server, final InetAddress localAddress, final int port, final URIScheme scheme) { + this.server = Args.notNull(server, "server"); + this.localAddress = localAddress; + this.port = port; + this.scheme = scheme != null ? scheme : URIScheme.HTTP; + } + + public void start() throws IOException { + server.start(); + try { + final InetSocketAddress address = localAddress != null + ? new InetSocketAddress(localAddress, Math.max(port, 0)) + : new InetSocketAddress(Math.max(port, 0)); + this.endpoint = server.listen(address, scheme).get(); + } catch (final ExecutionException ex) { + final Throwable cause = ex.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException(ex.getMessage(), ex); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException(ex.getMessage(), ex); + } + } + + public void stop() { + server.close(CloseMode.GRACEFUL); + } + + public void initiateShutdown() { + server.initiateShutdown(); + } + + public InetAddress getInetAddress() { + if (endpoint != null && endpoint.getAddress() instanceof InetSocketAddress) { + return ((InetSocketAddress) endpoint.getAddress()).getAddress(); + } + return localAddress; + } + + public int getLocalPort() { + if (endpoint != null && endpoint.getAddress() instanceof InetSocketAddress) { + return ((InetSocketAddress) endpoint.getAddress()).getPort(); + } + return port; + } + + public void awaitShutdown(final TimeValue waitTime) throws InterruptedException { + server.awaitShutdown(waitTime); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java new file mode 100644 index 0000000000..6156be409f --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java @@ -0,0 +1,230 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +import org.apache.hc.core5.function.Supplier; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.http.impl.routing.RequestRouter; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.H2StreamListener; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2ServerBootstrap; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; + +/** + * Bootstrap for HTTP/2 WebSocket servers using RFC 8441 (Extended CONNECT). + * + * @since 5.7 + */ +public final class WebSocketH2ServerBootstrap { + + private final List>> routeEntries; + private String canonicalHostName; + private int listenerPort; + private InetAddress localAddress; + private IOReactorConfig ioReactorConfig; + private HttpProcessor httpProcessor; + private HttpVersionPolicy versionPolicy; + private H2Config h2Config = H2Config.custom().setPushEnabled(false).build(); + private Http1Config http1Config = Http1Config.DEFAULT; + private CharCodingConfig charCodingConfig; + private TlsStrategy tlsStrategy; + private Timeout handshakeTimeout; + private H2StreamListener h2StreamListener; + private Http1StreamListener http1StreamListener; + private WebSocketConfig webSocketConfig; + private WebSocketExtensionRegistry extensionRegistry; + private Executor executor; + + private WebSocketH2ServerBootstrap() { + this.routeEntries = new ArrayList<>(); + } + + public static WebSocketH2ServerBootstrap bootstrap() { + return new WebSocketH2ServerBootstrap(); + } + + public WebSocketH2ServerBootstrap setCanonicalHostName(final String canonicalHostName) { + this.canonicalHostName = canonicalHostName; + return this; + } + + public WebSocketH2ServerBootstrap setListenerPort(final int listenerPort) { + this.listenerPort = listenerPort; + return this; + } + + public WebSocketH2ServerBootstrap setLocalAddress(final InetAddress localAddress) { + this.localAddress = localAddress; + return this; + } + + public WebSocketH2ServerBootstrap setIOReactorConfig(final IOReactorConfig ioReactorConfig) { + this.ioReactorConfig = ioReactorConfig; + return this; + } + + public WebSocketH2ServerBootstrap setHttpProcessor(final HttpProcessor httpProcessor) { + this.httpProcessor = httpProcessor; + return this; + } + + public WebSocketH2ServerBootstrap setVersionPolicy(final HttpVersionPolicy versionPolicy) { + this.versionPolicy = versionPolicy; + return this; + } + + public WebSocketH2ServerBootstrap setH2Config(final H2Config h2Config) { + if (h2Config == null) { + this.h2Config = H2Config.custom().setPushEnabled(false).build(); + } else if (h2Config.isPushEnabled()) { + this.h2Config = H2Config.copy(h2Config).setPushEnabled(false).build(); + } else { + this.h2Config = h2Config; + } + return this; + } + + public WebSocketH2ServerBootstrap setHttp1Config(final Http1Config http1Config) { + this.http1Config = http1Config != null ? http1Config : Http1Config.DEFAULT; + return this; + } + + public WebSocketH2ServerBootstrap setCharCodingConfig(final CharCodingConfig charCodingConfig) { + this.charCodingConfig = charCodingConfig; + return this; + } + + public WebSocketH2ServerBootstrap setTlsStrategy(final TlsStrategy tlsStrategy) { + this.tlsStrategy = tlsStrategy; + return this; + } + + public WebSocketH2ServerBootstrap setHandshakeTimeout(final Timeout handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + return this; + } + + public WebSocketH2ServerBootstrap setH2StreamListener(final H2StreamListener h2StreamListener) { + this.h2StreamListener = h2StreamListener; + return this; + } + + public WebSocketH2ServerBootstrap setHttp1StreamListener(final Http1StreamListener http1StreamListener) { + this.http1StreamListener = http1StreamListener; + return this; + } + + public WebSocketH2ServerBootstrap setWebSocketConfig(final WebSocketConfig webSocketConfig) { + this.webSocketConfig = webSocketConfig; + return this; + } + + public WebSocketH2ServerBootstrap setExtensionRegistry(final WebSocketExtensionRegistry extensionRegistry) { + this.extensionRegistry = extensionRegistry; + return this; + } + + public WebSocketH2ServerBootstrap setExecutor(final Executor executor) { + this.executor = executor; + return this; + } + + public WebSocketH2ServerBootstrap register(final String uriPattern, final Supplier supplier) { + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(uriPattern, supplier)); + return this; + } + + public WebSocketH2ServerBootstrap register(final String hostname, final String uriPattern, final Supplier supplier) { + Args.notNull(hostname, "Hostname"); + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(hostname, uriPattern, supplier)); + return this; + } + + public WebSocketH2Server create() { + final WebSocketConfig cfg = webSocketConfig != null ? webSocketConfig : WebSocketConfig.DEFAULT; + final WebSocketExtensionRegistry ext = extensionRegistry != null + ? extensionRegistry + : WebSocketExtensionRegistry.createDefault(); + + final H2ServerBootstrap bootstrap = H2ServerBootstrap.bootstrap() + .setCanonicalHostName(canonicalHostName) + .setIOReactorConfig(ioReactorConfig) + .setHttpProcessor(httpProcessor) + .setVersionPolicy(versionPolicy != null ? versionPolicy : HttpVersionPolicy.FORCE_HTTP_2) + .setTlsStrategy(tlsStrategy) + .setHandshakeTimeout(handshakeTimeout) + .setStreamListener(h2StreamListener) + .setStreamListener(http1StreamListener); + + if (h2Config != null) { + bootstrap.setH2Config(h2Config); + } + if (http1Config != null) { + bootstrap.setHttp1Config(http1Config); + } + + if (charCodingConfig != null) { + bootstrap.setCharset(charCodingConfig); + } + + for (final RequestRouter.Entry> entry : routeEntries) { + final Supplier exchangeSupplier = () -> + new WebSocketH2ServerExchangeHandler(entry.route.handler.get(), cfg, ext, executor); + if (entry.uriAuthority != null) { + bootstrap.register(entry.uriAuthority.getHostName(), entry.route.pattern, exchangeSupplier); + } else { + bootstrap.register(entry.route.pattern, exchangeSupplier); + } + } + + final HttpAsyncServer server = bootstrap.create(); + final URIScheme scheme = tlsStrategy != null ? URIScheme.HTTPS : URIScheme.HTTP; + return new WebSocketH2Server(server, localAddress, listenerPort, scheme); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java new file mode 100644 index 0000000000..dad8631510 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java @@ -0,0 +1,379 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.core5.concurrent.DefaultThreadFactory; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.impl.BasicEntityDetails; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.ResponseChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketCloseStatus; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketExtensionNegotiation; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketExtensions; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketHandshake; +import org.apache.hc.core5.websocket.WebSocketSession; + +final class WebSocketH2ServerExchangeHandler implements AsyncServerExchangeHandler { + + private static final byte[] END_INBOUND = new byte[0]; + private static final ByteBuffer END_OUTBOUND = ByteBuffer.allocate(0); + + /** + * Default execution strategy (no explicit thread creation in the handler). + * Note: tasks are typically long-lived (one per WS session). The bootstrap should ideally inject an executor. + */ + private static final Executor DEFAULT_EXECUTOR = + Executors.newCachedThreadPool(new DefaultThreadFactory("ws-h2-server", true)); + + private final WebSocketHandler handler; + private final WebSocketConfig config; + private final WebSocketExtensionRegistry extensionRegistry; + private final Executor executor; + + private final BlockingQueue inbound = new LinkedBlockingQueue<>(); + private final BlockingQueue outbound = new LinkedBlockingQueue<>(); + + private final ReentrantLock outLock = new ReentrantLock(); + private ByteBuffer currentOutbound; + + private volatile boolean responseSent; + private volatile boolean outboundEnd; + private volatile boolean shutdown; + private volatile DataStreamChannel dataChannel; + + WebSocketH2ServerExchangeHandler( + final WebSocketHandler handler, + final WebSocketConfig config, + final WebSocketExtensionRegistry extensionRegistry) { + this(handler, config, extensionRegistry, null); + } + + WebSocketH2ServerExchangeHandler( + final WebSocketHandler handler, + final WebSocketConfig config, + final WebSocketExtensionRegistry extensionRegistry, + final Executor executor) { + this.handler = Args.notNull(handler, "WebSocket handler"); + this.config = config != null ? config : WebSocketConfig.DEFAULT; + this.extensionRegistry = extensionRegistry != null ? extensionRegistry : WebSocketExtensionRegistry.createDefault(); + this.executor = executor != null ? executor : DEFAULT_EXECUTOR; + this.responseSent = false; + this.outboundEnd = false; + this.shutdown = false; + } + + @Override + public void handleRequest( + final HttpRequest request, + final EntityDetails entityDetails, + final ResponseChannel responseChannel, + final HttpContext context) throws HttpException, IOException { + + if (!Method.CONNECT.isSame(request.getMethod())) { + responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_BAD_REQUEST), null, context); + return; + } + + final String protocol = request.getFirstHeader(WebSocketConstants.PSEUDO_PROTOCOL) != null + ? request.getFirstHeader(WebSocketConstants.PSEUDO_PROTOCOL).getValue() + : null; + if (!"websocket".equalsIgnoreCase(protocol)) { + responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_BAD_REQUEST), null, context); + return; + } + + final WebSocketExtensionNegotiation negotiation = extensionRegistry.negotiate( + WebSocketExtensions.parse(request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER)), + true); + + final BasicHttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK); + final String extensionsHeader = negotiation.formatResponseHeader(); + if (extensionsHeader != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER, extensionsHeader); + } + + final List offeredProtocols = WebSocketHandshake.parseSubprotocols( + request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER)); + final String protocolResponse = handler.selectSubprotocol(offeredProtocols); + if (protocolResponse != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER, protocolResponse); + } + + responseChannel.sendResponse(response, new BasicEntityDetails(-1, null), context); + responseSent = true; + + final InputStream inputStream = new QueueInputStream(inbound); + final OutputStream outputStream = new QueueOutputStream(outbound); + final WebSocketSession session = new WebSocketSession( + config, inputStream, outputStream, null, null, negotiation.getExtensions()); + + executor.execute(() -> { + try { + handler.onOpen(session); + new WebSocketServerProcessor(session, handler, config.getMaxMessageSize()).process(); + } catch (final Exception ex) { + handler.onError(session, ex); + try { + session.close(WebSocketCloseStatus.INTERNAL_ERROR.getCode(), "WebSocket error"); + } catch (final IOException ignore) { + // ignore + } + } finally { + shutdown = true; + outbound.offer(END_OUTBOUND); + inbound.offer(END_INBOUND); + + final DataStreamChannel channel = dataChannel; + if (channel != null) { + channel.requestOutput(); + } + } + }); + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(final ByteBuffer src) throws IOException { + if (src == null || !src.hasRemaining() || shutdown) { + return; + } + final byte[] data = new byte[src.remaining()]; + src.get(data); + inbound.offer(data); + } + + @Override + public void streamEnd(final List trailers) throws HttpException, IOException { + inbound.offer(END_INBOUND); + } + + @Override + public int available() { + if (!responseSent || outboundEnd) { + return 0; + } + final ByteBuffer next; + outLock.lock(); + try { + next = currentOutbound != null ? currentOutbound : outbound.peek(); + } finally { + outLock.unlock(); + } + if (next == null) { + return 0; + } + if (next == END_OUTBOUND) { + // Force produce() so we can emit END_STREAM. + return 1; + } + return next.remaining(); + } + + @Override + public void produce(final DataStreamChannel channel) throws IOException { + if (!responseSent || outboundEnd) { + return; + } + this.dataChannel = channel; + + for (; ; ) { + final ByteBuffer buf; + outLock.lock(); + try { + if (currentOutbound == null) { + currentOutbound = outbound.poll(); + } + buf = currentOutbound; + } finally { + outLock.unlock(); + } + if (buf == null) { + return; + } + + if (buf == END_OUTBOUND) { + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + outboundEnd = true; + channel.endStream(null); + return; + } + + if (!buf.hasRemaining()) { + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + continue; + } + + final int n = channel.write(buf); + if (n == 0) { + channel.requestOutput(); + return; + } + if (buf.hasRemaining()) { + channel.requestOutput(); + return; + } + + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + } + } + + @Override + public void failed(final Exception cause) { + shutdown = true; + outbound.offer(END_OUTBOUND); + inbound.offer(END_INBOUND); + + final DataStreamChannel channel = dataChannel; + if (channel != null) { + channel.requestOutput(); + } + } + + @Override + public void releaseResources() { + shutdown = true; + outbound.clear(); + inbound.clear(); + outLock.lock(); + try { + currentOutbound = null; + } finally { + outLock.unlock(); + } + } + + private static final class QueueInputStream extends InputStream { + + private final BlockingQueue queue; + private byte[] current; + private int pos; + + QueueInputStream(final BlockingQueue queue) { + this.queue = queue; + } + + @Override + public int read() throws IOException { + if (current == null || pos >= current.length) { + try { + current = queue.take(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException(ex.getMessage(), ex); + } + pos = 0; + if (current == END_INBOUND) { + return -1; + } + } + return current[pos++] & 0xFF; + } + } + + private final class QueueOutputStream extends OutputStream { + + private final BlockingQueue queue; + + QueueOutputStream(final BlockingQueue queue) { + this.queue = queue; + } + + @Override + public void write(final int b) throws IOException { + queue.offer(ByteBuffer.wrap(new byte[]{(byte) b})); + requestOutput(); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + if (len == 0) { + return; + } + final byte[] copy = new byte[len]; + System.arraycopy(b, off, copy, 0, len); + queue.offer(ByteBuffer.wrap(copy)); + requestOutput(); + } + + @Override + public void close() { + queue.offer(END_OUTBOUND); + requestOutput(); + } + + private void requestOutput() { + final DataStreamChannel channel = dataChannel; + if (responseSent && channel != null) { + channel.requestOutput(); + } + } + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java new file mode 100644 index 0000000000..3caee56c7d --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; + +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.impl.DefaultConnectionReuseStrategy; +import org.apache.hc.core5.http.impl.io.HttpService; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.io.HttpServerConnection; +import org.apache.hc.core5.http.io.HttpServerRequestHandler; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.ConnectionReuseStrategy; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.util.Args; + +class WebSocketHttpService extends HttpService { + + WebSocketHttpService( + final HttpProcessor processor, + final HttpServerRequestHandler requestHandler, + final Http1Config http1Config, + final ConnectionReuseStrategy connReuseStrategy, + final Http1StreamListener streamListener) { + super( + Args.notNull(processor, "HTTP processor"), + Args.notNull(requestHandler, "Request handler"), + http1Config != null ? http1Config : Http1Config.DEFAULT, + connReuseStrategy != null ? connReuseStrategy : DefaultConnectionReuseStrategy.INSTANCE, + streamListener); + } + + @Override + public void handleRequest( + final HttpServerConnection conn, + final HttpContext localContext) throws IOException, HttpException { + if (localContext != null) { + localContext.setAttribute(WebSocketContextKeys.CONNECTION, conn); + } + super.handleRequest(conn, localContext); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java new file mode 100644 index 0000000000..eacb15e118 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java @@ -0,0 +1,61 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.net.InetAddress; + +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; + +public final class WebSocketServer { + + private final HttpServer httpServer; + + WebSocketServer(final HttpServer httpServer) { + this.httpServer = httpServer; + } + + public void start() throws IOException { + httpServer.start(); + } + + public void stop() { + httpServer.stop(); + } + + public void initiateShutdown() { + httpServer.initiateShutdown(); + } + + public InetAddress getInetAddress() { + return httpServer.getInetAddress(); + } + + public int getLocalPort() { + return httpServer.getLocalPort(); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java new file mode 100644 index 0000000000..8c7001dcb8 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java @@ -0,0 +1,250 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import javax.net.ServerSocketFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; + +import org.apache.hc.core5.function.Callback; +import org.apache.hc.core5.http.ConnectionReuseStrategy; +import org.apache.hc.core5.http.ExceptionListener; +import org.apache.hc.core5.http.HttpRequestMapper; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.DefaultConnectionReuseStrategy; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.routing.RequestRouter; +import org.apache.hc.core5.http.io.HttpConnectionFactory; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.http.io.ssl.DefaultTlsSetupHandler; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.protocol.UriPatternType; +import org.apache.hc.core5.net.InetAddressUtils; +import org.apache.hc.core5.net.URIAuthority; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; + +public class WebSocketServerBootstrap { + + private final List>> routeEntries; + private String canonicalHostName; + private int listenerPort; + private InetAddress localAddress; + private SocketConfig socketConfig; + private Http1Config http1Config; + private CharCodingConfig charCodingConfig; + private HttpProcessor httpProcessor; + private ConnectionReuseStrategy connStrategy; + private ServerSocketFactory serverSocketFactory; + private SSLContext sslContext; + private Callback sslSetupHandler; + private ExceptionListener exceptionListener; + private Http1StreamListener streamListener; + private BiFunction authorityResolver; + private HttpRequestMapper> requestRouter; + private WebSocketConfig webSocketConfig; + private HttpConnectionFactory connectionFactory; + private WebSocketExtensionRegistry extensionRegistry; + + private WebSocketServerBootstrap() { + this.routeEntries = new ArrayList<>(); + } + + public static WebSocketServerBootstrap bootstrap() { + return new WebSocketServerBootstrap(); + } + + public WebSocketServerBootstrap setCanonicalHostName(final String canonicalHostName) { + this.canonicalHostName = canonicalHostName; + return this; + } + + public WebSocketServerBootstrap setListenerPort(final int listenerPort) { + this.listenerPort = listenerPort; + return this; + } + + public WebSocketServerBootstrap setLocalAddress(final InetAddress localAddress) { + this.localAddress = localAddress; + return this; + } + + public WebSocketServerBootstrap setSocketConfig(final SocketConfig socketConfig) { + this.socketConfig = socketConfig; + return this; + } + + public WebSocketServerBootstrap setHttp1Config(final Http1Config http1Config) { + this.http1Config = http1Config; + return this; + } + + public WebSocketServerBootstrap setCharCodingConfig(final CharCodingConfig charCodingConfig) { + this.charCodingConfig = charCodingConfig; + return this; + } + + public WebSocketServerBootstrap setHttpProcessor(final HttpProcessor httpProcessor) { + this.httpProcessor = httpProcessor; + return this; + } + + public WebSocketServerBootstrap setConnectionReuseStrategy(final ConnectionReuseStrategy connStrategy) { + this.connStrategy = connStrategy; + return this; + } + + public WebSocketServerBootstrap setServerSocketFactory(final ServerSocketFactory serverSocketFactory) { + this.serverSocketFactory = serverSocketFactory; + return this; + } + + public WebSocketServerBootstrap setSslContext(final SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + public WebSocketServerBootstrap setSslSetupHandler(final Callback sslSetupHandler) { + this.sslSetupHandler = sslSetupHandler; + return this; + } + + public WebSocketServerBootstrap setExceptionListener(final ExceptionListener exceptionListener) { + this.exceptionListener = exceptionListener; + return this; + } + + public WebSocketServerBootstrap setStreamListener(final Http1StreamListener streamListener) { + this.streamListener = streamListener; + return this; + } + + public WebSocketServerBootstrap setAuthorityResolver(final BiFunction authorityResolver) { + this.authorityResolver = authorityResolver; + return this; + } + + public WebSocketServerBootstrap setRequestRouter(final HttpRequestMapper> requestRouter) { + this.requestRouter = requestRouter; + return this; + } + + public WebSocketServerBootstrap setWebSocketConfig(final WebSocketConfig webSocketConfig) { + this.webSocketConfig = webSocketConfig; + return this; + } + + public WebSocketServerBootstrap setExtensionRegistry(final WebSocketExtensionRegistry extensionRegistry) { + this.extensionRegistry = extensionRegistry; + return this; + } + + public WebSocketServerBootstrap setConnectionFactory( + final HttpConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + return this; + } + + public WebSocketServerBootstrap register(final String uriPattern, final Supplier supplier) { + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(uriPattern, supplier)); + return this; + } + + public WebSocketServerBootstrap register(final String hostname, final String uriPattern, final Supplier supplier) { + Args.notNull(hostname, "Hostname"); + Args.notNull(uriPattern, "URI pattern"); + Args.notNull(supplier, "WebSocket handler supplier"); + this.routeEntries.add(new RequestRouter.Entry<>(hostname, uriPattern, supplier)); + return this; + } + + public WebSocketServer create() { + final String actualCanonicalHostName = canonicalHostName != null ? canonicalHostName : InetAddressUtils.getCanonicalLocalHostName(); + final HttpRequestMapper> requestRouterCopy; + if (routeEntries.isEmpty()) { + requestRouterCopy = requestRouter; + } else { + requestRouterCopy = RequestRouter.create( + new URIAuthority(actualCanonicalHostName), + UriPatternType.URI_PATTERN, + routeEntries, + authorityResolver != null ? authorityResolver : RequestRouter.IGNORE_PORT_AUTHORITY_RESOLVER, + requestRouter); + } + final HttpRequestMapper> router = requestRouterCopy != null ? requestRouterCopy : (r, c) -> null; + + final WebSocketExtensionRegistry extensions = extensionRegistry != null + ? extensionRegistry + : WebSocketExtensionRegistry.createDefault(); + final WebSocketServerRequestHandler requestHandler = new WebSocketServerRequestHandler( + router, + webSocketConfig != null ? webSocketConfig : WebSocketConfig.DEFAULT, + extensions); + + final HttpProcessor processor = httpProcessor != null ? httpProcessor : HttpProcessors.server(); + final WebSocketHttpService httpService = new WebSocketHttpService( + processor, + requestHandler, + http1Config, + connStrategy != null ? connStrategy : DefaultConnectionReuseStrategy.INSTANCE, + streamListener); + + HttpConnectionFactory connectionFactoryCopy = this.connectionFactory; + if (connectionFactoryCopy == null) { + final String scheme = serverSocketFactory instanceof SSLServerSocketFactory || sslContext != null ? + URIScheme.HTTPS.id : URIScheme.HTTP.id; + connectionFactoryCopy = new WebSocketServerConnectionFactory(scheme, http1Config, charCodingConfig); + } + + final HttpServer httpServer = new HttpServer( + Math.max(this.listenerPort, 0), + httpService, + this.localAddress, + this.socketConfig != null ? this.socketConfig : SocketConfig.DEFAULT, + serverSocketFactory, + connectionFactoryCopy, + sslContext, + sslSetupHandler != null ? sslSetupHandler : DefaultTlsSetupHandler.SERVER, + this.exceptionListener != null ? this.exceptionListener : ExceptionListener.NO_OP); + return new WebSocketServer(httpServer); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java new file mode 100644 index 0000000000..ac0cc48aee --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CharsetEncoder; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentLengthStrategy; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.io.DefaultBHttpServerConnection; +import org.apache.hc.core5.http.impl.io.SocketHolder; +import org.apache.hc.core5.http.io.HttpMessageParserFactory; +import org.apache.hc.core5.http.io.HttpMessageWriterFactory; + +class WebSocketServerConnection extends DefaultBHttpServerConnection { + + WebSocketServerConnection( + final String scheme, + final Http1Config http1Config, + final CharsetDecoder charDecoder, + final CharsetEncoder charEncoder, + final ContentLengthStrategy incomingContentStrategy, + final ContentLengthStrategy outgoingContentStrategy, + final HttpMessageParserFactory requestParserFactory, + final HttpMessageWriterFactory responseWriterFactory) { + super(scheme, http1Config, charDecoder, charEncoder, incomingContentStrategy, outgoingContentStrategy, + requestParserFactory, responseWriterFactory); + } + + InputStream getSocketInputStream() throws IOException { + final SocketHolder socketHolder = getSocketHolder(); + return socketHolder != null ? socketHolder.getInputStream() : null; + } + + OutputStream getSocketOutputStream() throws IOException { + final SocketHolder socketHolder = getSocketHolder(); + return socketHolder != null ? socketHolder.getOutputStream() : null; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java new file mode 100644 index 0000000000..51e466b6ed --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java @@ -0,0 +1,105 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.net.Socket; + +import javax.net.ssl.SSLSocket; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentLengthStrategy; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.CharCodingSupport; +import org.apache.hc.core5.http.impl.io.DefaultHttpRequestParserFactory; +import org.apache.hc.core5.http.impl.io.DefaultHttpResponseWriterFactory; +import org.apache.hc.core5.http.io.HttpConnectionFactory; +import org.apache.hc.core5.http.io.HttpMessageParserFactory; +import org.apache.hc.core5.http.io.HttpMessageWriterFactory; + +class WebSocketServerConnectionFactory implements HttpConnectionFactory { + + private final String scheme; + private final Http1Config http1Config; + private final CharCodingConfig charCodingConfig; + private final ContentLengthStrategy incomingContentStrategy; + private final ContentLengthStrategy outgoingContentStrategy; + private final HttpMessageParserFactory requestParserFactory; + private final HttpMessageWriterFactory responseWriterFactory; + + WebSocketServerConnectionFactory( + final String scheme, + final Http1Config http1Config, + final CharCodingConfig charCodingConfig, + final ContentLengthStrategy incomingContentStrategy, + final ContentLengthStrategy outgoingContentStrategy, + final HttpMessageParserFactory requestParserFactory, + final HttpMessageWriterFactory responseWriterFactory) { + this.scheme = scheme; + this.http1Config = http1Config != null ? http1Config : Http1Config.DEFAULT; + this.charCodingConfig = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT; + this.incomingContentStrategy = incomingContentStrategy; + this.outgoingContentStrategy = outgoingContentStrategy; + this.requestParserFactory = requestParserFactory != null ? requestParserFactory : + new DefaultHttpRequestParserFactory(this.http1Config); + this.responseWriterFactory = responseWriterFactory != null ? responseWriterFactory : + new DefaultHttpResponseWriterFactory(this.http1Config); + } + + WebSocketServerConnectionFactory(final String scheme, final Http1Config http1Config, final CharCodingConfig charCodingConfig) { + this(scheme, http1Config, charCodingConfig, null, null, null, null); + } + + private WebSocketServerConnection createDetached(final Socket socket) { + return new WebSocketServerConnection( + scheme != null ? scheme : (socket instanceof SSLSocket ? URIScheme.HTTPS.id : URIScheme.HTTP.id), + this.http1Config, + CharCodingSupport.createDecoder(this.charCodingConfig), + CharCodingSupport.createEncoder(this.charCodingConfig), + this.incomingContentStrategy, + this.outgoingContentStrategy, + this.requestParserFactory, + this.responseWriterFactory); + } + + @Override + public WebSocketServerConnection createConnection(final Socket socket) throws IOException { + final WebSocketServerConnection conn = createDetached(socket); + conn.bind(socket); + return conn; + } + + @Override + public WebSocketServerConnection createConnection(final SSLSocket sslSocket, final Socket socket) throws IOException { + final WebSocketServerConnection conn = createDetached(sslSocket); + conn.bind(sslSocket, socket); + return conn; + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java new file mode 100644 index 0000000000..7208bb3a62 --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java @@ -0,0 +1,163 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketCloseStatus; +import org.apache.hc.core5.websocket.WebSocketException; +import org.apache.hc.core5.websocket.WebSocketFrame; +import org.apache.hc.core5.websocket.WebSocketFrameType; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.message.CloseCodec; + +class WebSocketServerProcessor { + + private final WebSocketSession session; + private final WebSocketHandler handler; + private final int maxMessageSize; + + WebSocketServerProcessor(final WebSocketSession session, final WebSocketHandler handler, final int maxMessageSize) { + this.session = Args.notNull(session, "WebSocket session"); + this.handler = Args.notNull(handler, "WebSocket handler"); + this.maxMessageSize = maxMessageSize; + } + + void process() throws IOException { + ByteArrayOutputStream continuationBuffer = null; + WebSocketFrameType continuationType = null; + while (true) { + final WebSocketFrame frame = session.readFrame(); + if (frame == null) { + break; + } + if (frame.isRsv2() || frame.isRsv3()) { + throw new WebSocketException("Unsupported RSV bits"); + } + final WebSocketFrameType type = frame.getType(); + final int payloadLen = frame.getPayload().remaining(); + if (type == WebSocketFrameType.CLOSE + || type == WebSocketFrameType.PING + || type == WebSocketFrameType.PONG) { + if (!frame.isFin() || payloadLen > 125) { + throw new WebSocketException("Invalid control frame"); + } + } + switch (type) { + case PING: + handler.onPing(session, frame.getPayload()); + session.sendPong(frame.getPayload()); + break; + case PONG: + handler.onPong(session, frame.getPayload()); + break; + case CLOSE: + handleCloseFrame(frame); + return; + case TEXT: + case BINARY: + if (frame.isFin()) { + dispatchMessage(type, frame.getPayload()); + } else { + continuationBuffer = startContinuation(type, frame.getPayload()); + continuationType = type; + } + break; + case CONTINUATION: + if (continuationBuffer == null || continuationType == null) { + throw new WebSocketException("Unexpected continuation frame"); + } + appendContinuation(continuationBuffer, frame.getPayload()); + if (frame.isFin()) { + final ByteBuffer payload = ByteBuffer.wrap(continuationBuffer.toByteArray()); + dispatchMessage(continuationType, payload); + continuationBuffer = null; + continuationType = null; + } + break; + default: + throw new WebSocketException("Unsupported frame type: " + type); + } + } + } + + private void dispatchMessage(final WebSocketFrameType type, final ByteBuffer payload) throws IOException { + if (payload.remaining() > maxMessageSize) { + throw new WebSocketProtocolException(1009, "Message too large: " + payload.remaining()); + } + if (type == WebSocketFrameType.TEXT) { + handler.onText(session, WebSocketSession.decodeText(payload)); + } else { + handler.onBinary(session, payload); + } + } + + private ByteArrayOutputStream startContinuation(final WebSocketFrameType type, final ByteBuffer payload) throws WebSocketException { + if (payload.remaining() > maxMessageSize) { + throw new WebSocketProtocolException(1009, "Message too large: " + payload.remaining()); + } + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(payload.remaining()); + appendContinuation(buffer, payload); + return buffer; + } + + private void appendContinuation(final ByteArrayOutputStream buffer, final ByteBuffer payload) throws WebSocketException { + if (buffer.size() + payload.remaining() > maxMessageSize) { + throw new WebSocketProtocolException(1009, "Message too large: " + (buffer.size() + payload.remaining())); + } + final ByteBuffer copy = payload.asReadOnlyBuffer(); + final byte[] data = new byte[copy.remaining()]; + copy.get(data); + buffer.write(data, 0, data.length); + } + + private void handleCloseFrame(final WebSocketFrame frame) throws IOException { + final ByteBuffer payload = frame.getPayload(); + final int remaining = payload.remaining(); + int statusCode = WebSocketCloseStatus.NORMAL.getCode(); + String reason = ""; + if (remaining == 1) { + throw new WebSocketProtocolException(1002, "Invalid close payload length"); + } else if (remaining >= 2) { + final int code = ((payload.get() & 0xFF) << 8) | (payload.get() & 0xFF); + if (!CloseCodec.isValidToReceive(code)) { + throw new WebSocketProtocolException(1002, "Invalid close code: " + code); + } + statusCode = code; + if (payload.hasRemaining()) { + reason = WebSocketSession.decodeText(payload); + } + } + handler.onClose(session, statusCode, reason); + session.close(statusCode, reason); + } +} diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java new file mode 100644 index 0000000000..4a7adb556f --- /dev/null +++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java @@ -0,0 +1,145 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.function.Supplier; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HeaderElements; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequestMapper; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.HttpServerRequestHandler; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.websocket.WebSocketCloseStatus; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketException; +import org.apache.hc.core5.websocket.WebSocketExtensionNegotiation; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketExtensions; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketHandshake; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; + +class WebSocketServerRequestHandler implements HttpServerRequestHandler { + + private final HttpRequestMapper> requestMapper; + private final WebSocketConfig config; + private final WebSocketExtensionRegistry extensionRegistry; + + WebSocketServerRequestHandler( + final HttpRequestMapper> requestMapper, + final WebSocketConfig config, + final WebSocketExtensionRegistry extensionRegistry) { + this.requestMapper = Args.notNull(requestMapper, "Request mapper"); + this.config = config != null ? config : WebSocketConfig.DEFAULT; + this.extensionRegistry = extensionRegistry != null ? extensionRegistry : new WebSocketExtensionRegistry(); + } + + @Override + public void handle( + final ClassicHttpRequest request, + final ResponseTrigger trigger, + final HttpContext context) throws HttpException, IOException { + final Supplier supplier = requestMapper.resolve(request, context); + if (supplier == null) { + trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_NOT_FOUND)); + return; + } + if (!WebSocketHandshake.isWebSocketUpgrade(request)) { + trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_UPGRADE_REQUIRED)); + return; + } + final WebSocketHandler handler = supplier.get(); + final WebSocketServerConnection connection = getConnection(context); + if (connection == null) { + trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR)); + return; + } + final String key = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_KEY).getValue(); + final String accept = WebSocketHandshake.createAcceptKey(key); + final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_SWITCHING_PROTOCOLS); + response.addHeader(HttpHeaders.CONNECTION, HeaderElements.UPGRADE); + response.addHeader(HttpHeaders.UPGRADE, "websocket"); + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_ACCEPT, accept); + final WebSocketExtensionNegotiation negotiation = extensionRegistry.negotiate( + WebSocketExtensions.parse(request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS)), + true); + final String extensionsHeader = negotiation.formatResponseHeader(); + if (extensionsHeader != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS, extensionsHeader); + } + final List offeredProtocols = WebSocketHandshake.parseSubprotocols( + request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL)); + final String protocol = handler.selectSubprotocol(offeredProtocols); + if (protocol != null) { + response.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, protocol); + } + trigger.submitResponse(response); + final InputStream inputStream = connection.getSocketInputStream(); + final OutputStream outputStream = connection.getSocketOutputStream(); + if (inputStream == null || outputStream == null) { + connection.close(); + return; + } + final WebSocketSession session = new WebSocketSession(config, inputStream, outputStream, + connection.getRemoteAddress(), connection.getLocalAddress(), negotiation.getExtensions()); + try { + handler.onOpen(session); + new WebSocketServerProcessor(session, handler, config.getMaxMessageSize()).process(); + } catch (final WebSocketProtocolException ex) { + handler.onError(session, ex); + session.close(ex.closeCode, ex.getMessage()); + } catch (final WebSocketException ex) { + handler.onError(session, ex); + session.close(WebSocketCloseStatus.PROTOCOL_ERROR.getCode(), ex.getMessage()); + } catch (final Exception ex) { + handler.onError(session, ex); + session.close(WebSocketCloseStatus.INTERNAL_ERROR.getCode(), "WebSocket error"); + } finally { + connection.close(); + } + } + + private static WebSocketServerConnection getConnection(final HttpContext context) { + if (context == null) { + return null; + } + final Object conn = context.getAttribute(WebSocketContextKeys.CONNECTION); + return conn instanceof WebSocketServerConnection ? (WebSocketServerConnection) conn : null; + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java new file mode 100644 index 0000000000..d4d94b2ff0 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java @@ -0,0 +1,57 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Test; + +final class WebSocketClientConfigTest { + + @Test + void builderDefaultsAndCustom() { + final WebSocketClientConfig def = WebSocketClientConfig.custom().build(); + assertTrue(def.isAutoPong()); + assertTrue(def.getMaxFrameSize() > 0); + assertTrue(def.getMaxMessageSize() > 0); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setAutoPong(false) + .setMaxFrameSize(1024) + .setMaxMessageSize(2048) + .setConnectTimeout(Timeout.ofSeconds(3)) + .build(); + + assertFalse(cfg.isAutoPong()); + assertEquals(1024, cfg.getMaxFrameSize()); + assertEquals(2048, cfg.getMaxMessageSize()); + assertEquals(Timeout.ofSeconds(3), cfg.getConnectTimeout()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilderTest.java new file mode 100644 index 0000000000..34d8711827 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilderTest.java @@ -0,0 +1,173 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import org.apache.hc.client5.http.impl.DefaultClientConnectionReuseStrategy; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.core5.http.HttpConnection; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.Http1StreamListener; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.http.nio.ssl.BasicClientTlsStrategy; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ConnPoolListener; +import org.apache.hc.core5.pool.ConnPoolStats; +import org.apache.hc.core5.pool.PoolConcurrencyPolicy; +import org.apache.hc.core5.pool.PoolReusePolicy; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.IOReactorMetricsListener; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.IOSessionListener; +import org.apache.hc.core5.reactor.IOWorkerSelector; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketClientBuilderTest { + + @Test + void buildWithCustomSettingsUsesLaxPool() throws Exception { + final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(WebSocketClientConfig.custom().enableHttp2(true).build()) + .setIOReactorConfig(IOReactorConfig.custom().setIoThreadCount(1).build()) + .setHttp1Config(Http1Config.custom().setMaxHeaderCount(64).build()) + .setCharCodingConfig(CharCodingConfig.custom().setCharset(java.nio.charset.StandardCharsets.UTF_8).build()) + .setHttpProcessor(HttpProcessors.client()) + .setConnectionReuseStrategy(new DefaultClientConnectionReuseStrategy()) + .setDefaultMaxPerRoute(2) + .setMaxTotal(4) + .setTimeToLive(Timeout.ofSeconds(2)) + .setPoolReusePolicy(PoolReusePolicy.FIFO) + .setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX) + .setTlsStrategy(new BasicClientTlsStrategy()) + .setTlsHandshakeTimeout(Timeout.ofSeconds(3)) + .setIOSessionDecorator(ioSession -> ioSession) + .setExceptionCallback(exception -> { }) + .setIOSessionListener(new NoopSessionListener()) + .setStreamListener(new NoopStreamListener()) + .setConnPoolListener(new NoopConnPoolListener()) + .setThreadFactory(r -> new Thread(r, "ws-test")) + .setReactorMetricsListener(new NoopMetricsListener()) + .setWorkerSelector(new NoopWorkerSelector()) + .setMaxPendingCommandsPerConnection(5) + .build(); + + Assertions.assertNotNull(client); + client.close(CloseMode.IMMEDIATE); + } + + @Test + void buildWithDefaultsUsesStrictPool() throws Exception { + final CloseableWebSocketClient client = WebSocketClientBuilder.create().build(); + Assertions.assertNotNull(client); + client.close(CloseMode.IMMEDIATE); + } + + private static final class NoopSessionListener implements IOSessionListener { + @Override + public void connected(final IOSession session) { + } + + @Override + public void startTls(final IOSession session) { + } + + @Override + public void inputReady(final IOSession session) { + } + + @Override + public void outputReady(final IOSession session) { + } + + @Override + public void timeout(final IOSession session) { + } + + @Override + public void exception(final IOSession session, final Exception ex) { + } + + @Override + public void disconnected(final IOSession session) { + } + } + + private static final class NoopStreamListener implements Http1StreamListener { + @Override + public void onRequestHead(final HttpConnection connection, final HttpRequest request) { + } + + @Override + public void onResponseHead(final HttpConnection connection, final HttpResponse response) { + } + + @Override + public void onExchangeComplete(final HttpConnection connection, final boolean keepAlive) { + } + } + + private static final class NoopConnPoolListener implements ConnPoolListener { + @Override + public void onLease(final org.apache.hc.core5.http.HttpHost route, + final ConnPoolStats connPoolStats) { + } + + @Override + public void onRelease(final org.apache.hc.core5.http.HttpHost route, + final ConnPoolStats connPoolStats) { + } + } + + private static final class NoopMetricsListener implements IOReactorMetricsListener { + @Override + public void onThreadPoolStatus(final int activeThreads, final int pendingConnections) { + } + + @Override + public void onThreadPoolSaturation(final double saturationPercentage) { + } + + @Override + public void onResourceStarvationDetected() { + } + + @Override + public void onQueueWaitTime(final long averageWaitTimeMillis) { + } + } + + private static final class NoopWorkerSelector implements IOWorkerSelector { + @Override + public int select(final org.apache.hc.core5.reactor.IOWorkerStats[] dispatchers) { + return 0; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java new file mode 100644 index 0000000000..5e233d0ef9 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java @@ -0,0 +1,344 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.TimeValue; +import org.junit.jupiter.api.Test; + +final class WebSocketClientTest { + + private static final class NoNetworkClient extends CloseableWebSocketClient { + + @Override + public void start() { + // no-op + } + + @Override + public IOReactorStatus getStatus() { + return IOReactorStatus.ACTIVE; + } + + @Override + public void awaitShutdown(final TimeValue waitTime) { + // no-op + } + + @Override + public void initiateShutdown() { + // no-op + } + + // ModalCloseable (if your ModalCloseable declares this) + public void close(final CloseMode closeMode) { + // no-op + } + + // Closeable + @Override + public void close() { + // no-op – needed for try-with-resources + } + + @Override + protected CompletableFuture doConnect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + + final CompletableFuture f = new CompletableFuture<>(); + final LocalLoopWebSocket ws = new LocalLoopWebSocket(listener, cfg); + try { + listener.onOpen(ws); + } catch (final Throwable ignore) { + } + f.complete(ws); + return f; + } + } + + private static final class LocalLoopWebSocket implements WebSocket { + private final WebSocketListener listener; + private final WebSocketClientConfig cfg; + private volatile boolean open = true; + + LocalLoopWebSocket(final WebSocketListener listener, final WebSocketClientConfig cfg) { + this.listener = listener; + this.cfg = cfg != null ? cfg : WebSocketClientConfig.custom().build(); + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + if (!open) { + return false; + } + if (cfg.getMaxMessageSize() > 0 && data != null && data.length() > cfg.getMaxMessageSize()) { + // Simulate client closing due to oversized message + try { + listener.onClose(1009, "Message too big"); + } catch (final Throwable ignore) { + } + open = false; + return false; + } + try { + final CharBuffer cb = data != null ? CharBuffer.wrap(data) : CharBuffer.allocate(0); + listener.onText(cb, finalFragment); + } catch (final Throwable ignore) { + } + return true; + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + if (!open) { + return false; + } + try { + listener.onBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0), finalFragment); + } catch (final Throwable ignore) { + } + return true; + } + + @Override + public boolean ping(final ByteBuffer data) { + if (!open) { + return false; + } + try { + listener.onPong(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0)); + } catch (final Throwable ignore) { + } + return true; + } + + @Override + public boolean pong(final ByteBuffer data) { + // In a real client this would send a PONG; here it's a no-op. + return open; + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + final CompletableFuture f = new CompletableFuture<>(); + if (!open) { + f.complete(null); + return f; + } + open = false; + try { + listener.onClose(statusCode, reason != null ? reason : ""); + } catch (final Throwable ignore) { + } + f.complete(null); + return f; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public boolean sendTextBatch(final List fragments, final boolean finalFragment) { + if (!open) { + return false; + } + if (fragments == null || fragments.isEmpty()) { + return true; + } + for (int i = 0; i < fragments.size(); i++) { + final boolean last = i == fragments.size() - 1 && finalFragment; + if (!sendText(fragments.get(i), last)) { + return false; + } + } + return true; + } + + @Override + public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) { + if (!open) { + return false; + } + if (fragments == null || fragments.isEmpty()) { + return true; + } + for (int i = 0; i < fragments.size(); i++) { + final boolean last = i == fragments.size() - 1 && finalFragment; + if (!sendBinary(fragments.get(i), last)) { + return false; + } + } + return true; + } + } + + private static CloseableWebSocketClient newClient() { + final CloseableWebSocketClient c = new NoNetworkClient(); + c.start(); + return c; + } + + // ------------------------------- Tests ----------------------------------- + + @Test + void echo_uncompressed_no_network() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + final StringBuilder echoed = new StringBuilder(); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + client.connect(URI.create("ws://example/echo"), new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + final String prefix = "hello @ " + Instant.now() + " — "; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16; i++) { + sb.append(prefix); + } + ws.sendText(sb, true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + echoed.append(text); + ws.close(1000, "done"); + } + + @Override + public void onClose(final int code, final String reason) { + assertEquals(1000, code); + assertEquals("done", reason); + assertTrue(echoed.length() > 0); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + done.countDown(); + } + }, cfg, null); + + assertTrue(done.await(3, TimeUnit.SECONDS)); + } + } + + @Test + void ping_interleaved_fragmentation_no_network() throws Exception { + final CountDownLatch gotText = new CountDownLatch(1); + final CountDownLatch gotPong = new CountDownLatch(1); + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + + client.connect(URI.create("ws://example/interleave"), new WebSocketListener() { + + @Override + public void onOpen(final WebSocket ws) { + ws.ping(StandardCharsets.UTF_8.encode("ping")); + ws.sendText("hello", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + gotText.countDown(); + } + + @Override + public void onPong(final ByteBuffer payload) { + gotPong.countDown(); + } + }, cfg, null); + + assertTrue(gotPong.await(2, TimeUnit.SECONDS)); + assertTrue(gotText.await(2, TimeUnit.SECONDS)); + } + } + + @Test + void max_message_1009_no_network() throws Exception { + final CountDownLatch done = new CountDownLatch(1); + final int maxMessage = 2048; + + try (final CloseableWebSocketClient client = newClient()) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setMaxMessageSize(maxMessage) + .enablePerMessageDeflate(false) + .build(); + + client.connect(URI.create("ws://example/echo"), new WebSocketListener() { + @Override + public void onOpen(final WebSocket ws) { + final StringBuilder sb = new StringBuilder(); + final String chunk = "1234567890abcdef-"; + while (sb.length() <= maxMessage * 2) { + sb.append(chunk); + } + ws.sendText(sb, true); + } + + @Override + public void onClose(final int code, final String reason) { + assertEquals(1009, code); + done.countDown(); + } + }, cfg, null); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/WebSocketClientImplTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/WebSocketClientImplTest.java new file mode 100644 index 0000000000..5f7e225cb5 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/WebSocketClientImplTest.java @@ -0,0 +1,435 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.impl.protocol.WebSocketProtocolStrategy; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.DefaultAddressResolver; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.pool.PoolEntry; +import org.apache.hc.core5.pool.PoolStats; +import org.apache.hc.core5.reactor.IOEventHandlerFactory; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +@SuppressWarnings({"resource", "try"}) +class WebSocketClientImplTest { + + @Test + @SuppressWarnings("resource") + void abstractClientStartsAllRequesters() throws Exception { + final StubConnPool pool = new StubConnPool(); + final TestRequester primary = new TestRequester(pool); + final TestRequester extra = new TestRequester(pool); + final TestClient client = new TestClient(primary, extra); + boolean closed = false; + try { + client.start(); + + Assertions.assertTrue(primary.started.await(1, TimeUnit.SECONDS)); + Assertions.assertTrue(extra.started.await(1, TimeUnit.SECONDS)); + Assertions.assertTrue(client.isRunning()); + + client.initiateShutdown(); + Assertions.assertEquals(1, primary.shutdownCalls); + Assertions.assertEquals(1, extra.shutdownCalls); + + client.close(CloseMode.GRACEFUL); + closed = true; + Assertions.assertEquals(1, primary.closeCalls); + Assertions.assertEquals(1, extra.closeCalls); + } finally { + if (!closed) { + client.close(CloseMode.IMMEDIATE); + } + } + } + + @Test + @SuppressWarnings("resource") + void internalClientFallsBackToH1OnH2Failure() throws Exception { + final StubConnPool pool = new StubConnPool(); + final TestRequester requester = new TestRequester(pool); + final StubProtocol h1 = new StubProtocol(); + final StubProtocol h2 = new StubProtocol(); + h2.failWith = new IllegalStateException("h2 fail"); + + final TestInternalClient client = new TestInternalClient(requester, pool, h1, h2); + boolean closed = false; + try { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().enableHttp2(true).build(); + final WebSocket ws = client.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, null) + .get(1, TimeUnit.SECONDS); + + Assertions.assertNotNull(ws); + Assertions.assertEquals(1, h2.calls); + Assertions.assertEquals(1, h1.calls); + client.close(CloseMode.GRACEFUL); + closed = true; + } finally { + if (!closed) { + client.close(CloseMode.IMMEDIATE); + } + } + } + + @Test + @SuppressWarnings("resource") + void internalClientUsesH2WhenAvailable() throws Exception { + final StubConnPool pool = new StubConnPool(); + final TestRequester requester = new TestRequester(pool); + final StubProtocol h1 = new StubProtocol(); + final StubProtocol h2 = new StubProtocol(); + + final TestInternalClient client = new TestInternalClient(requester, pool, h1, h2); + boolean closed = false; + try { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().enableHttp2(true).build(); + final WebSocket ws = client.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, null) + .get(1, TimeUnit.SECONDS); + + Assertions.assertNotNull(ws); + Assertions.assertEquals(1, h2.calls); + Assertions.assertEquals(0, h1.calls); + client.close(CloseMode.GRACEFUL); + closed = true; + } finally { + if (!closed) { + client.close(CloseMode.IMMEDIATE); + } + } + } + + @Test + @SuppressWarnings("resource") + void internalClientUsesH1WhenH2Disabled() throws Exception { + final StubConnPool pool = new StubConnPool(); + final TestRequester requester = new TestRequester(pool); + final StubProtocol h1 = new StubProtocol(); + final StubProtocol h2 = new StubProtocol(); + + final TestInternalClient client = new TestInternalClient(requester, pool, h1, h2); + boolean closed = false; + try { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().enableHttp2(false).build(); + final WebSocket ws = client.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, null) + .get(1, TimeUnit.SECONDS); + + Assertions.assertNotNull(ws); + Assertions.assertEquals(0, h2.calls); + Assertions.assertEquals(1, h1.calls); + client.close(CloseMode.GRACEFUL); + closed = true; + } finally { + if (!closed) { + client.close(CloseMode.IMMEDIATE); + } + } + } + + private static final class TestClient extends AbstractWebSocketClient { + TestClient(final HttpAsyncRequester requester, final HttpAsyncRequester extra) { + super(requester, r -> new Thread(r, "ws-test"), extra); + } + + @Override + protected CompletableFuture doConnect( + final URI uri, final WebSocketListener listener, final WebSocketClientConfig cfg, final HttpContext context) { + return CompletableFuture.completedFuture(new StubWebSocket()); + } + } + + private static final class TestInternalClient extends InternalWebSocketClientBase { + private final WebSocketProtocolStrategy h1; + private final WebSocketProtocolStrategy h2; + + TestInternalClient(final HttpAsyncRequester requester, + final ManagedConnPool connPool, + final WebSocketProtocolStrategy h1, + final WebSocketProtocolStrategy h2) { + super(requester, connPool, WebSocketClientConfig.custom().build(), r -> new Thread(r, "ws-test"), + new NoopH2Requester()); + this.h1 = h1; + this.h2 = h2; + } + + @Override + protected WebSocketProtocolStrategy newH1Protocol( + final HttpAsyncRequester requester, final ManagedConnPool connPool) { + return new DelegatingProtocol(() -> h1); + } + + @Override + protected WebSocketProtocolStrategy newH2Protocol(final org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester requester) { + return new DelegatingProtocol(() -> h2); + } + } + + private static final class DelegatingProtocol implements WebSocketProtocolStrategy { + private final Supplier delegateSupplier; + + private DelegatingProtocol(final Supplier delegateSupplier) { + this.delegateSupplier = delegateSupplier; + } + + @Override + public CompletableFuture connect( + final URI uri, + final WebSocketListener listener, + final WebSocketClientConfig cfg, + final HttpContext context) { + final WebSocketProtocolStrategy delegate = delegateSupplier.get(); + if (delegate == null) { + final CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new IllegalStateException("Protocol not configured")); + return f; + } + return delegate.connect(uri, listener, cfg, context); + } + } + + private static final class StubProtocol implements WebSocketProtocolStrategy { + private int calls; + private RuntimeException failWith; + + @Override + public CompletableFuture connect(final URI uri, final WebSocketListener listener, + final WebSocketClientConfig cfg, final HttpContext context) { + calls++; + if (failWith != null) { + final CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(failWith); + return f; + } + return CompletableFuture.completedFuture(new StubWebSocket()); + } + } + + private static final class StubWebSocket implements WebSocket { + @Override + public boolean isOpen() { + return true; + } + + @Override + public boolean ping(final ByteBuffer data) { + return true; + } + + @Override + public boolean pong(final ByteBuffer data) { + return true; + } + + @Override + public boolean sendText(final CharSequence data, final boolean finalFragment) { + return true; + } + + @Override + public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) { + return true; + } + + @Override + public boolean sendTextBatch(final java.util.List fragments, final boolean finalFragment) { + return true; + } + + @Override + public boolean sendBinaryBatch(final java.util.List fragments, final boolean finalFragment) { + return true; + } + + @Override + public CompletableFuture close(final int statusCode, final String reason) { + return CompletableFuture.completedFuture(null); + } + } + + private static final class NoopH2Requester extends H2MultiplexingRequester { + NoopH2Requester() { + super(IOReactorConfig.DEFAULT, + (ioSession, attachment) -> null, + null, + null, + null, + DefaultAddressResolver.INSTANCE, + null, + null, + null, + 0); + } + + @Override + public void start() { + } + + @Override + public void initiateShutdown() { + } + + @Override + public void awaitShutdown(final TimeValue waitTime) { + } + + @Override + public void close(final CloseMode closeMode) { + } + } + + private static final class TestRequester extends HttpAsyncRequester { + private final CountDownLatch started = new CountDownLatch(1); + private int shutdownCalls; + private int closeCalls; + + TestRequester(final ManagedConnPool pool) { + super(IOReactorConfig.DEFAULT, + (IOEventHandlerFactory) (ioSession, attachment) -> null, + (org.apache.hc.core5.function.Decorator) null, + (org.apache.hc.core5.function.Callback) null, + (org.apache.hc.core5.reactor.IOSessionListener) null, + pool, + (org.apache.hc.core5.http.nio.ssl.TlsStrategy) null, + (Timeout) null, + (org.apache.hc.core5.reactor.IOReactorMetricsListener) null, + (org.apache.hc.core5.reactor.IOWorkerSelector) null, + 0); + } + + @Override + public void start() { + started.countDown(); + } + + @Override + public void initiateShutdown() { + shutdownCalls++; + } + + @Override + public void awaitShutdown(final TimeValue waitTime) { + } + + @Override + public void close(final CloseMode closeMode) { + closeCalls++; + } + } + + private static final class StubConnPool implements ManagedConnPool { + @Override + public java.util.concurrent.Future> lease( + final HttpHost route, final Object state, final Timeout requestTimeout, + final FutureCallback> callback) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void release(final PoolEntry entry, final boolean reusable) { + } + + @Override + public void close(final CloseMode closeMode) { + } + + @Override + public void close() { + } + + @Override + public void closeIdle(final TimeValue idleTime) { + } + + @Override + public void closeExpired() { + } + + @Override + public Set getRoutes() { + return Collections.emptySet(); + } + + @Override + public void setMaxTotal(final int max) { + } + + @Override + public int getMaxTotal() { + return 0; + } + + @Override + public void setDefaultMaxPerRoute(final int max) { + } + + @Override + public int getDefaultMaxPerRoute() { + return 0; + } + + @Override + public void setMaxPerRoute(final HttpHost route, final int max) { + } + + @Override + public int getMaxPerRoute(final HttpHost route) { + return 0; + } + + @Override + public PoolStats getTotalStats() { + return new PoolStats(0, 0, 0, 0); + } + + @Override + public PoolStats getStats(final HttpHost route) { + return new PoolStats(0, 0, 0, 0); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnectorTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnectorTest.java new file mode 100644 index 0000000000..253f6b81c4 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnectorTest.java @@ -0,0 +1,386 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.connector; + +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.ManagedConnPool; +import org.apache.hc.core5.pool.PoolEntry; +import org.apache.hc.core5.pool.PoolStats; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketEndpointConnectorTest { + + @Test + void connectReturnsEndpointWhenPoolEntryHasConnection() throws Exception { + final StubConnPool pool = new StubConnPool(); + final StubSession session = new StubSession(true); + final PoolEntry entry = new PoolEntry<>(new HttpHost("http", "localhost", 80)); + entry.assignConnection(session); + pool.entry = entry; + + final TestRequester requester = new TestRequester(pool); + final WebSocketEndpointConnector connector = new WebSocketEndpointConnector(requester, pool); + + final Future future = + connector.connect(new HttpHost("http", "localhost", 80), Timeout.ofSeconds(1), null, null); + final WebSocketEndpointConnector.ProtoEndpoint endpoint = future.get(1, TimeUnit.SECONDS); + + Assertions.assertNotNull(endpoint); + Assertions.assertEquals(0, requester.requestSessionCalls); + Assertions.assertTrue(endpoint.isConnected()); + } + + @Test + void connectRequestsSessionWhenPoolEntryIsEmpty() throws Exception { + final StubConnPool pool = new StubConnPool(); + pool.entry = new PoolEntry<>(new HttpHost("http", "localhost", 80)); + final TestRequester requester = new TestRequester(pool); + requester.nextSession = new StubSession(true); + + final WebSocketEndpointConnector connector = new WebSocketEndpointConnector(requester, pool); + final Timeout timeout = Timeout.ofSeconds(5); + final Future future = + connector.connect(new HttpHost("http", "localhost", 80), timeout, "attach", null); + final WebSocketEndpointConnector.ProtoEndpoint endpoint = future.get(1, TimeUnit.SECONDS); + + Assertions.assertNotNull(endpoint); + Assertions.assertEquals(1, requester.requestSessionCalls); + Assertions.assertNotNull(pool.entry.getConnection()); + Assertions.assertEquals(timeout, ((StubSession) pool.entry.getConnection()).socketTimeout); + } + + @Test + void failedSessionRequestReleasesEntry() throws Exception { + final StubConnPool pool = new StubConnPool(); + pool.entry = new PoolEntry<>(new HttpHost("http", "localhost", 80)); + final TestRequester requester = new TestRequester(pool); + requester.failWith = new IllegalStateException("boom"); + + final WebSocketEndpointConnector connector = new WebSocketEndpointConnector(requester, pool); + final Future future = + connector.connect(new HttpHost("http", "localhost", 80), Timeout.ofSeconds(1), null, null); + + Assertions.assertThrows(Exception.class, () -> future.get(1, TimeUnit.SECONDS)); + Assertions.assertNotNull(pool.lastReleaseEntry); + Assertions.assertFalse(pool.lastReusable); + } + + @Test + void protoEndpointThrowsWhenNotProtocolSession() { + final StubConnPool pool = new StubConnPool(); + final StubSession session = new StubSession(true); + final PoolEntry entry = new PoolEntry<>(new HttpHost("http", "localhost", 80)); + entry.assignConnection(session); + pool.entry = entry; + + final WebSocketEndpointConnector.ProtoEndpoint endpoint = + new WebSocketEndpointConnector(new TestRequester(pool), pool).new ProtoEndpoint(entry); + + Assertions.assertThrows(IllegalStateException.class, endpoint::getProtocolIOSession); + } + + private static final class TestRequester extends HttpAsyncRequester { + private int requestSessionCalls; + private IOSession nextSession; + private Exception failWith; + + TestRequester(final ManagedConnPool pool) { + super(IOReactorConfig.DEFAULT, + (ioSession, attachment) -> null, + null, + null, + null, + pool, + null, + null, + null, + null, + 0); + } + + @Override + public Future requestSession( + final HttpHost host, + final Timeout timeout, + final Object attachment, + final FutureCallback callback) { + requestSessionCalls++; + if (failWith != null) { + if (callback != null) { + callback.failed(failWith); + } + final CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(failWith); + return f; + } + final IOSession session = nextSession != null ? nextSession : new StubSession(true); + if (callback != null) { + callback.completed(session); + } + return CompletableFuture.completedFuture(session); + } + } + + private static final class StubConnPool implements ManagedConnPool { + private PoolEntry entry; + private PoolEntry lastReleaseEntry; + private boolean lastReusable; + + @Override + public Future> lease( + final HttpHost route, + final Object state, + final Timeout requestTimeout, + final FutureCallback> callback) { + if (callback != null) { + callback.completed(entry); + } + return CompletableFuture.completedFuture(entry); + } + + @Override + public void release(final PoolEntry entry, final boolean reusable) { + this.lastReleaseEntry = entry; + this.lastReusable = reusable; + } + + @Override + public void close(final CloseMode closeMode) { + } + + @Override + public void close() { + } + + @Override + public void closeIdle(final TimeValue idleTime) { + } + + @Override + public void closeExpired() { + } + + @Override + public Set getRoutes() { + return Collections.emptySet(); + } + + @Override + public void setMaxTotal(final int max) { + } + + @Override + public int getMaxTotal() { + return 0; + } + + @Override + public void setDefaultMaxPerRoute(final int max) { + } + + @Override + public int getDefaultMaxPerRoute() { + return 0; + } + + @Override + public void setMaxPerRoute(final HttpHost route, final int max) { + } + + @Override + public int getMaxPerRoute(final HttpHost route) { + return 0; + } + + @Override + public PoolStats getTotalStats() { + return new PoolStats(0, 0, 0, 0); + } + + @Override + public PoolStats getStats(final HttpHost route) { + return new PoolStats(0, 0, 0, 0); + } + } + + private static final class StubSession implements IOSession { + private final Lock lock = new ReentrantLock(); + private final boolean open; + private Timeout socketTimeout = Timeout.DISABLED; + + private StubSession(final boolean open) { + this.open = open; + } + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() { + } + + @Override + public void close(final CloseMode closeMode) { + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return 0; + } + + @Override + public void setEventMask(final int ops) { + } + + @Override + public void setEvent(final int op) { + } + + @Override + public void clearEvent(final int op) { + } + + @Override + public Status getStatus() { + return open ? Status.ACTIVE : Status.CLOSED; + } + + @Override + public Timeout getSocketTimeout() { + return socketTimeout; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + socketTimeout = timeout != null ? timeout : Timeout.DISABLED; + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java new file mode 100644 index 0000000000..153a632cdd --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java @@ -0,0 +1,106 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.junit.jupiter.api.Test; + +final class Http1UpgradeProtocolExtensionTest { + + @Test + void pmce_rejectedWhenDisabled() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate")); + } + + @Test + void pmce_rejectedWhenParametersNotOffered() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerServerNoContextTakeover(false) + .offerClientNoContextTakeover(false) + .offerClientMaxWindowBits(null) + .offerServerMaxWindowBits(null) + .build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; server_no_context_takeover")); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; client_max_window_bits=15")); + } + + @Test + void pmce_rejectedOnUnknownOrDuplicate() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; unknown=1")); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate, permessage-deflate")); + } + + @Test + void pmce_rejectedOnUnsupportedClientWindowBits() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerClientMaxWindowBits(15) + .offerServerMaxWindowBits(15) + .build(); + assertThrows(IllegalStateException.class, () -> + Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; client_max_window_bits=12")); + } + + @Test + void pmce_acceptsServerWindowBitsBelow15() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerServerMaxWindowBits(15) + .build(); + final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg, + "permessage-deflate; server_max_window_bits=12"); + assertFalse(chain.isEmpty()); + assertEquals(FrameHeaderBits.RSV1, chain.rsvMask()); + } + + @Test + void pmce_validNegotiation_buildsChain() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .offerClientMaxWindowBits(15) + .offerServerMaxWindowBits(15) + .offerClientNoContextTakeover(true) + .offerServerNoContextTakeover(true) + .build(); + final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg, + "permessage-deflate; client_no_context_takeover; server_no_context_takeover; client_max_window_bits=15"); + assertFalse(chain.isEmpty()); + assertEquals(FrameHeaderBits.RSV1, chain.rsvMask()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocolTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocolTest.java new file mode 100644 index 0000000000..cc213b3ec5 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocolTest.java @@ -0,0 +1,142 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.client.impl.protocol; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.Cancellable; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.impl.DefaultAddressResolver; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.nio.RequestChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOEventHandlerFactory; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class Http2ExtendedConnectProtocolTest { + + @Test + void connectFailsWhenRequesterMissing() throws Exception { + final Http2ExtendedConnectProtocol protocol = new Http2ExtendedConnectProtocol(null); + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + final CompletableFuture future = + protocol.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, HttpCoreContext.create()); + + final ExecutionException ex = Assertions.assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS)); + Assertions.assertTrue(ex.getCause() instanceof Http2ExtendedConnectProtocol.H2NotAvailable); + } + + @Test + void connectRejectsInvalidScheme() throws Exception { + final RecordingRequester requester = new RecordingRequester(); + final Http2ExtendedConnectProtocol protocol = new Http2ExtendedConnectProtocol(requester); + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + final CompletableFuture future = + protocol.connect(URI.create("http://localhost"), new WebSocketListener() { }, cfg, HttpCoreContext.create()); + + final ExecutionException ex = Assertions.assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS)); + Assertions.assertTrue(ex.getCause() instanceof IllegalArgumentException); + Assertions.assertNull(requester.requestRef.get()); + } + + @Test + void connectBuildsRequestWithProtocolHeader() { + final RecordingRequester requester = new RecordingRequester(); + final Http2ExtendedConnectProtocol protocol = new Http2ExtendedConnectProtocol(requester); + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + + protocol.connect(URI.create("ws://example.com"), new WebSocketListener() { }, cfg, HttpCoreContext.create()); + + final HttpRequest request = requester.requestRef.get(); + Assertions.assertNotNull(request); + Assertions.assertEquals("CONNECT", request.getMethod()); + Assertions.assertEquals("/", request.getPath()); + Assertions.assertEquals("websocket", request.getFirstHeader(WebSocketConstants.PSEUDO_PROTOCOL).getValue()); + } + + private static final class RecordingRequester extends H2MultiplexingRequester { + private final AtomicReference requestRef = new AtomicReference<>(); + + RecordingRequester() { + super(IOReactorConfig.DEFAULT, + new IOEventHandlerFactory() { + @Override + public IOEventHandler createHandler(final ProtocolIOSession ioSession, final Object attachment) { + return null; + } + }, + null, + null, + null, + DefaultAddressResolver.INSTANCE, + null, + null, + null, + 0); + } + + @Override + public Cancellable execute( + final HttpHost target, + final AsyncClientExchangeHandler exchangeHandler, + final HandlerFactory pushHandlerFactory, + final Timeout timeout, + final HttpContext context) { + final HttpContext ctx = context != null ? context : HttpCoreContext.create(); + try { + exchangeHandler.produceRequest(new RequestChannel() { + @Override + public void sendRequest(final HttpRequest request, final org.apache.hc.core5.http.EntityDetails entityDetails, + final HttpContext context) { + requestRef.set(request); + } + }, ctx); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + return () -> true; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java new file mode 100644 index 0000000000..34a8560ca1 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java @@ -0,0 +1,125 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +public final class WebSocketEchoClient { + + public static void main(final String[] args) throws Exception { + final URI uri = URI.create(args.length > 0 ? args[0] : "ws://localhost:8080/echo"); + final CountDownLatch done = new CountDownLatch(1); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(true) + .offerServerNoContextTakeover(true) + .offerClientNoContextTakeover(true) + .offerClientMaxWindowBits(15) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + System.out.println("[TEST] connecting: " + uri); + client.start(); + + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + System.out.println("[TEST] open: " + uri); + + final String prefix = "hello from hc5 WS @ " + Instant.now() + " — "; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 256; i++) { + sb.append(prefix); + } + final String msg = sb.toString(); + + ws.sendText(msg, true); + System.out.println("[TEST] sent (chars=" + msg.length() + ")"); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + final int len = text.length(); + final CharSequence preview = len > 120 ? text.subSequence(0, 120) + "…" : text; + System.out.println("[TEST] text (chars=" + len + "): " + preview); + ws.close(1000, "done"); + } + + @Override + public void onPong(final ByteBuffer payload) { + System.out.println("[TEST] pong: " + StandardCharsets.UTF_8.decode(payload)); + } + + @Override + public void onClose(final int code, final String reason) { + System.out.println("[TEST] close: " + code + " " + reason); + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + ex.printStackTrace(System.err); + done.countDown(); + } + }, cfg).exceptionally(ex -> { + ex.printStackTrace(System.err); + done.countDown(); + return null; + }); + + if (!done.await(12, TimeUnit.SECONDS)) { + System.err.println("[TEST] Timed out waiting for echo/close"); + System.exit(1); + } + + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} + diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java new file mode 100644 index 0000000000..412b5a19f5 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java @@ -0,0 +1,131 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketServer; +import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap; + +/** + *

WebSocketEchoServer

+ * + *

A tiny WebSocket echo server built on httpclient5-websocket. It echoes back + * any TEXT or BINARY message it receives. This is intended for local + * development and interoperability testing of {@code WebSocketClient} and is + * not production hardened.

+ * + *

Usage

+ *
+ *   # Default port 8080
+ *   java -cp ... org.apache.hc.client5.http.websocket.example.WebSocketEchoServer
+ *
+ *   # Custom port
+ *   java -cp ... org.apache.hc.client5.http.websocket.example.WebSocketEchoServer 9090
+ * 
+ * + *

Once started, the server listens on {@code ws://localhost:<port>/echo}.

+ */ +public final class WebSocketEchoServer { + + private WebSocketEchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + final CountDownLatch shutdown = new CountDownLatch(1); + + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onOpen(final WebSocketSession session) { + System.out.println("WebSocket open: " + session.getRemoteAddress()); + } + + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int statusCode, final String reason) { + System.out.println("WebSocket close: " + statusCode + " " + reason); + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + System.err.println("WebSocket error: " + cause.getMessage()); + cause.printStackTrace(System.err); + } + + @Override + public String selectSubprotocol(final List protocols) { + return protocols.isEmpty() ? null : protocols.get(0); + } + }) + .create(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.initiateShutdown(); + server.stop(); + shutdown.countDown(); + })); + + server.start(); + System.out.println("[WS-Server] up at ws://localhost:" + port + "/echo"); + shutdown.await(); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java new file mode 100644 index 0000000000..69c3c1afd7 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java @@ -0,0 +1,113 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +/** + * Standalone H2 WebSocket echo client (RFC 8441). + */ +public final class WebSocketH2EchoClient { + + private WebSocketH2EchoClient() { + } + + public static void main(final String[] args) throws Exception { + final URI uri = URI.create(args.length > 0 ? args[0] : "ws://localhost:8080/echo"); + final CountDownLatch done = new CountDownLatch(1); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + System.out.println("[H2] echo: " + text); + ws.close(1000, "done"); + } + + @Override + public void onBinary(final ByteBuffer payload, final boolean last) { + System.out.println("[H2] binary: " + payload.remaining()); + } + + @Override + public void onClose(final int code, final String reason) { + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + if (!done.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for H2 echo"); + } + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java new file mode 100644 index 0000000000..58595fbe4f --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java @@ -0,0 +1,106 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; + +/** + * Standalone H2 WebSocket echo server (RFC 8441). + */ +public final class WebSocketH2EchoServer { + + private WebSocketH2EchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; + final CountDownLatch done = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(done::countDown)); + + final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + // Keep server running for additional sessions. + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + cause.printStackTrace(System.err); + } + }) + .create(); + + server.start(); + System.out.println("[H2] echo server started at ws://localhost:" + server.getLocalPort() + "/echo"); + + try { + done.await(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + } finally { + server.initiateShutdown(); + server.stop(); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java new file mode 100644 index 0000000000..414bc2200b --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java @@ -0,0 +1,123 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient; +import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http2.ssl.H2ClientTlsStrategy; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +/** + * Standalone H2 WebSocket echo client over TLS (RFC 8441, wss://). + */ +public final class WebSocketH2TlsEchoClient { + + private WebSocketH2TlsEchoClient() { + } + + public static void main(final String[] args) throws Exception { + final URI uri = URI.create(args.length > 0 ? args[0] : "wss://localhost:8443/echo"); + final CountDownLatch done = new CountDownLatch(1); + + final SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(WebSocketH2TlsEchoClient.class.getResource("/test.keystore"), + "nopassword".toCharArray()) + .build(); + + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enableHttp2(true) + .setCloseWaitTimeout(Timeout.ofSeconds(2)) + .build(); + + try (final CloseableWebSocketClient client = WebSocketClientBuilder.create() + .setTlsStrategy(new H2ClientTlsStrategy(sslContext)) + .defaultConfig(cfg) + .build()) { + + client.start(); + client.connect(uri, new WebSocketListener() { + private WebSocket ws; + + @Override + public void onOpen(final WebSocket ws) { + this.ws = ws; + ws.sendText("hello-h2-tls", true); + } + + @Override + public void onText(final CharBuffer text, final boolean last) { + System.out.println("[H2/TLS] echo: " + text); + ws.close(1000, "done"); + } + + @Override + public void onBinary(final ByteBuffer payload, final boolean last) { + System.out.println("[H2/TLS] binary: " + payload.remaining()); + } + + @Override + public void onClose(final int code, final String reason) { + done.countDown(); + } + + @Override + public void onError(final Throwable ex) { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + } + }, cfg).exceptionally(ex -> { + if (!(ex instanceof ConnectionClosedException)) { + ex.printStackTrace(System.err); + } + done.countDown(); + return null; + }); + + if (!done.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for H2/TLS echo"); + } + client.initiateShutdown(); + client.awaitShutdown(TimeValue.ofSeconds(2)); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java new file mode 100644 index 0000000000..30dfafba78 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java @@ -0,0 +1,118 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.example; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.core5.http2.ssl.H2ServerTlsStrategy; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.apache.hc.core5.websocket.server.WebSocketH2Server; +import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap; + +/** + * Standalone H2 WebSocket echo server over TLS (RFC 8441, wss://). + */ +public final class WebSocketH2TlsEchoServer { + + private WebSocketH2TlsEchoServer() { + } + + public static void main(final String[] args) throws Exception { + final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8443; + final CountDownLatch done = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(done::countDown)); + + final SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(WebSocketH2TlsEchoServer.class.getResource("/test.keystore"), + "nopassword".toCharArray()) + .loadKeyMaterial(WebSocketH2TlsEchoServer.class.getResource("/test.keystore"), + "nopassword".toCharArray(), "nopassword".toCharArray()) + .build(); + + final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap() + .setListenerPort(port) + .setCanonicalHostName("localhost") + .setTlsStrategy(new H2ServerTlsStrategy(sslContext)) + .register("/echo", () -> new WebSocketHandler() { + @Override + public void onText(final WebSocketSession session, final String text) { + try { + session.sendText(text); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendBinary(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer data) { + try { + session.sendPong(data); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + // Keep server running for additional sessions. + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + cause.printStackTrace(System.err); + } + }) + .create(); + + server.start(); + System.out.println("[H2/TLS] echo server started at wss://localhost:" + server.getLocalPort() + "/echo"); + + try { + done.await(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + } finally { + server.initiateShutdown(); + server.stop(); + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketInboundRfcTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketInboundRfcTest.java new file mode 100644 index 0000000000..633bd46fb8 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketInboundRfcTest.java @@ -0,0 +1,312 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.junit.jupiter.api.Test; + +class WebSocketInboundRfcTest { + + @Test + void closeReasonInvalidUtf8_closesWith1007() { + final CloseCaptureListener listener = new CloseCaptureListener(); + final WebSocketInbound inbound = newInbound(listener); + final ProtocolIOSession session = new StubSession(); + + final byte[] payload = new byte[]{0x03, (byte) 0xE8, (byte) 0xC3, (byte) 0x28}; // 1000 + invalid UTF-8 + inbound.onInputReady(session, unmaskedFrame(FrameOpcode.CLOSE, true, payload)); + + assertEquals(1007, listener.closeCode.get()); + } + + @Test + void closeCodeInvalidOnWire_closesWith1002() { + final CloseCaptureListener listener = new CloseCaptureListener(); + final WebSocketInbound inbound = newInbound(listener); + final ProtocolIOSession session = new StubSession(); + + final byte[] payload = new byte[]{0x03, (byte) 0xED}; // 1005 (invalid on wire) + inbound.onInputReady(session, unmaskedFrame(FrameOpcode.CLOSE, true, payload)); + + assertEquals(1002, listener.closeCode.get()); + } + + @Test + void dataFrameWhileFragmentedMessage_closesWith1002() { + final CloseCaptureListener listener = new CloseCaptureListener(); + final WebSocketInbound inbound = newInbound(listener); + final ProtocolIOSession session = new StubSession(); + + inbound.onInputReady(session, unmaskedFrame(FrameOpcode.TEXT, false, new byte[]{0x61})); + inbound.onInputReady(session, unmaskedFrame(FrameOpcode.BINARY, true, new byte[]{0x62})); + + assertEquals(1002, listener.closeCode.get()); + } + + @Test + void unexpectedContinuation_closesWith1002() { + final CloseCaptureListener listener = new CloseCaptureListener(); + final WebSocketInbound inbound = newInbound(listener); + final ProtocolIOSession session = new StubSession(); + + inbound.onInputReady(session, unmaskedFrame(FrameOpcode.CONT, true, new byte[]{0x01})); + + assertEquals(1002, listener.closeCode.get()); + } + + @Test + void fragmentedControlFrame_closesWith1002() { + final CloseCaptureListener listener = new CloseCaptureListener(); + final WebSocketInbound inbound = newInbound(listener); + final ProtocolIOSession session = new StubSession(); + + inbound.onInputReady(session, unmaskedFrame(FrameOpcode.PING, false, new byte[0])); + + assertEquals(1002, listener.closeCode.get()); + } + + private static WebSocketInbound newInbound(final CloseCaptureListener listener) { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .enablePerMessageDeflate(false) + .build(); + final WebSocketSessionState state = new WebSocketSessionState( + new StubSession(), + listener, + cfg, + null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + return new WebSocketInbound(state, outbound); + } + + private static ByteBuffer unmaskedFrame(final int opcode, final boolean fin, final byte[] payload) { + final int len = payload != null ? payload.length : 0; + final ByteBuffer buf = ByteBuffer.allocate(2 + len); + buf.put((byte) ((fin ? 0x80 : 0x00) | (opcode & 0x0F))); + buf.put((byte) len); + if (len > 0) { + buf.put(payload); + } + buf.flip(); + return buf; + } + + private static final class CloseCaptureListener implements WebSocketListener { + private final AtomicInteger closeCode = new AtomicInteger(-1); + + @Override + public void onClose(final int code, final String reason) { + closeCode.compareAndSet(-1, code); + } + } + + private static final class StubSession implements ProtocolIOSession { + private final Lock lock = new ReentrantLock(); + private volatile Timeout socketTimeout = Timeout.DISABLED; + private volatile Status status = Status.ACTIVE; + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return status == Status.ACTIVE; + } + + @Override + public void close() { + status = Status.CLOSED; + } + + @Override + public void close(final CloseMode closeMode) { + status = Status.CLOSED; + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return 0; + } + + @Override + public void setEventMask(final int ops) { + } + + @Override + public void setEvent(final int op) { + } + + @Override + public void clearEvent(final int op) { + } + + @Override + public Status getStatus() { + return status; + } + + @Override + public Timeout getSocketTimeout() { + return socketTimeout; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + socketTimeout = timeout != null ? timeout : Timeout.DISABLED; + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, + final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, + final Timeout handshakeTimeout) throws UnsupportedOperationException { + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + + @Override + public NamedEndpoint getInitialEndpoint() { + return null; + } + + @Override + public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) { + } + + @Override + public void switchProtocol(final String protocolId, final FutureCallback callback) + throws UnsupportedOperationException { + if (callback != null) { + callback.failed(new UnsupportedOperationException()); + } + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandlerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandlerTest.java new file mode 100644 index 0000000000..198da2c26c --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandlerTest.java @@ -0,0 +1,322 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.net.SocketAddress; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.EventMask; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketIoHandlerTest { + + @Test + void connectedSetsTimeoutAndEvents() { + final StubSession session = new StubSession(); + final WebSocketIoHandler handler = new WebSocketIoHandler( + session, new CapturingListener(), WebSocketClientConfig.custom().build(), null, null); + + handler.connected(session); + + Assertions.assertEquals(Timeout.DISABLED, session.socketTimeout); + Assertions.assertEquals(EventMask.READ | EventMask.WRITE, session.eventMask); + } + + @Test + void timeoutClosesAndNotifies() { + final StubSession session = new StubSession(); + final CapturingListener listener = new CapturingListener(); + final WebSocketIoHandler handler = new WebSocketIoHandler( + session, listener, WebSocketClientConfig.custom().build(), null, null); + + handler.timeout(session, Timeout.ofSeconds(1)); + + Assertions.assertTrue(listener.error.get() instanceof java.util.concurrent.TimeoutException); + Assertions.assertEquals(CloseMode.GRACEFUL, session.closedWith); + } + + @Test + void exceptionClosesAndNotifies() { + final StubSession session = new StubSession(); + final CapturingListener listener = new CapturingListener(); + final WebSocketIoHandler handler = new WebSocketIoHandler( + session, listener, WebSocketClientConfig.custom().build(), null, null); + + final IllegalStateException ex = new IllegalStateException("boom"); + handler.exception(session, ex); + + Assertions.assertSame(ex, listener.error.get()); + Assertions.assertEquals(CloseMode.GRACEFUL, session.closedWith); + } + + @Test + void disconnectedEnqueuesShutdownAndReleasesEndpoint() { + final StubSession session = new StubSession(); + final StubEndpoint endpoint = new StubEndpoint(); + final WebSocketIoHandler handler = new WebSocketIoHandler( + session, new CapturingListener(), WebSocketClientConfig.custom().build(), null, endpoint); + + handler.disconnected(session); + + Assertions.assertNotNull(session.lastCommand); + Assertions.assertTrue(session.lastCommand instanceof org.apache.hc.core5.http.nio.command.ShutdownCommand); + Assertions.assertEquals(Command.Priority.IMMEDIATE, session.lastPriority); + Assertions.assertTrue(endpoint.released); + } + + private static final class CapturingListener implements WebSocketListener { + private final AtomicReference error = new AtomicReference<>(); + + @Override + public void onError(final Throwable cause) { + error.compareAndSet(null, cause); + } + } + + private static final class StubEndpoint extends AsyncClientEndpoint { + private volatile boolean released; + + @Override + public void execute(final AsyncClientExchangeHandler exchangeHandler, + final HandlerFactory pushHandlerFactory, + final HttpContext context) { + } + + @Override + public void releaseAndReuse() { + } + + @Override + public void releaseAndDiscard() { + released = true; + } + + @Override + public boolean isConnected() { + return true; + } + } + + private static final class StubSession implements ProtocolIOSession { + private final Lock lock = new ReentrantLock(); + private volatile Timeout socketTimeout = Timeout.DISABLED; + private volatile IOSession.Status status = IOSession.Status.ACTIVE; + private volatile int eventMask; + private volatile CloseMode closedWith; + private volatile Command lastCommand; + private volatile Command.Priority lastPriority; + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return status == IOSession.Status.ACTIVE; + } + + @Override + public void close() { + status = IOSession.Status.CLOSED; + } + + @Override + public void close(final CloseMode closeMode) { + status = IOSession.Status.CLOSED; + closedWith = closeMode; + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + lastCommand = command; + lastPriority = priority; + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return eventMask; + } + + @Override + public void setEventMask(final int ops) { + eventMask = ops; + } + + @Override + public void setEvent(final int op) { + eventMask |= op; + } + + @Override + public void clearEvent(final int op) { + eventMask &= ~op; + } + + @Override + public IOSession.Status getStatus() { + return status; + } + + @Override + public Timeout getSocketTimeout() { + return socketTimeout; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + socketTimeout = timeout != null ? timeout : Timeout.DISABLED; + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, + final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, + final Timeout handshakeTimeout) throws UnsupportedOperationException { + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + + @Override + public NamedEndpoint getInitialEndpoint() { + return null; + } + + @Override + public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) { + } + + @Override + public void switchProtocol(final String protocolId, final FutureCallback callback) + throws UnsupportedOperationException { + if (callback != null) { + callback.failed(new UnsupportedOperationException()); + } + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgraderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgraderTest.java new file mode 100644 index 0000000000..0e934d11de --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgraderTest.java @@ -0,0 +1,271 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.net.SocketAddress; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketUpgraderTest { + + @Test + void upgradeInstallsHandlerAndExposesWebSocket() { + final StubSession session = new StubSession(false); + final WebSocketUpgrader upgrader = new WebSocketUpgrader( + new WebSocketListener() { }, WebSocketClientConfig.custom().build(), null); + final CapturingCallback callback = new CapturingCallback(); + + upgrader.upgrade(session, callback); + + Assertions.assertNotNull(session.lastHandler); + Assertions.assertNotNull(upgrader.getWebSocket()); + Assertions.assertSame(session, callback.completed.get()); + Assertions.assertNull(callback.failed.get()); + } + + @Test + void upgradeFailureReportsCallback() { + final StubSession session = new StubSession(true); + final WebSocketUpgrader upgrader = new WebSocketUpgrader( + new WebSocketListener() { }, WebSocketClientConfig.custom().build(), null); + final CapturingCallback callback = new CapturingCallback(); + + upgrader.upgrade(session, callback); + + Assertions.assertNull(callback.completed.get()); + Assertions.assertNotNull(callback.failed.get()); + } + + private static final class CapturingCallback implements FutureCallback { + private final AtomicReference completed = new AtomicReference<>(); + private final AtomicReference failed = new AtomicReference<>(); + + @Override + public void completed(final ProtocolIOSession result) { + completed.compareAndSet(null, result); + } + + @Override + public void failed(final Exception ex) { + failed.compareAndSet(null, ex); + } + + @Override + public void cancelled() { + } + } + + private static final class StubSession implements ProtocolIOSession { + private final Lock lock = new ReentrantLock(); + private final boolean failUpgrade; + private volatile IOEventHandler lastHandler; + + private StubSession(final boolean failUpgrade) { + this.failUpgrade = failUpgrade; + } + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return true; + } + + @Override + public void close() { + } + + @Override + public void close(final CloseMode closeMode) { + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + if (failUpgrade) { + throw new IllegalStateException("upgrade failed"); + } + lastHandler = handler; + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return 0; + } + + @Override + public void setEventMask(final int ops) { + } + + @Override + public void setEvent(final int op) { + } + + @Override + public void clearEvent(final int op) { + } + + @Override + public IOSession.Status getStatus() { + return IOSession.Status.ACTIVE; + } + + @Override + public Timeout getSocketTimeout() { + return Timeout.DISABLED; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, + final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, + final Timeout handshakeTimeout) throws UnsupportedOperationException { + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + + @Override + public NamedEndpoint getInitialEndpoint() { + return null; + } + + @Override + public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) { + } + + @Override + public void switchProtocol(final String protocolId, final FutureCallback callback) + throws UnsupportedOperationException { + if (callback != null) { + callback.failed(new UnsupportedOperationException()); + } + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java new file mode 100644 index 0000000000..da96fbfaa6 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java @@ -0,0 +1,173 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.junit.jupiter.api.Test; + +class WsDecoderTest { + + @Test + void serverMaskedFrame_isRejected() { + // Build a minimal TEXT frame with MASK bit set (which servers MUST NOT set). + // 0x81 FIN|TEXT, 0x80 | 0 = mask + length 0, then 4-byte masking key. + final ByteBuffer buf = ByteBuffer.allocate(2 + 4); + buf.put((byte) 0x81); + buf.put((byte) 0x80); // MASK set, len=0 + buf.putInt(0x11223344); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void clientMaskedFrame_isAccepted_whenExpectMasked() { + final ByteBuffer buf = ByteBuffer.allocate(2 + 4); + buf.put((byte) 0x81); + buf.put((byte) 0x80); // MASK set, len=0 + buf.putInt(0x01020304); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192, true, true); + assertTrue(d.decode(buf)); + assertEquals(FrameOpcode.TEXT, d.opcode()); + } + + @Test + void clientUnmaskedFrame_isRejected_whenExpectMasked() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0x81); + buf.put((byte) 0x00); // no MASK + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192, true, true); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void controlFrame_fragmented_isRejected() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0x09); // FIN=0, PING + buf.put((byte) 0x00); // len=0 + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void controlFrame_tooLarge_isRejected() { + final ByteBuffer buf = ByteBuffer.allocate(4); + buf.put((byte) 0x89); // FIN=1, PING + buf.put((byte) 126); // len=126 (invalid for control frame) + buf.putShort((short) 126); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void rsvBitsWithoutExtensions_areRejected() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0xC1); // FIN=1, RSV1=1, TEXT + buf.put((byte) 0x00); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void reservedOpcode_isRejected() { + final ByteBuffer buf = ByteBuffer.allocate(2); + buf.put((byte) 0x83); // FIN=1, opcode=3 (reserved) + buf.put((byte) 0x00); + buf.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192); + assertThrows(WebSocketProtocolException.class, () -> d.decode(buf)); + } + + @Test + void extendedLen_126_and_127_parse() { + // A FIN|BINARY with 126 length, len=300 + final byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + + final ByteBuffer f126 = ByteBuffer.allocate(2 + 2 + payload.length); + f126.put((byte) 0x82); // FIN+BINARY + f126.put((byte) 126); + f126.putShort((short) payload.length); + f126.put(payload); + f126.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(4096); + assertTrue(d.decode(f126)); + assertEquals(FrameOpcode.BINARY, d.opcode()); + assertEquals(payload.length, d.payload().remaining()); + + // Now 127 with len=65540 (> 0xFFFF) + final int big = 65540; + final byte[] p2 = new byte[big]; + final ByteBuffer f127 = ByteBuffer.allocate(2 + 8 + p2.length); + f127.put((byte) 0x82); + f127.put((byte) 127); + f127.putLong(big); + f127.put(p2); + f127.flip(); + + final WebSocketFrameDecoder d2 = new WebSocketFrameDecoder(big + 32); + assertTrue(d2.decode(f127)); + assertEquals(big, d2.payload().remaining()); + } + + @Test + void partialBuffer_returnsFalse_and_consumesNothing() { + final ByteBuffer f = ByteBuffer.allocate(2); + f.put((byte) 0x81); + f.put((byte) 0x7E); // says 126, but no length bytes present + f.flip(); + + final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); + // Should mark/reset and return false; buffer remains at same position after call (no throw). + final int posBefore = f.position(); + assertFalse(d.decode(f)); + assertEquals(posBefore, f.position()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundBufferTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundBufferTest.java new file mode 100644 index 0000000000..24c012aef4 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundBufferTest.java @@ -0,0 +1,244 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.net.SocketAddress; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WsInboundBufferTest { + + @Test + void readBufferIsRecreatedWhenNull() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), new WebSocketListener() { }, cfg, null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + state.readBuf = null; + inbound.onInputReady(state.session, ByteBuffer.allocate(0)); + + Assertions.assertNotNull(state.readBuf); + } + + @Test + void onDisconnectedDoesNotClearReadBuffer() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), new WebSocketListener() { }, cfg, null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final ByteBuffer initial = state.readBuf; + inbound.onDisconnected(state.session); + + Assertions.assertSame(initial, state.readBuf); + } + + private static final class StubSession implements ProtocolIOSession { + private final Lock lock = new ReentrantLock(); + private volatile Timeout socketTimeout = Timeout.DISABLED; + private volatile IOSession.Status status = IOSession.Status.ACTIVE; + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return status == IOSession.Status.ACTIVE; + } + + @Override + public void close() { + status = IOSession.Status.CLOSED; + } + + @Override + public void close(final CloseMode closeMode) { + status = IOSession.Status.CLOSED; + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return 0; + } + + @Override + public void setEventMask(final int ops) { + } + + @Override + public void setEvent(final int op) { + } + + @Override + public void clearEvent(final int op) { + } + + @Override + public IOSession.Status getStatus() { + return status; + } + + @Override + public Timeout getSocketTimeout() { + return socketTimeout; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + socketTimeout = timeout != null ? timeout : Timeout.DISABLED; + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, + final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, + final Timeout handshakeTimeout) throws UnsupportedOperationException { + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + + @Override + public NamedEndpoint getInitialEndpoint() { + return null; + } + + @Override + public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) { + } + + @Override + public void switchProtocol(final String protocolId, final FutureCallback callback) + throws UnsupportedOperationException { + if (callback != null) { + callback.failed(new UnsupportedOperationException()); + } + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundCoverageTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundCoverageTest.java new file mode 100644 index 0000000000..6e2a7305fd --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundCoverageTest.java @@ -0,0 +1,339 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.ByteChannel; +import java.net.SocketAddress; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WsInboundCoverageTest { + + @Test + void autoPongQueuesPong() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build(); + final CapturingListener listener = new CapturingListener(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, cfg, null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final ByteBuffer ping = writer.frame(FrameOpcode.PING, ByteBuffer.wrap(new byte[] { 1 }), true, false); + inbound.onInputReady(state.session, ping); + + Assertions.assertEquals(1, listener.pingCount.get()); + Assertions.assertFalse(state.ctrlOutbound.isEmpty()); + } + + @Test + void fragmentedControlFrameTriggersClose() { + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), new CapturingListener(), + WebSocketClientConfig.custom().build(), null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final ByteBuffer badPing = ByteBuffer.allocate(2); + badPing.put((byte) FrameOpcode.PING); + badPing.put((byte) 0); + badPing.flip(); + + inbound.onInputReady(state.session, badPing); + + Assertions.assertTrue(state.closeSent.get()); + } + + @Test + void continuationWithoutStartTriggersClose() { + final CapturingListener listener = new CapturingListener(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, + WebSocketClientConfig.custom().build(), null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final ByteBuffer cont = writer.frame(FrameOpcode.CONT, ByteBuffer.allocate(0), true, false); + inbound.onInputReady(state.session, cont); + + Assertions.assertEquals(1002, listener.closeCode.get()); + } + + @Test + void closeFrameLengthOneIsRejected() { + final CapturingListener listener = new CapturingListener(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, + WebSocketClientConfig.custom().build(), null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final ByteBuffer close = writer.frame(FrameOpcode.CLOSE, ByteBuffer.wrap(new byte[] { 1 }), true, false); + inbound.onInputReady(state.session, close); + + Assertions.assertEquals(1002, listener.closeCode.get()); + } + + @Test + void oversizedMessageTriggersClose() { + final CapturingListener listener = new CapturingListener(); + final WebSocketClientConfig cfg = WebSocketClientConfig.custom().setMaxMessageSize(1).build(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, cfg, null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final ByteBuffer text = writer.frame(FrameOpcode.TEXT, ByteBuffer.wrap(new byte[] { 1, 2 }), true, false); + inbound.onInputReady(state.session, text); + + Assertions.assertEquals(1009, listener.closeCode.get()); + } + + @Test + void rsv1WithoutExtensionTriggersClose() { + final CapturingListener listener = new CapturingListener(); + final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, + WebSocketClientConfig.custom().build(), null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocketInbound inbound = new WebSocketInbound(state, outbound); + + final WebSocketFrameWriter writer = new WebSocketFrameWriter(); + final ByteBuffer text = writer.frameWithRSV(FrameOpcode.TEXT, ByteBuffer.wrap(new byte[] { 1 }), true, false, + FrameHeaderBits.RSV1); + inbound.onInputReady(state.session, text); + + Assertions.assertEquals(1002, listener.closeCode.get()); + } + + private static final class CapturingListener implements WebSocketListener { + private final AtomicInteger pingCount = new AtomicInteger(); + private final AtomicInteger closeCode = new AtomicInteger(-1); + private final AtomicReference text = new AtomicReference<>(); + + @Override + public void onPing(final ByteBuffer data) { + pingCount.incrementAndGet(); + } + + @Override + public void onText(final CharBuffer data, final boolean last) { + text.set(data); + } + + @Override + public void onClose(final int statusCode, final String reason) { + closeCode.compareAndSet(-1, statusCode); + } + } + + private static final class StubSession implements ProtocolIOSession { + private final Lock lock = new ReentrantLock(); + private volatile Timeout socketTimeout = Timeout.DISABLED; + private volatile IOSession.Status status = IOSession.Status.ACTIVE; + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return status == IOSession.Status.ACTIVE; + } + + @Override + public void close() { + status = IOSession.Status.CLOSED; + } + + @Override + public void close(final CloseMode closeMode) { + status = IOSession.Status.CLOSED; + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return 0; + } + + @Override + public void setEventMask(final int ops) { + } + + @Override + public void setEvent(final int op) { + } + + @Override + public void clearEvent(final int op) { + } + + @Override + public IOSession.Status getStatus() { + return status; + } + + @Override + public Timeout getSocketTimeout() { + return socketTimeout; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + socketTimeout = timeout != null ? timeout : Timeout.DISABLED; + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, + final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, + final Timeout handshakeTimeout) throws UnsupportedOperationException { + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + + @Override + public NamedEndpoint getInitialEndpoint() { + return null; + } + + @Override + public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) { + } + + @Override + public void switchProtocol(final String protocolId, final FutureCallback callback) + throws UnsupportedOperationException { + if (callback != null) { + callback.failed(new UnsupportedOperationException()); + } + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java new file mode 100644 index 0000000000..d69909eacd --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java @@ -0,0 +1,181 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.websocket.extension.ExtensionChain; +import org.apache.hc.core5.websocket.extension.PerMessageDeflate; +import org.apache.hc.core5.websocket.frame.FrameOpcode; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.junit.jupiter.api.Test; + +final class WsOutboundCompressionTest { + + @Test + void outboundPmce_setsRsv1_and_roundTrips() throws Exception { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setOutgoingChunkSize(64 * 1024) + .build(); + final ExtensionChain chain = new ExtensionChain(); + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + chain.add(pmce); + + final WebSocketSessionState state = new WebSocketSessionState(dummySession(), new WebSocketListener() { + }, cfg, chain); + final WebSocketOutbound out = new WebSocketOutbound(state); + final WebSocket ws = out.facade(); + + final String text = "hello hello hello hello hello"; + assertTrue(ws.sendText(text, true)); + + final WebSocketOutbound.OutFrame f = state.dataOutbound.poll(); + assertNotNull(f); + + final Frame frame = parseFrame(f.buf.asReadOnlyBuffer()); + assertEquals(FrameOpcode.TEXT, frame.opcode); + assertTrue(frame.rsv1); + assertTrue(frame.masked); + + final byte[] decoded = pmce.newDecoder().decode(frame.payload); + assertArrayEquals(text.getBytes(StandardCharsets.UTF_8), decoded); + + release(state, f); + } + + @Test + void outboundPmce_rsv1_onlyOnFirstFragment() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .build(); + final ExtensionChain chain = new ExtensionChain(); + chain.add(new PerMessageDeflate(true, true, true, null, null)); + + final WebSocketSessionState state = new WebSocketSessionState(dummySession(), new WebSocketListener() { + }, cfg, chain); + final WebSocketOutbound out = new WebSocketOutbound(state); + final WebSocket ws = out.facade(); + + assertTrue(ws.sendTextBatch(Arrays.asList("alpha", "beta"), true)); + + final WebSocketOutbound.OutFrame first = state.dataOutbound.poll(); + final WebSocketOutbound.OutFrame second = state.dataOutbound.poll(); + assertNotNull(first); + assertNotNull(second); + + final Frame f1 = parseFrame(first.buf.asReadOnlyBuffer()); + final Frame f2 = parseFrame(second.buf.asReadOnlyBuffer()); + + assertEquals(FrameOpcode.TEXT, f1.opcode); + assertTrue(f1.rsv1); + assertEquals(FrameOpcode.CONT, f2.opcode); + assertFalse(f2.rsv1); + + release(state, first); + release(state, second); + } + + private static void release(final WebSocketSessionState state, final WebSocketOutbound.OutFrame f) { + } + + private static ProtocolIOSession dummySession() { + return (ProtocolIOSession) Proxy.newProxyInstance( + ProtocolIOSession.class.getClassLoader(), + new Class[]{ProtocolIOSession.class}, + (proxy, method, args) -> { + final Class rt = method.getReturnType(); + if (rt == void.class) { + return null; + } + if (rt == boolean.class) { + return false; + } + if (rt == int.class) { + return 0; + } + if (rt == long.class) { + return 0L; + } + if (rt == float.class) { + return 0f; + } + if (rt == double.class) { + return 0d; + } + return null; + }); + } + + private static Frame parseFrame(final ByteBuffer buf) { + final int b0 = buf.get() & 0xFF; + final int b1 = buf.get() & 0xFF; + long len = b1 & 0x7F; + if (len == 126) { + len = buf.getShort() & 0xFFFF; + } else if (len == 127) { + len = buf.getLong(); + } + final boolean masked = (b1 & 0x80) != 0; + final byte[] mask = masked ? new byte[4] : null; + if (mask != null) { + buf.get(mask); + } + final byte[] payload = new byte[(int) len]; + buf.get(payload); + if (mask != null) { + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (payload[i] ^ mask[i & 3]); + } + } + return new Frame(b0, masked, payload); + } + + private static final class Frame { + final int opcode; + final boolean rsv1; + final boolean masked; + final byte[] payload; + + Frame(final int b0, final boolean masked, final byte[] payload) { + this.opcode = b0 & 0x0F; + this.rsv1 = (b0 & 0x40) != 0; + this.masked = masked; + this.payload = payload; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundQueueTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundQueueTest.java new file mode 100644 index 0000000000..f6e5fe6ac2 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundQueueTest.java @@ -0,0 +1,234 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.websocket.transport; + +import java.nio.ByteBuffer; + +import java.nio.channels.ByteChannel; +import java.net.SocketAddress; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.websocket.api.WebSocket; +import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig; +import org.apache.hc.client5.http.websocket.api.WebSocketListener; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.Command; +import org.apache.hc.core5.reactor.IOEventHandler; +import org.apache.hc.core5.reactor.IOSession; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ProtocolUpgradeHandler; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WsOutboundQueueTest { + + @Test + void dataQueueLimitRejectsExcess() { + final WebSocketClientConfig cfg = WebSocketClientConfig.custom() + .setMaxOutboundDataBytes(7) + .build(); + final ProtocolIOSession session = new StubSession(); + final WebSocketSessionState state = new WebSocketSessionState(session, new WebSocketListener() { }, cfg, null); + final WebSocketOutbound outbound = new WebSocketOutbound(state); + final WebSocket ws = outbound.facade(); + + Assertions.assertTrue(ws.sendBinary(ByteBuffer.wrap(new byte[] {1}), true)); + Assertions.assertFalse(ws.sendBinary(ByteBuffer.wrap(new byte[] {1}), true)); + } + + private static final class StubSession implements ProtocolIOSession { + private final Lock lock = new ReentrantLock(); + private volatile Timeout socketTimeout = Timeout.DISABLED; + private volatile IOSession.Status status = IOSession.Status.ACTIVE; + + @Override + public int read(final ByteBuffer dst) { + return 0; + } + + @Override + public int write(final ByteBuffer src) { + return 0; + } + + @Override + public boolean isOpen() { + return status == IOSession.Status.ACTIVE; + } + + @Override + public void close() { + status = IOSession.Status.CLOSED; + } + + @Override + public void close(final CloseMode closeMode) { + status = IOSession.Status.CLOSED; + } + + @Override + public String getId() { + return "stub"; + } + + @Override + public IOEventHandler getHandler() { + return null; + } + + @Override + public void upgrade(final IOEventHandler handler) { + } + + @Override + public Lock getLock() { + return lock; + } + + @Override + public void enqueue(final Command command, final Command.Priority priority) { + } + + @Override + public boolean hasCommands() { + return false; + } + + @Override + public Command poll() { + return null; + } + + @Override + public ByteChannel channel() { + return this; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public int getEventMask() { + return 0; + } + + @Override + public void setEventMask(final int ops) { + } + + @Override + public void setEvent(final int op) { + } + + @Override + public void clearEvent(final int op) { + } + + @Override + public IOSession.Status getStatus() { + return status; + } + + @Override + public Timeout getSocketTimeout() { + return socketTimeout; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + socketTimeout = timeout != null ? timeout : Timeout.DISABLED; + } + + @Override + public long getLastReadTime() { + return 0; + } + + @Override + public long getLastWriteTime() { + return 0; + } + + @Override + public long getLastEventTime() { + return 0; + } + + @Override + public void updateReadTime() { + } + + @Override + public void updateWriteTime() { + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, + final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, + final Timeout handshakeTimeout) throws UnsupportedOperationException { + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + + @Override + public NamedEndpoint getInitialEndpoint() { + return null; + } + + @Override + public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) { + } + + @Override + public void switchProtocol(final String protocolId, final FutureCallback callback) + throws UnsupportedOperationException { + if (callback != null) { + callback.failed(new UnsupportedOperationException()); + } + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java new file mode 100644 index 0000000000..964ed201ce --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java @@ -0,0 +1,72 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class PerMessageDeflateExtensionFactoryTest { + + @Test + void createsExtension() { + final PerMessageDeflateExtensionFactory factory = new PerMessageDeflateExtensionFactory(); + assertEquals("permessage-deflate", factory.getName()); + final WebSocketExtension ext = factory.create( + new WebSocketExtensionData("permessage-deflate", Collections.emptyMap()), true); + assertNotNull(ext); + assertEquals("permessage-deflate", ext.getName()); + } + + @Test + void rejectsUnsupportedWindowBits() { + final Map params = new LinkedHashMap<>(); + params.put("client_max_window_bits", "12"); + final WebSocketExtension ext = new PerMessageDeflateExtensionFactory().create( + new WebSocketExtensionData("permessage-deflate", params), true); + assertNull(ext); + } + + @Test + void echoesNegotiatedWindowBitsWhenRequested() { + final Map params = new LinkedHashMap<>(); + params.put("client_max_window_bits", "15"); + params.put("server_max_window_bits", "15"); + final WebSocketExtension ext = new PerMessageDeflateExtensionFactory().create( + new WebSocketExtensionData("permessage-deflate", params), true); + assertNotNull(ext); + final WebSocketExtensionData response = ext.getResponseData(); + assertEquals("15", response.getParameters().get("client_max_window_bits")); + assertEquals("15", response.getParameters().get("server_max_window_bits")); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java new file mode 100644 index 0000000000..3e1fcc3997 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java @@ -0,0 +1,88 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.zip.Deflater; + +import org.junit.jupiter.api.Test; + +class PerMessageDeflateExtensionTest { + + @Test + void decodesFragmentedMessage() throws Exception { + final byte[] plain = "fragmented message".getBytes(StandardCharsets.UTF_8); + final byte[] compressed = deflateWithSyncFlush(plain); + final int mid = compressed.length / 2; + final ByteBuffer part1 = ByteBuffer.wrap(compressed, 0, mid); + final ByteBuffer part2 = ByteBuffer.wrap(compressed, mid, compressed.length - mid); + + final PerMessageDeflateExtension ext = new PerMessageDeflateExtension(); + final ByteBuffer out1 = ext.decode(WebSocketFrameType.TEXT, false, part1); + final ByteBuffer out2 = ext.decode(WebSocketFrameType.CONTINUATION, true, part2); + + final ByteArrayOutputStream joined = new ByteArrayOutputStream(); + joined.write(toBytes(out1)); + joined.write(toBytes(out2)); + + assertEquals("fragmented message", WebSocketSession.decodeText(ByteBuffer.wrap(joined.toByteArray()))); + } + + private static byte[] deflateWithSyncFlush(final byte[] input) { + final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true); + deflater.setInput(input); + final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, input.length / 2)); + final byte[] buffer = new byte[8192]; + while (!deflater.needsInput()) { + final int count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH); + if (count > 0) { + out.write(buffer, 0, count); + } else { + break; + } + } + deflater.end(); + final byte[] data = out.toByteArray(); + if (data.length >= 4) { + final byte[] trimmed = new byte[data.length - 4]; + System.arraycopy(data, 0, trimmed, 0, trimmed.length); + return trimmed; + } + return data; + } + + private static byte[] toBytes(final ByteBuffer buf) { + final ByteBuffer copy = buf.asReadOnlyBuffer(); + final byte[] out = new byte[copy.remaining()]; + copy.get(out); + return out; + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java new file mode 100644 index 0000000000..cb58d63226 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketCloseStatusTest { + + @Test + void exposesStatusCodes() { + assertEquals(1000, WebSocketCloseStatus.NORMAL.getCode()); + assertEquals(1002, WebSocketCloseStatus.PROTOCOL_ERROR.getCode()); + assertEquals(1011, WebSocketCloseStatus.INTERNAL_ERROR.getCode()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java new file mode 100644 index 0000000000..b8a021c48f --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java @@ -0,0 +1,60 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class WebSocketConfigTest { + + @Test + void defaultsArePositive() { + final WebSocketConfig cfg = WebSocketConfig.DEFAULT; + assertEquals(16 * 1024 * 1024, cfg.getMaxFramePayloadSize()); + assertEquals(64 * 1024 * 1024, cfg.getMaxMessageSize()); + } + + @Test + void customBuilderAppliesLimits() { + final WebSocketConfig cfg = WebSocketConfig.custom() + .setMaxFramePayloadSize(1024) + .setMaxMessageSize(2048) + .build(); + assertEquals(1024, cfg.getMaxFramePayloadSize()); + assertEquals(2048, cfg.getMaxMessageSize()); + } + + @Test + void invalidSizesThrow() { + assertThrows(IllegalArgumentException.class, + () -> WebSocketConfig.custom().setMaxFramePayloadSize(0)); + assertThrows(IllegalArgumentException.class, + () -> WebSocketConfig.custom().setMaxMessageSize(0)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java new file mode 100644 index 0000000000..cd8130e863 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketConstantsTest { + + @Test + void exposesStandardHeaderNames() { + assertEquals("Sec-WebSocket-Key", WebSocketConstants.SEC_WEBSOCKET_KEY); + assertEquals("Sec-WebSocket-Version", WebSocketConstants.SEC_WEBSOCKET_VERSION); + assertEquals("Sec-WebSocket-Accept", WebSocketConstants.SEC_WEBSOCKET_ACCEPT); + assertEquals("Sec-WebSocket-Protocol", WebSocketConstants.SEC_WEBSOCKET_PROTOCOL); + assertEquals("Sec-WebSocket-Extensions", WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS); + assertEquals("258EAFA5-E914-47DA-95CA-C5AB0DC85B11", WebSocketConstants.WEBSOCKET_GUID); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java new file mode 100644 index 0000000000..75a676b141 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java @@ -0,0 +1,43 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +class WebSocketExceptionTest { + + @Test + void carriesMessageAndCause() { + final RuntimeException cause = new RuntimeException("boom"); + final WebSocketException ex = new WebSocketException("fail", cause); + assertEquals("fail", ex.getMessage()); + assertSame(cause, ex.getCause()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java new file mode 100644 index 0000000000..7b61e21339 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class WebSocketExtensionDataTest { + + @Test + void formatsParametersInOrder() { + final Map params = new LinkedHashMap<>(); + params.put("server_no_context_takeover", null); + params.put("client_max_window_bits", "12"); + final WebSocketExtensionData data = new WebSocketExtensionData("permessage-deflate", params); + + assertEquals("permessage-deflate", data.getName()); + assertTrue(data.getParameters().containsKey("server_no_context_takeover")); + assertEquals("permessage-deflate; server_no_context_takeover; client_max_window_bits=12", data.format()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java new file mode 100644 index 0000000000..c1c5a415d2 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java @@ -0,0 +1,54 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketExtensionNegotiationTest { + + @Test + void formatsResponseHeader() { + final WebSocketExtensionData data = new WebSocketExtensionData( + "permessage-deflate", Collections.singletonMap("client_max_window_bits", "12")); + final WebSocketExtensionNegotiation negotiation = new WebSocketExtensionNegotiation( + Collections.emptyList(), + Collections.singletonList(data)); + assertEquals("permessage-deflate; client_max_window_bits=12", negotiation.formatResponseHeader()); + } + + @Test + void formatsEmptyHeaderAsNull() { + final WebSocketExtensionNegotiation negotiation = new WebSocketExtensionNegotiation( + Collections.emptyList(), Collections.emptyList()); + assertNull(negotiation.formatResponseHeader()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java new file mode 100644 index 0000000000..f3b6b7418b --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java @@ -0,0 +1,105 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.ByteBuffer; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketExtensionRegistryTest { + + @Test + void registersAndNegotiatesExtensions() throws Exception { + final WebSocketExtensionRegistry registry = new WebSocketExtensionRegistry() + .register(new WebSocketExtensionFactory() { + @Override + public String getName() { + return "x-test"; + } + + @Override + public WebSocketExtension create(final WebSocketExtensionData request, final boolean server) { + return new WebSocketExtension() { + @Override + public String getName() { + return "x-test"; + } + + @Override + public boolean usesRsv1() { + return false; + } + + @Override + public boolean usesRsv2() { + return false; + } + + @Override + public boolean usesRsv3() { + return false; + } + + @Override + public ByteBuffer decode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) { + return payload; + } + + @Override + public ByteBuffer encode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) { + return payload; + } + + @Override + public WebSocketExtensionData getResponseData() { + return new WebSocketExtensionData("x-test", Collections.emptyMap()); + } + }; + } + }); + + final WebSocketExtensionNegotiation negotiation = registry.negotiate( + Collections.singletonList(new WebSocketExtensionData("x-test", Collections.emptyMap())), + true); + assertNotNull(negotiation); + assertEquals(1, negotiation.getExtensions().size()); + assertEquals("x-test", negotiation.getResponseData().get(0).getName()); + } + + @Test + void defaultRegistryContainsPerMessageDeflate() throws Exception { + final WebSocketExtensionRegistry registry = WebSocketExtensionRegistry.createDefault(); + final WebSocketExtensionNegotiation negotiation = registry.negotiate( + Collections.singletonList(new WebSocketExtensionData("permessage-deflate", Collections.emptyMap())), + true); + assertEquals(1, negotiation.getExtensions().size()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java new file mode 100644 index 0000000000..5b48cae8f8 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.Test; + +class WebSocketExtensionsTest { + + @Test + void parsesExtensionsHeader() { + final BasicHeader header = new BasicHeader( + WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS, + "permessage-deflate; client_max_window_bits=12, foo; bar=1"); + final List data = WebSocketExtensions.parse(header); + assertEquals(2, data.size()); + assertEquals("permessage-deflate", data.get(0).getName()); + assertEquals("12", data.get(0).getParameters().get("client_max_window_bits")); + assertEquals("foo", data.get(1).getName()); + final Map params = data.get(1).getParameters(); + assertEquals("1", params.get("bar")); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java new file mode 100644 index 0000000000..762f27c2cf --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java @@ -0,0 +1,84 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketFrameCodecTest { + + @Test + void readsMaskedTextFrame() throws Exception { + final byte[] payload = "hi".getBytes(StandardCharsets.UTF_8); + final byte[] mask = new byte[] { 1, 2, 3, 4 }; + final byte[] masked = new byte[payload.length]; + for (int i = 0; i < payload.length; i++) { + masked[i] = (byte) (payload[i] ^ mask[i % 4]); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x81); + out.write(0x80 | payload.length); + out.write(mask); + out.write(masked); + final WebSocketFrameReader reader = new WebSocketFrameReader( + WebSocketConfig.DEFAULT, + new ByteArrayInputStream(out.toByteArray()), + Collections.emptyList()); + final WebSocketFrame frame = reader.readFrame(); + Assertions.assertNotNull(frame); + Assertions.assertEquals(WebSocketFrameType.TEXT, frame.getType()); + Assertions.assertEquals("hi", WebSocketSession.decodeText(frame.getPayload())); + } + + @Test + void writesUnmaskedServerFrame() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final WebSocketFrameWriter writer = new WebSocketFrameWriter(out, Collections.emptyList()); + writer.writeBinary(ByteBuffer.wrap(new byte[] { 1, 2, 3 })); + final byte[] data = out.toByteArray(); + Assertions.assertEquals((byte) 0x82, data[0]); + Assertions.assertEquals((byte) 0x03, data[1]); + Assertions.assertEquals((byte) 1, data[2]); + Assertions.assertEquals((byte) 2, data[3]); + Assertions.assertEquals((byte) 3, data[4]); + } + + @Test + void encodesAndDecodesPerMessageDeflate() throws Exception { + final PerMessageDeflateExtension extension = new PerMessageDeflateExtension(); + final ByteBuffer payload = ByteBuffer.wrap("compress me".getBytes(StandardCharsets.UTF_8)); + final ByteBuffer encoded = extension.encode(WebSocketFrameType.TEXT, true, payload); + final ByteBuffer decoded = extension.decode(WebSocketFrameType.TEXT, true, encoded); + Assertions.assertEquals("compress me", WebSocketSession.decodeText(decoded)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java new file mode 100644 index 0000000000..c70921bbec --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java @@ -0,0 +1,163 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameReaderTest { + + private static final byte[] MASK_KEY = new byte[] { 0x11, 0x22, 0x33, 0x44 }; + + private static WebSocketFrame readFrame(final ByteBuffer frame, final int maxFrameSize) throws IOException { + final ByteBuffer copy = frame.asReadOnlyBuffer(); + final byte[] data = new byte[copy.remaining()]; + copy.get(data); + final WebSocketFrameReader reader = new WebSocketFrameReader( + WebSocketConfig.custom().setMaxFramePayloadSize(maxFrameSize).build(), + new ByteArrayInputStream(data), + Collections.emptyList()); + return reader.readFrame(); + } + + private static ByteBuffer maskedFrame(final int firstByte, final byte[] payload) { + final int len = payload.length; + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final ByteBuffer buf = ByteBuffer.allocate(2 + hdrExtra + 4 + len); + buf.put((byte) firstByte); + if (len <= 125) { + buf.put((byte) (0x80 | len)); + } else if (len <= 0xFFFF) { + buf.put((byte) (0x80 | 126)); + buf.putShort((short) len); + } else { + buf.put((byte) (0x80 | 127)); + buf.putLong(len); + } + buf.put(MASK_KEY); + for (int i = 0; i < len; i++) { + buf.put((byte) (payload[i] ^ MASK_KEY[i % 4])); + } + buf.flip(); + return buf; + } + + private static ByteBuffer unmaskedFrame(final int firstByte, final byte[] payload) { + final int len = payload.length; + final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8; + final ByteBuffer buf = ByteBuffer.allocate(2 + hdrExtra + len); + buf.put((byte) firstByte); + if (len <= 125) { + buf.put((byte) len); + } else if (len <= 0xFFFF) { + buf.put((byte) 126); + buf.putShort((short) len); + } else { + buf.put((byte) 127); + buf.putLong(len); + } + buf.put(payload); + buf.flip(); + return buf; + } + + @Test + void decode_small_text_masked() throws Exception { + final byte[] p = "hello".getBytes(StandardCharsets.UTF_8); + final ByteBuffer f = maskedFrame(0x81, p); // FIN|TEXT + final WebSocketFrame frame = readFrame(f, 8192); + assertNotNull(frame); + assertEquals(WebSocketFrameType.TEXT, frame.getType()); + assertEquals("hello", StandardCharsets.UTF_8.decode(frame.getPayload()).toString()); + } + + @Test + void decode_extended_126_length() throws Exception { + final byte[] p = new byte[300]; + for (int i = 0; i < p.length; i++) { + p[i] = (byte) (i & 0xFF); + } + final ByteBuffer f = maskedFrame(0x82, p); // FIN|BINARY + final WebSocketFrame frame = readFrame(f, 4096); + assertNotNull(frame); + assertEquals(WebSocketFrameType.BINARY, frame.getType()); + final ByteBuffer payload = frame.getPayload(); + final byte[] got = new byte[p.length]; + payload.get(got); + assertArrayEquals(p, got); + } + + @Test + void decode_extended_127_length() throws Exception { + final int len = 66000; + final byte[] p = new byte[len]; + Arrays.fill(p, (byte) 0xAB); + final ByteBuffer f = maskedFrame(0x82, p); // FIN|BINARY + final WebSocketFrame frame = readFrame(f, len + 64); + assertNotNull(frame); + assertEquals(len, frame.getPayload().remaining()); + } + + @Test + void unmasked_client_frame_is_rejected() { + final ByteBuffer f = unmaskedFrame(0x81, new byte[0]); // FIN|TEXT, no MASK + assertThrows(WebSocketException.class, () -> readFrame(f, 1024)); + } + + @Test + void rsv_bits_without_extension_is_rejected() { + final ByteBuffer f = maskedFrame(0xC1, new byte[0]); // FIN|RSV1|TEXT + assertThrows(WebSocketException.class, () -> readFrame(f, 1024)); + } + + @Test + void truncated_frame_throws() { + final ByteBuffer f = ByteBuffer.allocate(2); + f.put((byte) 0x81); + f.put((byte) 0xFE); // MASK|126, but missing extended length and mask + f.flip(); + assertThrows(IOException.class, () -> readFrame(f, 1024)); + } + + @Test + void frame_too_large_throws() { + final int len = 2000; + final ByteBuffer f = maskedFrame(0x82, new byte[len]); + assertThrows(WebSocketException.class, () -> readFrame(f, 1024)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java new file mode 100644 index 0000000000..27cf1c2420 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameTest { + + @Test + void exposesImmutablePayload() { + final ByteBuffer payload = ByteBuffer.wrap(new byte[] { 1, 2, 3 }); + final WebSocketFrame frame = new WebSocketFrame(true, false, false, false, WebSocketFrameType.BINARY, payload); + final ByteBuffer got = frame.getPayload(); + + assertTrue(frame.isFin()); + assertFalse(frame.isRsv1()); + assertEquals(WebSocketFrameType.BINARY, frame.getType()); + assertNotSame(payload, got); + assertEquals(3, got.remaining()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java new file mode 100644 index 0000000000..a465077006 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameTypeTest { + + @Test + void resolvesOpcodes() { + assertEquals(WebSocketFrameType.TEXT, WebSocketFrameType.fromOpcode(0x1)); + assertEquals(WebSocketFrameType.BINARY, WebSocketFrameType.fromOpcode(0x2)); + assertEquals(WebSocketFrameType.PING, WebSocketFrameType.fromOpcode(0x9)); + assertNull(WebSocketFrameType.fromOpcode(0x3)); + } + + @Test + void identifiesControlFrames() { + assertTrue(WebSocketFrameType.PING.isControl()); + assertTrue(WebSocketFrameType.CLOSE.isControl()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameWriterTest.java new file mode 100644 index 0000000000..7ce360d565 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameWriterTest.java @@ -0,0 +1,109 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +class WebSocketFrameWriterTest { + + @Test + void writesSmallBinaryFrame() throws Exception { + final byte[] payload = new byte[] {0x01, 0x02, 0x03}; + final byte[] out = writeBinary(payload, Collections.emptyList()); + + assertEquals((byte) 0x82, out[0]); // FIN + BINARY + assertEquals(payload.length, out[1] & 0xFF); + assertEquals(payload[0], out[2]); + assertEquals(payload[1], out[3]); + assertEquals(payload[2], out[4]); + } + + @Test + void writesExtendedLength126() throws Exception { + final byte[] payload = new byte[126]; + final byte[] out = writeBinary(payload, Collections.emptyList()); + + assertEquals((byte) 0x82, out[0]); + assertEquals(126, out[1] & 0xFF); + assertEquals(0, out[2] & 0xFF); + assertEquals(126, out[3] & 0xFF); + assertEquals(payload.length, out.length - 4); + } + + @Test + void writesExtendedLength127() throws Exception { + final int len = 66000; + final byte[] payload = new byte[len]; + final byte[] out = writeBinary(payload, Collections.emptyList()); + + assertEquals((byte) 0x82, out[0]); + assertEquals(127, out[1] & 0xFF); + final long declared = ((long) (out[2] & 0xFF) << 56) + | ((long) (out[3] & 0xFF) << 48) + | ((long) (out[4] & 0xFF) << 40) + | ((long) (out[5] & 0xFF) << 32) + | ((long) (out[6] & 0xFF) << 24) + | ((long) (out[7] & 0xFF) << 16) + | ((long) (out[8] & 0xFF) << 8) + | ((long) (out[9] & 0xFF)); + assertEquals(len, declared); + assertEquals(payload.length, out.length - 10); + } + + @Test + void setsRsv1WhenExtensionUsesIt() throws Exception { + final byte[] payload = new byte[] {0x01}; + final byte[] out = writeBinary(payload, Collections.singletonList(new Rsv1Extension())); + assertTrue((out[0] & 0x40) != 0, "RSV1 must be set"); + } + + private static byte[] writeBinary(final byte[] payload, final java.util.List extensions) throws Exception { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final WebSocketFrameWriter writer = new WebSocketFrameWriter(baos, extensions); + writer.writeBinary(ByteBuffer.wrap(payload)); + return baos.toByteArray(); + } + + private static final class Rsv1Extension implements WebSocketExtension { + @Override + public String getName() { + return "rsv1-test"; + } + + @Override + public boolean usesRsv1() { + return true; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java new file mode 100644 index 0000000000..8f517d259d --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java @@ -0,0 +1,60 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketHandshakeTest { + + @Test + void createsExpectedAcceptKey() throws WebSocketException { + final String key = "dGhlIHNhbXBsZSBub25jZQ=="; + final String expected = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + Assertions.assertEquals(expected, WebSocketHandshake.createAcceptKey(key)); + } + + @Test + void detectsWebSocketUpgradeRequest() { + final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/chat"); + request.addHeader(HttpHeaders.CONNECTION, "Upgrade"); + request.addHeader(HttpHeaders.UPGRADE, "websocket"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION, "13"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ=="); + + Assertions.assertTrue(WebSocketHandshake.isWebSocketUpgrade(request)); + } + + @Test + void parsesSubprotocols() { + final BasicHeader header = new BasicHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, "chat, superchat"); + Assertions.assertEquals(2, WebSocketHandshake.parseSubprotocols(header).size()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java new file mode 100644 index 0000000000..c6e93a9278 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java @@ -0,0 +1,80 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException; +import org.junit.jupiter.api.Test; + +class WebSocketSessionTest { + + @Test + void writesTextAndCloseFrames() throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final WebSocketSession session = new WebSocketSession( + WebSocketConfig.DEFAULT, + new ByteArrayInputStream(new byte[0]), + out, + null, + null, + Collections.emptyList()); + + session.sendText("hello"); + final int afterText = out.size(); + assertTrue(afterText > 0); + + session.close(1000, "done"); + final int afterClose = out.size(); + assertTrue(afterClose > afterText); + + session.close(1000, "done"); + assertEquals(afterClose, out.size(), "close should be sent once"); + } + + @Test + void decodeTextValidatesUtf8() throws Exception { + final ByteBuffer payload = StandardCharsets.UTF_8.encode("ok"); + assertEquals("ok", WebSocketSession.decodeText(payload)); + } + + @Test + void decodeTextRejectsInvalidUtf8() { + final byte[] invalid = new byte[] {(byte) 0xC3, (byte) 0x28}; + final WebSocketProtocolException ex = org.junit.jupiter.api.Assertions.assertThrows( + WebSocketProtocolException.class, + () -> WebSocketSession.decodeText(ByteBuffer.wrap(invalid))); + assertEquals(1007, ex.closeCode); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java new file mode 100644 index 0000000000..53222e52ca --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java @@ -0,0 +1,41 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.exceptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketProtocolExceptionTest { + + @Test + void exposesCloseCode() { + final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "protocol"); + assertEquals(1002, ex.closeCode); + assertEquals("protocol", ex.getMessage()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java new file mode 100644 index 0000000000..a79abeb83d --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.extension; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +final class ExtensionChainTest { + + @Test + void addAndUsePmce_decodeRoundTrip() throws Exception { + final ExtensionChain chain = new ExtensionChain(); + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + chain.add(pmce); + + final byte[] data = "compress me please".getBytes(StandardCharsets.UTF_8); + + final WebSocketExtensionChain.Encoded enc = pmce.newEncoder().encode(data, true, true); + final byte[] back = chain.newDecodeChain().decode(enc.payload); + + assertArrayEquals(data, back); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java new file mode 100644 index 0000000000..84a3b92f6b --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java @@ -0,0 +1,90 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.extension; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.websocket.frame.FrameHeaderBits; +import org.junit.jupiter.api.Test; + +final class MessageDeflateTest { + + @Test + void rsvMask_isRSV1() { + final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null); + assertEquals(FrameHeaderBits.RSV1, pmce.rsvMask()); + } + + @Test + void encode_setsRSVOnlyOnFirst() { + final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null); + final WebSocketExtensionChain.Encoder enc = pmce.newEncoder(); + + final byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + + final WebSocketExtensionChain.Encoded first = enc.encode(data, true, false); + final WebSocketExtensionChain.Encoded cont = enc.encode(data, false, true); + + assertTrue(first.setRsvOnFirst, "RSV on first fragment"); + assertFalse(cont.setRsvOnFirst, "no RSV on continuation"); + assertNotEquals(0, first.payload.length); + assertNotEquals(0, cont.payload.length); + } + + @Test + void roundTrip_message() throws Exception { + final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null); + final WebSocketExtensionChain.Encoder enc = pmce.newEncoder(); + final WebSocketExtensionChain.Decoder dec = pmce.newDecoder(); + + final String s = "The quick brown fox jumps over the lazy dog. " + + "The quick brown fox jumps over the lazy dog."; + final byte[] plain = s.getBytes(StandardCharsets.UTF_8); + + // Single-frame message: first=true, fin=true + final byte[] wire = enc.encode(plain, true, true).payload; + + assertTrue(wire.length > 0); + assertFalse(endsWithTail(wire), "tail must be stripped on wire"); + + final byte[] roundTrip = dec.decode(wire); + assertArrayEquals(plain, roundTrip); + } + + private static boolean endsWithTail(final byte[] b) { + if (b.length < 4) { + return false; + } + return b[b.length - 4] == 0x00 && b[b.length - 3] == 0x00 && (b[b.length - 2] & 0xFF) == 0xFF && (b[b.length - 1] & 0xFF) == 0xFF; + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java new file mode 100644 index 0000000000..9159a5a5b2 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java @@ -0,0 +1,43 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.frame; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class FrameHeaderBitsTest { + + @Test + void exposesHeaderBitConstants() { + assertEquals(0x80, FrameHeaderBits.FIN); + assertEquals(0x40, FrameHeaderBits.RSV1); + assertEquals(0x20, FrameHeaderBits.RSV2); + assertEquals(0x10, FrameHeaderBits.RSV3); + assertEquals(0x80, FrameHeaderBits.MASK_BIT); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java new file mode 100644 index 0000000000..ccae851593 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.frame; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class FrameOpcodeTest { + + @Test + void identifiesOpCodes() { + assertTrue(FrameOpcode.isControl(FrameOpcode.PING)); + assertTrue(FrameOpcode.isData(FrameOpcode.TEXT)); + assertTrue(FrameOpcode.isContinuation(FrameOpcode.CONT)); + assertFalse(FrameOpcode.isData(FrameOpcode.CONT)); + } + + @Test + void namesOpcodes() { + assertEquals("TEXT", FrameOpcode.name(FrameOpcode.TEXT)); + assertEquals("0x3", FrameOpcode.name(0x3)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java new file mode 100644 index 0000000000..f32785392d --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java @@ -0,0 +1,188 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.frame; + +import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV1; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + + +class FrameWriterTest { + + private static class Parsed { + int b0; + int b1; + int opcode; + boolean fin; + boolean mask; + long len; + final byte[] maskKey = new byte[4]; + int headerLen; + ByteBuffer payloadSlice; + } + + private static Parsed parse(final ByteBuffer frame) { + final ByteBuffer frameCopy = frame.asReadOnlyBuffer(); + final Parsed r = new Parsed(); + r.b0 = frameCopy.get() & 0xFF; + r.fin = (r.b0 & 0x80) != 0; + r.opcode = r.b0 & 0x0F; + + r.b1 = frameCopy.get() & 0xFF; + r.mask = (r.b1 & 0x80) != 0; + final int low = r.b1 & 0x7F; + if (low <= 125) { + r.len = low; + } else if (low == 126) { + r.len = frameCopy.getShort() & 0xFFFF; + } else { + r.len = frameCopy.getLong(); + } + + if (r.mask) { + frameCopy.get(r.maskKey); + } + r.headerLen = frameCopy.position(); + r.payloadSlice = frameCopy.slice(); + return r; + } + + private static byte[] unmask(final Parsed p) { + final byte[] out = new byte[(int) p.len]; + for (int i = 0; i < out.length; i++) { + int b = p.payloadSlice.get(i) & 0xFF; + b ^= p.maskKey[i & 3] & 0xFF; + out[i] = (byte) b; + } + return out; + } + + @Test + void text_small_masked_roundtrip() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.text("hello", true); + final Parsed p = parse(f); + assertTrue(p.fin); + assertEquals(FrameOpcode.TEXT, p.opcode); + assertTrue(p.mask, "client frame must be masked"); + assertEquals(5, p.len); + assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), unmask(p)); + } + + @Test + void binary_len_126_masked_roundtrip() { + final byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true); + + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.BINARY, p.opcode); + assertEquals(300, p.len); + assertArrayEquals(payload, unmask(p)); + } + + @Test + void binary_len_127_masked_roundtrip() { + final int len = 70000; + final byte[] payload = new byte[len]; + Arrays.fill(payload, (byte) 0xA5); + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true); + + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.BINARY, p.opcode); + assertEquals(len, p.len); + assertArrayEquals(payload, unmask(p)); + } + + @Test + void rsv1_set_with_frameWithRSV() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer payload = StandardCharsets.UTF_8.encode("x"); + // Use RSV1 bit + final ByteBuffer f = w.frameWithRSV(FrameOpcode.TEXT, payload, true, true, RSV1); + final Parsed p = parse(f); + assertTrue(p.fin); + assertEquals(FrameOpcode.TEXT, p.opcode); + assertTrue((p.b0 & RSV1) != 0, "RSV1 must be set"); + assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), unmask(p)); + } + + @Test + void close_frame_contains_code_and_reason() { + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.close(1000, "done"); + final Parsed p = parse(f); + assertTrue(p.mask); + assertEquals(FrameOpcode.CLOSE, p.opcode); + assertTrue(p.len >= 2); + + final byte[] raw = unmask(p); + final int code = (raw[0] & 0xFF) << 8 | raw[1] & 0xFF; + final String reason = new String(raw, 2, raw.length - 2, StandardCharsets.UTF_8); + + assertEquals(1000, code); + assertEquals("done", reason); + } + + @Test + void closeEcho_masks_and_preserves_payload() { + // Build a close payload manually + final byte[] reason = "bye".getBytes(StandardCharsets.UTF_8); + final ByteBuffer payload = ByteBuffer.allocate(2 + reason.length); + payload.put((byte) (1000 >>> 8)); + payload.put((byte) (1000 & 0xFF)); + payload.put(reason); + payload.flip(); + + final WebSocketFrameWriter w = new WebSocketFrameWriter(); + final ByteBuffer f = w.closeEcho(payload); + final Parsed p = parse(f); + + assertTrue(p.mask); + assertEquals(FrameOpcode.CLOSE, p.opcode); + assertEquals(2 + reason.length, p.len); + + final byte[] got = unmask(p); + assertEquals(1000, (got[0] & 0xFF) << 8 | got[1] & 0xFF); + assertEquals("bye", new String(got, 2, got.length - 2, StandardCharsets.UTF_8)); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java new file mode 100644 index 0000000000..56f168f921 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java @@ -0,0 +1,87 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +final class CloseCodecTest { + + @Test + void readEmptyIs1005() { + final ByteBuffer empty = ByteBuffer.allocate(0); + assertEquals(1005, CloseCodec.readCloseCode(empty.asReadOnlyBuffer())); + assertEquals("", CloseCodec.readCloseReason(empty.asReadOnlyBuffer())); + } + + @Test + void readCodeAndReason() { + final ByteBuffer payload = ByteBuffer.allocate(2 + 4); + payload.put((byte) 0x03).put((byte) 0xE8); // 1000 + payload.put(StandardCharsets.UTF_8.encode("done")); + payload.flip(); + + // Use the SAME buffer so the position advances + final ByteBuffer buf = payload.asReadOnlyBuffer(); + assertEquals(1000, CloseCodec.readCloseCode(buf)); // advances position by 2 + assertEquals("done", CloseCodec.readCloseReason(buf)); // reads remaining bytes only + } + + @Test + void validateCloseCodes() { + assertTrue(CloseCodec.isValidToSend(1000)); + assertTrue(CloseCodec.isValidToReceive(1000)); + assertTrue(CloseCodec.isValidToSend(3000)); + assertTrue(CloseCodec.isValidToReceive(3000)); + + assertFalse(CloseCodec.isValidToSend(1005)); + assertFalse(CloseCodec.isValidToReceive(1005)); + assertFalse(CloseCodec.isValidToSend(1006)); + assertFalse(CloseCodec.isValidToReceive(1006)); + assertFalse(CloseCodec.isValidToSend(1015)); + assertFalse(CloseCodec.isValidToReceive(1015)); + + assertFalse(CloseCodec.isValidToSend(2000)); + assertFalse(CloseCodec.isValidToReceive(2000)); + } + + @Test + void truncateReasonUtf8_capsAt123Bytes() { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 130; i++) { + sb.append('a'); + } + final String truncated = CloseCodec.truncateReasonUtf8(sb.toString()); + assertEquals(123, truncated.getBytes(StandardCharsets.UTF_8).length); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java new file mode 100644 index 0000000000..6c5d057aa2 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java @@ -0,0 +1,39 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class WebSocketContextKeysTest { + + @Test + void exposesConnectionKey() { + assertEquals("httpcore.websocket.connection", WebSocketContextKeys.CONNECTION); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrapTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrapTest.java new file mode 100644 index 0000000000..0f7d786313 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrapTest.java @@ -0,0 +1,130 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.lang.reflect.Field; +import java.net.SocketAddress; + +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; +import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketH2ServerBootstrapTest { + + @Test + void setH2ConfigDisablesPush() { + final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap(); + bootstrap.setH2Config(H2Config.custom().setPushEnabled(true).build()); + + final H2Config cfg = getField(bootstrap, "h2Config", H2Config.class); + Assertions.assertNotNull(cfg); + Assertions.assertFalse(cfg.isPushEnabled()); + } + + @Test + void setH2ConfigNullUsesDefault() { + final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap(); + bootstrap.setH2Config(null); + + final H2Config cfg = getField(bootstrap, "h2Config", H2Config.class); + Assertions.assertNotNull(cfg); + Assertions.assertFalse(cfg.isPushEnabled()); + } + + @Test + void setHttp1ConfigNullUsesDefault() { + final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap(); + bootstrap.setHttp1Config(null); + final Http1Config cfg = getField(bootstrap, "http1Config", Http1Config.class); + Assertions.assertSame(Http1Config.DEFAULT, cfg); + } + + @Test + void createUsesHttpsWhenTlsStrategyProvided() { + final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap() + .setTlsStrategy(new StubTlsStrategy()); + bootstrap.register("/", WebSocketH2ServerBootstrapTest::noopHandler); + final WebSocketH2Server server = bootstrap.create(); + final URIScheme scheme = getField(server, "scheme", URIScheme.class); + Assertions.assertEquals(URIScheme.HTTPS, scheme); + } + + @Test + void createUsesHttpWhenNoTlsStrategy() { + final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap(); + bootstrap.register("/", WebSocketH2ServerBootstrapTest::noopHandler); + final WebSocketH2Server server = bootstrap.create(); + final URIScheme scheme = getField(server, "scheme", URIScheme.class); + Assertions.assertEquals(URIScheme.HTTP, scheme); + } + + private static final class StubTlsStrategy implements TlsStrategy { + @Override + public boolean upgrade(final TransportSecurityLayer sessionLayer, + final HttpHost host, + final SocketAddress localAddress, + final SocketAddress remoteAddress, + final Object attachment, + final Timeout handshakeTimeout) { + return true; + } + + @Override + public void upgrade(final TransportSecurityLayer sessionLayer, + final NamedEndpoint endpoint, + final Object attachment, + final Timeout handshakeTimeout, + final FutureCallback callback) { + if (callback != null) { + callback.completed(sessionLayer); + } + } + } + + private static T getField(final Object target, final String name, final Class type) { + try { + final Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return type.cast(field.get(target)); + } catch (final Exception ex) { + throw new AssertionError("Failed to access field " + name, ex); + } + } + + private static WebSocketHandler noopHandler() { + return new WebSocketHandler() { }; + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java new file mode 100644 index 0000000000..ae88106701 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java @@ -0,0 +1,118 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncPushProducer; +import org.apache.hc.core5.http.nio.ResponseChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.junit.jupiter.api.Test; + +class WebSocketH2ServerExchangeHandlerTest { + + private static final class CapturingResponseChannel implements ResponseChannel { + private HttpResponse response; + + @Override + public void sendInformation(final HttpResponse response, final HttpContext context) { + // not used + } + + @Override + public void sendResponse(final HttpResponse response, final EntityDetails entityDetails, final HttpContext context) { + this.response = response; + } + + @Override + public void pushPromise(final HttpRequest promise, final AsyncPushProducer responseProducer, final HttpContext context) { + // not used + } + + HttpResponse getResponse() { + return response; + } + } + + @Test + void rejectsNonConnectMethod() throws Exception { + final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler( + new WebSocketHandler() { + }, null, WebSocketExtensionRegistry.createDefault()); + + final HttpRequest request = new BasicHttpRequest(Method.GET, "/"); + request.addHeader(WebSocketConstants.PSEUDO_PROTOCOL, "websocket"); + + final CapturingResponseChannel channel = new CapturingResponseChannel(); + handler.handleRequest(request, null, channel, HttpCoreContext.create()); + + assertNotNull(channel.getResponse()); + assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode()); + } + + @Test + void rejectsMissingProtocolHeader() throws Exception { + final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler( + new WebSocketHandler() { + }, null, WebSocketExtensionRegistry.createDefault()); + + final HttpRequest request = new BasicHttpRequest(Method.CONNECT, "/echo"); + + final CapturingResponseChannel channel = new CapturingResponseChannel(); + handler.handleRequest(request, null, channel, HttpCoreContext.create()); + + assertNotNull(channel.getResponse()); + assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode()); + } + + @Test + void rejectsUnknownProtocol() throws Exception { + final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler( + new WebSocketHandler() { + }, null, WebSocketExtensionRegistry.createDefault()); + + final HttpRequest request = new BasicHttpRequest(Method.CONNECT, "/echo"); + request.addHeader(WebSocketConstants.PSEUDO_PROTOCOL, "chat"); + + final CapturingResponseChannel channel = new CapturingResponseChannel(); + handler.handleRequest(request, null, channel, HttpCoreContext.create()); + + assertNotNull(channel.getResponse()); + assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode()); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerTest.java new file mode 100644 index 0000000000..5cf29039ae --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerTest.java @@ -0,0 +1,168 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.ListenerEndpoint; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class WebSocketH2ServerTest { + + @Test + void startUsesEndpointAddressAndScheme() throws Exception { + final InetAddress local = InetAddress.getLoopbackAddress(); + final InetSocketAddress endpointAddress = new InetSocketAddress("127.0.0.2", 12345); + final StubHttpAsyncServer server = new StubHttpAsyncServer(endpointAddress); + final WebSocketH2Server ws = new WebSocketH2Server(server, local, 0, URIScheme.HTTPS); + try { + ws.start(); + Assertions.assertTrue(server.started); + Assertions.assertEquals(URIScheme.HTTPS, server.lastScheme); + Assertions.assertEquals(12345, ws.getLocalPort()); + Assertions.assertEquals("127.0.0.2", ws.getInetAddress().getHostAddress()); + } finally { + ws.stop(); + } + } + + @Test + void startPropagatesIOExceptionFromListen() { + final StubHttpAsyncServer server = new StubHttpAsyncServer(null); + server.failWith = new IOException("boom"); + final WebSocketH2Server ws = new WebSocketH2Server(server, null, 0, URIScheme.HTTP); + Assertions.assertThrows(IOException.class, ws::start); + } + + @Test + void accessorsReturnConfiguredValuesBeforeStart() { + final InetAddress local = InetAddress.getLoopbackAddress(); + final WebSocketH2Server ws = new WebSocketH2Server(new StubHttpAsyncServer(null), local, 8443, URIScheme.HTTP); + Assertions.assertEquals(local, ws.getInetAddress()); + Assertions.assertEquals(8443, ws.getLocalPort()); + } + + @Test + void stopAndShutdownDelegateToServer() { + final StubHttpAsyncServer server = new StubHttpAsyncServer(null); + final WebSocketH2Server ws = new WebSocketH2Server(server, null, 0, URIScheme.HTTP); + ws.initiateShutdown(); + Assertions.assertTrue(server.shutdownCalled); + ws.stop(); + Assertions.assertEquals(CloseMode.GRACEFUL, server.closeMode); + } + + private static final class StubHttpAsyncServer extends HttpAsyncServer { + private final InetSocketAddress endpointAddress; + private boolean started; + private boolean shutdownCalled; + private CloseMode closeMode; + private URIScheme lastScheme; + private SocketAddress lastAddress; + private IOException failWith; + + StubHttpAsyncServer(final InetSocketAddress endpointAddress) { + super((ioSession, attachment) -> null, + IOReactorConfig.DEFAULT, + null, + null, + null, + null, + null, + null); + this.endpointAddress = endpointAddress; + } + + @Override + public void start() { + started = true; + } + + @Override + public Future listen(final SocketAddress address, final URIScheme scheme) { + lastAddress = address; + lastScheme = scheme; + final CompletableFuture future = new CompletableFuture<>(); + if (failWith != null) { + future.completeExceptionally(failWith); + } else { + final SocketAddress effective = endpointAddress != null ? endpointAddress : address; + future.complete(new StubListenerEndpoint(effective)); + } + return future; + } + + @Override + public void initiateShutdown() { + shutdownCalled = true; + } + + @Override + public void close(final CloseMode closeMode) { + this.closeMode = closeMode; + } + } + + private static final class StubListenerEndpoint implements ListenerEndpoint { + private final SocketAddress address; + private boolean closed; + + StubListenerEndpoint(final SocketAddress address) { + this.address = address; + } + + @Override + public SocketAddress getAddress() { + return address; + } + + @Override + public boolean isClosed() { + return closed; + } + + @Override + public void close(final CloseMode closeMode) { + closed = true; + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java new file mode 100644 index 0000000000..6394f94ef5 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java @@ -0,0 +1,148 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.SocketAddress; + +import javax.net.ssl.SSLSession; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.EndpointDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.ProtocolVersion; +import org.apache.hc.core5.http.io.HttpServerConnection; +import org.apache.hc.core5.http.io.HttpServerRequestHandler; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.impl.HttpProcessors; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Test; + +class WebSocketHttpServiceTest { + + @Test + void setsConnectionInContext() throws Exception { + final HttpProcessor processor = HttpProcessors.server(); + final HttpServerRequestHandler handler = (request, trigger, context) -> { + }; + final WebSocketHttpService service = new WebSocketHttpService(processor, handler, null, null, null); + final HttpContext context = HttpCoreContext.create(); + + final FailingConnection connection = new FailingConnection(); + final IOException thrown = assertThrows(IOException.class, + () -> service.handleRequest(connection, context)); + assertEquals("boom", thrown.getMessage()); + assertEquals(connection, context.getAttribute(WebSocketContextKeys.CONNECTION)); + } + + private static final class FailingConnection implements HttpServerConnection { + @Override + public ClassicHttpRequest receiveRequestHeader() throws HttpException, IOException { + throw new IOException("boom"); + } + + @Override + public void receiveRequestEntity(final ClassicHttpRequest request) throws HttpException, IOException { + } + + @Override + public void sendResponseHeader(final ClassicHttpResponse response) throws HttpException, IOException { + } + + @Override + public void sendResponseEntity(final ClassicHttpResponse response) throws HttpException, IOException { + } + + @Override + public boolean isDataAvailable(final Timeout timeout) { + return false; + } + + @Override + public boolean isStale() { + return false; + } + + @Override + public void flush() { + } + + @Override + public void close() throws IOException { + } + + @Override + public void close(final CloseMode closeMode) { + } + + @Override + public Timeout getSocketTimeout() { + return Timeout.ZERO_MILLISECONDS; + } + + @Override + public void setSocketTimeout(final Timeout timeout) { + } + + @Override + public EndpointDetails getEndpointDetails() { + return null; + } + + @Override + public SocketAddress getLocalAddress() { + return null; + } + + @Override + public SocketAddress getRemoteAddress() { + return null; + } + + @Override + public ProtocolVersion getProtocolVersion() { + return null; + } + + @Override + public SSLSession getSSLSession() { + return null; + } + + @Override + public boolean isOpen() { + return true; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java new file mode 100644 index 0000000000..18acfe471e --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.junit.jupiter.api.Test; + +class WebSocketServerBootstrapTest { + + @Test + void createsServerWithDefaults() { + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .register("/ws", () -> new WebSocketHandler() { + }) + .create(); + assertNotNull(server); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java new file mode 100644 index 0000000000..e9111188c1 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.net.ServerSocket; +import java.net.Socket; + +import org.junit.jupiter.api.Test; + +class WebSocketServerConnectionFactoryTest { + + @Test + void createsBoundConnection() throws Exception { + final ServerSocket server = new ServerSocket(0); + final Socket client = new Socket("127.0.0.1", server.getLocalPort()); + final Socket socket = server.accept(); + client.close(); + server.close(); + + final WebSocketServerConnectionFactory factory = new WebSocketServerConnectionFactory("http", null, null); + final WebSocketServerConnection conn = factory.createConnection(socket); + assertNotNull(conn.getSocketInputStream()); + assertNotNull(conn.getSocketOutputStream()); + conn.close(); + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java new file mode 100644 index 0000000000..31ee63b14a --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java @@ -0,0 +1,146 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.junit.jupiter.api.Test; + +class WebSocketServerProcessorTest { + + private static final byte[] MASK = new byte[]{1, 2, 3, 4}; + + private static byte[] maskedFrame(final int opcode, final byte[] payload) { + final int len = payload.length; + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x80 | (opcode & 0x0F)); + if (len <= 125) { + out.write(0x80 | len); + } else if (len <= 0xFFFF) { + out.write(0x80 | 126); + out.write((len >> 8) & 0xFF); + out.write(len & 0xFF); + } else { + out.write(0x80 | 127); + final long l = len; + for (int i = 7; i >= 0; i--) { + out.write((int) ((l >> (i * 8)) & 0xFF)); + } + } + out.write(MASK, 0, MASK.length); + for (int i = 0; i < len; i++) { + out.write(payload[i] ^ MASK[i % 4]); + } + return out.toByteArray(); + } + + private static byte[] closePayload(final int code, final String reason) { + final byte[] reasonBytes = reason != null ? reason.getBytes(StandardCharsets.UTF_8) : new byte[0]; + final byte[] payload = new byte[2 + reasonBytes.length]; + payload[0] = (byte) ((code >> 8) & 0xFF); + payload[1] = (byte) (code & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + return payload; + } + + @Test + void processesTextAndCloseFrames() throws Exception { + final byte[] text = "hello".getBytes(StandardCharsets.UTF_8); + final byte[] close = closePayload(1000, "bye"); + final ByteArrayOutputStream frames = new ByteArrayOutputStream(); + frames.write(maskedFrame(0x1, text)); + frames.write(maskedFrame(0x8, close)); + + final ByteArrayInputStream in = new ByteArrayInputStream(frames.toByteArray()); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final WebSocketSession session = new WebSocketSession( + WebSocketConfig.DEFAULT, + in, + out, + null, + null, + Collections.emptyList()); + + final TrackingHandler handler = new TrackingHandler(); + final WebSocketServerProcessor processor = new WebSocketServerProcessor(session, handler, 1024); + processor.process(); + + assertEquals("hello", handler.text); + assertEquals(1000, handler.closeCode); + assertEquals("bye", handler.closeReason); + assertTrue(out.size() > 0, "server should send close response"); + } + + private static final class TrackingHandler implements WebSocketHandler { + private String text; + private int closeCode; + private String closeReason; + + @Override + public void onText(final WebSocketSession session, final String payload) { + this.text = payload; + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onPong(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + this.closeCode = code; + this.closeReason = reason; + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + } + + @Override + public String selectSubprotocol(final List protocols) { + return null; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java new file mode 100644 index 0000000000..056d834c3b --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java @@ -0,0 +1,160 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequestMapper; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.HttpServerRequestHandler.ResponseTrigger; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.websocket.WebSocketConfig; +import org.apache.hc.core5.websocket.WebSocketConstants; +import org.apache.hc.core5.websocket.WebSocketExtensionRegistry; +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.apache.hc.core5.websocket.WebSocketSession; +import org.junit.jupiter.api.Test; + +class WebSocketServerRequestHandlerTest { + + @Test + void upgradesValidRequest() throws Exception { + final AtomicBoolean opened = new AtomicBoolean(false); + final Supplier supplier = () -> new WebSocketHandler() { + @Override + public void onOpen(final WebSocketSession session) { + opened.set(true); + } + + @Override + public void onText(final WebSocketSession session, final String payload) { + } + + @Override + public void onBinary(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onPing(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onPong(final WebSocketSession session, final ByteBuffer payload) { + } + + @Override + public void onClose(final WebSocketSession session, final int code, final String reason) { + } + + @Override + public void onError(final WebSocketSession session, final Exception cause) { + } + + @Override + public String selectSubprotocol(final List protocols) { + return null; + } + }; + + final HttpRequestMapper> mapper = (request, context) -> supplier; + final WebSocketServerRequestHandler handler = new WebSocketServerRequestHandler( + mapper, + WebSocketConfig.DEFAULT, + WebSocketExtensionRegistry.createDefault()); + + final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/ws"); + request.addHeader(HttpHeaders.CONNECTION, "Upgrade"); + request.addHeader(HttpHeaders.UPGRADE, "websocket"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION, "13"); + request.addHeader(WebSocketConstants.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ=="); + + final RecordingTrigger trigger = new RecordingTrigger(); + final HttpContext context = HttpCoreContext.create(); + + final WebSocketServerConnection connection = createConnection(); + context.setAttribute(WebSocketContextKeys.CONNECTION, connection); + handler.handle(request, trigger, context); + + assertNotNull(trigger.response); + assertEquals(HttpStatus.SC_SWITCHING_PROTOCOLS, trigger.response.getCode()); + assertEquals("websocket", trigger.response.getFirstHeader(HttpHeaders.UPGRADE).getValue()); + assertTrue(opened.get()); + connection.close(); + } + + @Test + void returnsUpgradeRequiredForNonUpgradeRequest() throws Exception { + final WebSocketServerRequestHandler handler = new WebSocketServerRequestHandler( + (request, context) -> () -> null, + WebSocketConfig.DEFAULT, + new WebSocketExtensionRegistry()); + + final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/ws"); + final RecordingTrigger trigger = new RecordingTrigger(); + handler.handle(request, trigger, HttpCoreContext.create()); + + assertEquals(HttpStatus.SC_UPGRADE_REQUIRED, trigger.response.getCode()); + } + + private static WebSocketServerConnection createConnection() throws IOException { + final ServerSocket server = new ServerSocket(0); + final Socket client = new Socket("127.0.0.1", server.getLocalPort()); + final Socket socket = server.accept(); + client.close(); + server.close(); + final WebSocketServerConnectionFactory factory = new WebSocketServerConnectionFactory("http", null, null); + return factory.createConnection(socket); + } + + private static final class RecordingTrigger implements ResponseTrigger { + ClassicHttpResponse response; + + @Override + public void sendInformation(final ClassicHttpResponse response) { + this.response = response; + } + + @Override + public void submitResponse(final ClassicHttpResponse response) { + this.response = response; + } + } +} diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java new file mode 100644 index 0000000000..e0e34bd356 --- /dev/null +++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java @@ -0,0 +1,45 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.websocket.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hc.core5.websocket.WebSocketHandler; +import org.junit.jupiter.api.Test; + +class WebSocketServerTest { + + @Test + void exposesServerInfo() { + final WebSocketServer server = WebSocketServerBootstrap.bootstrap() + .register("/ws", () -> new WebSocketHandler() { + }) + .create(); + assertNotNull(server); + server.getLocalPort(); + } +} diff --git a/httpclient5-websocket/src/test/resources/log4j2.xml b/httpclient5-websocket/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..ce9e796abd --- /dev/null +++ b/httpclient5-websocket/src/test/resources/log4j2.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/httpclient5-websocket/src/test/resources/test.keystore b/httpclient5-websocket/src/test/resources/test.keystore new file mode 100644 index 0000000000..f8d5ace1ad Binary files /dev/null and b/httpclient5-websocket/src/test/resources/test.keystore differ diff --git a/pom.xml b/pom.xml index 1a0d7291c6..2ac4cdd382 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ 1.8 1.8 - 5.4 + 5.5-alpha1-SNAPSHOT 2.25.3 1.20.0 2.5.2 @@ -135,6 +135,11 @@ httpclient5-sse ${project.version}
+ + org.apache.httpcomponents.client5 + httpclient5-websocket + ${project.version} + org.slf4j slf4j-api @@ -273,6 +278,7 @@ httpclient5-sse httpclient5-observation httpclient5-fluent + httpclient5-websocket httpclient5-cache httpclient5-testing @@ -495,6 +501,10 @@ Apache HttpClient SSE org.apache.hc.client5.http.sse* + + Apache HttpClient SSE + org.apache.hc.client5.http.websocket* +