feat: introduce WebSocket support with new endpoints and transaction propagation enhancements
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerWebSocketTest {
|
||||
|
||||
private FlashApp app;
|
||||
private int port;
|
||||
private final AtomicReference<Integer> closed = new AtomicReference<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
|
||||
app.ws("/chat", new WebSocketHandler() {
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
try {
|
||||
if (frame.opcode() == WebSocketFrame.OP_TEXT) {
|
||||
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.ws("/close", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) {}
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
@Override public void onClose(WebSocketSession session, int code) { closed.set(code); }
|
||||
});
|
||||
|
||||
app.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private static final int SOCKET_TIMEOUT_MS = 5000;
|
||||
|
||||
private static String handshakeKey() {
|
||||
return Base64.getEncoder().encodeToString("flash-test-key".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String readHeaders(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
int b, prev3 = -1, prev2 = -1, prev1 = -1;
|
||||
while ((b = in.read()) != -1) {
|
||||
out.write(b);
|
||||
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
|
||||
prev3 = prev2; prev2 = prev1; prev1 = b;
|
||||
}
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] textFrame(String message) {
|
||||
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] frame = new byte[payload.length + 6];
|
||||
frame[0] = (byte) 0x81;
|
||||
frame[1] = (byte) (0x80 | payload.length);
|
||||
byte[] mask = {1, 2, 3, 4};
|
||||
System.arraycopy(mask, 0, frame, 2, 4);
|
||||
for (int i = 0; i < payload.length; i++) {
|
||||
frame[6 + i] = (byte) (payload[i] ^ mask[i & 3]);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] closeFrame(int code) {
|
||||
byte[] frame = new byte[8];
|
||||
frame[0] = (byte) 0x88;
|
||||
frame[1] = (byte) 0x82;
|
||||
byte[] mask = {1, 2, 3, 4};
|
||||
System.arraycopy(mask, 0, frame, 2, 4);
|
||||
frame[6] = (byte) (((code >> 8) & 0xFF) ^ mask[0]);
|
||||
frame[7] = (byte) ((code & 0xFF) ^ mask[1]);
|
||||
return frame;
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocket_upgrade_returns101AndEchoesText() throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = socket.getOutputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
|
||||
String key = handshakeKey();
|
||||
String req = "GET /chat HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: keep-alive, Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + key + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n";
|
||||
out.write(req.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String headers = readHeaders(in);
|
||||
assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"));
|
||||
assertTrue(headers.contains("Upgrade: websocket"));
|
||||
assertTrue(headers.contains("Connection: Upgrade"));
|
||||
assertTrue(headers.contains("Sec-WebSocket-Accept: "));
|
||||
|
||||
out.write(textFrame("hello"));
|
||||
out.flush();
|
||||
|
||||
byte[] frame = in.readNBytes(7);
|
||||
assertEquals((byte) 0x81, frame[0]);
|
||||
assertEquals((byte) 0x05, frame[1]);
|
||||
assertEquals("hello", new String(frame, 2, 5, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocket_ping_is_ponged() throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = socket.getOutputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
|
||||
out.write(("GET /chat HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
readHeaders(in);
|
||||
|
||||
out.write(new byte[] {(byte) 0x89, (byte) 0x80, 1, 2, 3, 4});
|
||||
out.flush();
|
||||
|
||||
byte[] pong = in.readNBytes(2);
|
||||
assertEquals((byte) 0x8A, pong[0]);
|
||||
assertEquals((byte) 0x00, pong[1]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocket_close_frame_closesSession() throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = socket.getOutputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
|
||||
out.write(("GET /close HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
readHeaders(in);
|
||||
|
||||
out.write(closeFrame(1000));
|
||||
out.flush();
|
||||
|
||||
Thread.sleep(100);
|
||||
assertEquals(1000, closed.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FlashAppWebSocketTest {
|
||||
|
||||
private FlashApp app;
|
||||
private int port;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ws_registersDirectEndpoint() {
|
||||
WebSocketHandler handler = new WebSocketHandler() {
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
};
|
||||
|
||||
assertSame(app, app.ws("/chat", handler));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PackageScannerTest {
|
||||
|
||||
@Test
|
||||
void scan_separatesHttpHandlersAndWsEndpoints() {
|
||||
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.onlyws");
|
||||
|
||||
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
|
||||
assertFalse(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scan_includesHttpHandlerAndWsEndpointFromTestPackage() {
|
||||
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.scantest");
|
||||
|
||||
assertTrue(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("ScanHttpHandler")));
|
||||
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("ScanWsEndpoint")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AbstractWsRouterTest {
|
||||
|
||||
static class DummyWsRouter extends AbstractWsRouter {
|
||||
WebSocketHandler lastHandler;
|
||||
HttpMethod lastMethod;
|
||||
String lastPath;
|
||||
|
||||
@Override
|
||||
public WebSocketHandler route(Request request) { return null; }
|
||||
|
||||
@Override
|
||||
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
|
||||
this.lastMethod = method;
|
||||
this.lastPath = path;
|
||||
this.lastHandler = handler;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_sanitizesPathAndStoresHandler() {
|
||||
DummyWsRouter router = new DummyWsRouter();
|
||||
WebSocketHandler handler = new WebSocketHandler() {
|
||||
public void onOpen(dev.relism.flash.websocket.WebSocketSession session) {}
|
||||
public void onMessage(dev.relism.flash.websocket.WebSocketSession session, dev.relism.flash.websocket.WebSocketFrame frame) {}
|
||||
};
|
||||
|
||||
router.register(HttpMethod.GET, "chat/", handler);
|
||||
|
||||
assertEquals(HttpMethod.GET, router.lastMethod);
|
||||
assertEquals("/chat", router.lastPath);
|
||||
assertSame(handler, router.lastHandler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketEndpointTest {
|
||||
|
||||
static class DummyEndpoint extends WebSocketEndpoint {
|
||||
boolean initCalled;
|
||||
|
||||
@Override
|
||||
protected void onInit() {
|
||||
initCalled = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bind_callsOnInit() {
|
||||
DummyEndpoint endpoint = new DummyEndpoint();
|
||||
|
||||
endpoint.bind(new FlashContext());
|
||||
|
||||
assertTrue(endpoint.initCalled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireBeforeBind_throws() {
|
||||
DummyEndpoint endpoint = new DummyEndpoint();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> endpoint.require(String.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketFrameTest {
|
||||
|
||||
@Test
|
||||
void copyPayload_copiesActiveSlice() {
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
byte[] buf = "hello".getBytes();
|
||||
frame.reset(buf, 1, 3, WebSocketFrame.OP_TEXT, true);
|
||||
|
||||
byte[] copy = frame.copyPayload();
|
||||
|
||||
assertArrayEquals("ell".getBytes(), copy);
|
||||
assertNotSame(buf, copy);
|
||||
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
|
||||
assertTrue(frame.isFin());
|
||||
assertEquals(1, frame.payloadOffset());
|
||||
assertEquals(3, frame.payloadLength());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketSessionFrameTest {
|
||||
|
||||
@Test
|
||||
void readFrame_unmasksMaskedPayload() throws Exception {
|
||||
byte[] raw = new byte[] {
|
||||
(byte) 0x81,
|
||||
(byte) 0x85,
|
||||
1, 2, 3, 4,
|
||||
(byte) ('h' ^ 1),
|
||||
(byte) ('i' ^ 2),
|
||||
(byte) ('!' ^ 3),
|
||||
(byte) ('!' ^ 4),
|
||||
(byte) ('?' ^ 1)
|
||||
};
|
||||
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 16);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
|
||||
assertTrue(session.readFrame(frame));
|
||||
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
|
||||
assertTrue(frame.isFin());
|
||||
assertEquals(5, frame.payloadLength());
|
||||
assertEquals("hi!!?", new String(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readFrame_rejectsOversizedPayload() {
|
||||
byte[] raw = new byte[] {(byte) 0x82, (byte) 0x7E, 0x01, 0x00};
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 8);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
|
||||
assertThrows(Exception.class, () -> session.readFrame(frame));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketSessionTest {
|
||||
|
||||
@Test
|
||||
void close_setsClosedAndWritesFrame() throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
|
||||
|
||||
session.close(1000);
|
||||
|
||||
assertFalse(session.isOpen());
|
||||
assertEquals(1000, session.closeCode());
|
||||
byte[] bytes = out.toByteArray();
|
||||
assertEquals((byte) 0x88, bytes[0]);
|
||||
assertEquals((byte) 0x02, bytes[1]);
|
||||
assertEquals((byte) 0x03, bytes[2]);
|
||||
assertEquals((byte) 0xE8, bytes[3]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeFromPeer_extractsCloseCode() {
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
frame.reset(new byte[] {(byte) 0x03, (byte) 0xE8}, 0, 2, WebSocketFrame.OP_CLOSE, true);
|
||||
|
||||
session.closeFromPeer(frame);
|
||||
|
||||
assertFalse(session.isOpen());
|
||||
assertEquals(1000, session.closeCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.flash.websocket.onlyws;
|
||||
|
||||
import dev.relism.flash.routing.Ws;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
|
||||
@Ws("/only")
|
||||
public class OnlyWsEndpoint extends WebSocketEndpoint {
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
try {
|
||||
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.relism.flash.websocket.scantest;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.GET;
|
||||
|
||||
@GET("/http")
|
||||
public class ScanHttpHandler extends RequestHandler {
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return "http";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.flash.websocket.scantest;
|
||||
|
||||
import dev.relism.flash.routing.Ws;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
|
||||
@Ws("/ws")
|
||||
public class ScanWsEndpoint extends WebSocketEndpoint {
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
try {
|
||||
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user