Add WebSocket support with Jakarta WebSocket integration

- Implemented WebSocketSession abstract class with callback-based API
- Added ServletWebSocketSession with full Jakarta WebSocket bridging
- Created @WebSocket annotation for declarative endpoint marking
- Updated JettyApp to initialize Jakarta WebSocket container
- Split Request/Response into abstract base and servlet implementations
- Moved JettyApp to jabba.servlet package
- Moved annotations to jabba.decor package
- Added comprehensive WebSocket test suite (5 tests, all passing)
- Updated README.md with WebSocket documentation and examples
- All 31 tests passing (async, sync, security, websocket, database)
- Fixed spelling errors in README.md
This commit is contained in:
Amer Agovic
2026-01-07 08:57:12 -06:00
parent 222d2d886f
commit 5f36b3d3e2
42 changed files with 3868 additions and 789 deletions
@@ -22,10 +22,10 @@ public class TerminalTest {
name="dbo.Maps"
)
public static class Maps extends DBO{
public static Field map_id=Field.Int("map_id").setPk(true);
public static Field map_name=Field.Str("map_name");
public static Field created=Field.DateTime("created");
public static Field active=Field.Bool("active");
public static Field map_id=Field.Int("Map_id").setPk(true);
public static Field map_name=Field.Str("Map_name");
public static Field created=Field.DateTime("Created");
public static Field active=Field.Bool("Active");
static{
//Entity.publish(Maps.class);
}
@@ -12,14 +12,18 @@ public class ArgsConfigTest {
ArgsConfig args=new ArgsConfig("prog","--verbose","--key","value","cmd");
try {
args.load();
ArgsConfig.Property<String> env_user=new ArgsConfig.Property<>("USER",String.class);
// Cross-platform username check: USER on Unix/Linux/Mac, USERNAME on Windows
String osName = System.getProperty("os.name").toLowerCase();
boolean isWindows = osName.contains("win");
ArgsConfig.Property<String> env_user = new ArgsConfig.Property<>(
isWindows ? "USERNAME" : "USER", String.class);
ArgsConfig.Property<String> sys_user=new ArgsConfig.Property<>("user.name",String.class);
ArgsConfig.Property<Boolean> verbose=new ArgsConfig.Property<>("verbose",Boolean.class);
String usr_val1=args.getProperty(env_user,"None1");
String usr_val2=args.getProperty(sys_user,"None2");
System.out.println("Env User:"+usr_val1);
System.out.println("Sys User:"+usr_val2);
assertTrue(usr_val1.equals(usr_val2));
assertTrue("Environment username should match system username", usr_val1.equals(usr_val2));
System.out.println("Positional:"+args.getProperty(Config.APP_ARGS, null));
System.out.println("Verbose:"+verbose.get(args));
for(ArgsConfig.Property<?> p:args){
@@ -0,0 +1,305 @@
/*
Copyright (c) 2011-2022 Reliancy LLC
Licensed under the GNU LESSER GENERAL PUBLIC LICENSE Version 3.
You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html.
You may not use this file except in compliance with the License.
*/
package com.reliancy.jabba;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import com.reliancy.jabba.decor.Async;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.servlet.JettyApp;
/**
* Test async endpoint support.
*/
public class AsyncTest {
public static class TestApp extends JettyApp {
@Override
public void configure(Config conf) throws Exception {
super.configure(conf);
// Import routes from this app - router is set by super.configure()
Router router = getRouter();
if(router != null){
router.importMethods(this);
router.compile();
} else {
// Router not set yet, set it ourselves
Router newRouter = new Router();
newRouter.importMethods(this);
newRouter.compile();
setRouter(newRouter);
}
}
@Routed(path="/async")
public CompletableFuture<String> asyncEndpoint() {
return CompletableFuture.supplyAsync(() -> {
try {
// Simulate long-running operation
Thread.sleep(100);
return "Async result";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
@Routed(path="/sync")
public String syncEndpoint() {
return "Sync result";
}
@Routed(path="/asyncWithParam")
public CompletableFuture<String> asyncWithParam(int delay) {
return CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(delay);
return "Delayed: " + delay + "ms";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
@Routed(path="/asyncAnnotation")
@Async
public String asyncWithAnnotation(String input, int value) {
// Regular method with @Async annotation - should be detected as async
return "Processed: " + input + " (" + value + ")";
}
}
@Test
public void testAsyncEndpointDetection() throws Exception {
TestApp app = new TestApp();
// Test async endpoint directly
java.lang.reflect.Method asyncMethod = TestApp.class.getMethod("asyncEndpoint");
MethodEndPoint asyncEp = new MethodEndPoint(app, asyncMethod);
assertTrue("Endpoint should be detected as async", asyncEp.isAsync());
// Test sync endpoint directly
java.lang.reflect.Method syncMethod = TestApp.class.getMethod("syncEndpoint");
MethodEndPoint syncEp = new MethodEndPoint(app, syncMethod);
assertFalse("Endpoint should be detected as sync", syncEp.isAsync());
// Test async with params
java.lang.reflect.Method asyncParamMethod = TestApp.class.getMethod("asyncWithParam", int.class);
MethodEndPoint asyncParamEp = new MethodEndPoint(app, asyncParamMethod);
assertTrue("Endpoint with params should be detected as async", asyncParamEp.isAsync());
}
@Test
public void testCompletableFutureReturnType() throws Exception {
TestApp app = new TestApp();
java.lang.reflect.Method method = TestApp.class.getMethod("asyncEndpoint");
MethodEndPoint endpoint = new MethodEndPoint(app, method);
assertTrue("Should detect CompletableFuture return type", endpoint.isAsync());
}
@Test
public void testAsyncAnnotation() throws Exception {
TestApp app = new TestApp();
// Test method with @Async annotation and regular args/return type
java.lang.reflect.Method asyncAnnotMethod = TestApp.class.getMethod("asyncWithAnnotation", String.class, int.class);
MethodEndPoint asyncAnnotEp = new MethodEndPoint(app, asyncAnnotMethod);
// Should be detected as async because of @Async annotation
assertTrue("Endpoint with @Async annotation should be detected as async", asyncAnnotEp.isAsync());
// Verify it has regular return type (not CompletableFuture)
assertFalse("Return type should not be CompletableFuture",
CompletableFuture.class.isAssignableFrom(asyncAnnotEp.method.getReturnType()));
// Verify it has regular parameters
assertEquals("Should have 2 parameters", 2, asyncAnnotEp.method.getParameterCount());
}
private TestApp app;
private int testPort;
private String baseUrl;
@Before
public void setUp() throws Exception {
// Use a random port to avoid conflicts
testPort = 18090 + (int)(Math.random() * 1000);
baseUrl = "http://localhost:" + testPort;
app = new TestApp();
ArgsConfig config = new ArgsConfig();
Config.SERVER_PORT.set(config, testPort);
config.load();
app.begin(config);
// Wait for server to be started (not necessarily running, which requires work() to be called)
int attempts = 0;
while(!app.isStarted() && attempts < 20){
Thread.sleep(100);
attempts++;
}
if(!app.isStarted()){
throw new Exception("Server failed to start on port " + testPort);
}
// Give server a moment to be ready
Thread.sleep(200);
}
@After
public void tearDown() throws Exception {
if(app != null){
try {
if(app.isRunning()){
app.end();
// Give server a moment to stop
Thread.sleep(300);
}
} catch (Exception e) {
// Ignore cleanup errors
}
app = null;
}
}
/**
* Helper method to make HTTP GET request
*/
private String httpGet(String path) throws Exception {
URL url = new URL(baseUrl + path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(15000); // Longer timeout for async operations
int responseCode = conn.getResponseCode();
if(responseCode == HttpURLConnection.HTTP_OK){
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
StringBuilder response = new StringBuilder();
String line;
while((line = in.readLine()) != null){
response.append(line);
}
in.close();
return response.toString();
}else{
// Read error stream for more info
String errorMsg = "HTTP request failed with code: " + responseCode;
try {
BufferedReader err = new BufferedReader(new InputStreamReader(conn.getErrorStream()));
String errLine;
while((errLine = err.readLine()) != null){
errorMsg += "\n" + errLine;
}
err.close();
} catch (Exception e) {
// Ignore
}
throw new Exception(errorMsg);
}
}
@Test
public void testSyncEndpointIntegration() throws Exception {
// Test synchronous endpoint first to verify basic connectivity
String result = httpGet("/sync");
assertEquals("Sync endpoint should return correct result", "Sync result", result);
}
@Test
public void testAsyncEndpointIntegration() throws Exception {
// Test CompletableFuture return type endpoint
long startTime = System.currentTimeMillis();
String result = httpGet("/async");
long duration = System.currentTimeMillis() - startTime;
assertEquals("Async endpoint should return correct result", "Async result", result);
// Should take at least 100ms (the sleep time in the endpoint)
assertTrue("Async endpoint should take time", duration >= 90);
}
@Test
public void testAsyncWithParamIntegration() throws Exception {
// Test async endpoint with parameters
long startTime = System.currentTimeMillis();
String result = httpGet("/asyncWithParam?delay=50");
long duration = System.currentTimeMillis() - startTime;
assertTrue("Result should contain delay info", result.contains("Delayed: 50ms"));
// Should take at least 50ms
assertTrue("Async endpoint with delay should take time", duration >= 40);
}
@Test
public void testAsyncAnnotationIntegration() throws Exception {
// Test @Async annotation endpoint
String result = httpGet("/asyncAnnotation?input=test&value=42");
assertEquals("Async annotation endpoint should return correct result",
"Processed: test (42)", result);
}
@Test
public void testAsyncNonBlocking() throws Exception {
// Test that async endpoints don't block the server
// First verify the endpoint works with a single request
String singleResult = httpGet("/async");
assertEquals("Single async request should work", "Async result", singleResult);
// Make multiple concurrent requests
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
return httpGet("/async");
} catch (Exception e) {
e.printStackTrace();
return "ERROR: " + e.getMessage();
}
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
try {
return httpGet("/async");
} catch (Exception e) {
e.printStackTrace();
return "ERROR: " + e.getMessage();
}
});
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
try {
return httpGet("/async");
} catch (Exception e) {
e.printStackTrace();
return "ERROR: " + e.getMessage();
}
});
// Wait for all to complete
CompletableFuture.allOf(future1, future2, future3).join();
// All should succeed
String result1 = future1.get();
String result2 = future2.get();
String result3 = future3.get();
assertEquals("First request should succeed", "Async result", result1);
assertEquals("Second request should succeed", "Async result", result2);
assertEquals("Third request should succeed", "Async result", result3);
}
}
+12 -6
View File
@@ -4,8 +4,10 @@ import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.sec.NotAuthentic;
import com.reliancy.jabba.sec.Secured;
import com.reliancy.jabba.servlet.JettyApp;
import com.reliancy.jabba.sec.SecurityActor;
import com.reliancy.jabba.sec.SecurityPolicy;
import com.reliancy.jabba.sec.plain.PlainSecurityStore;
@@ -22,7 +24,10 @@ import com.reliancy.util.Resources;
*/
public class DemoApp extends JettyApp implements AppModule{
public static void main( String[] args ) throws Exception{
Config cnf=new ArgsConfig(args).load();
ArgsConfig cnf=new ArgsConfig(args);
cnf.setProperty(Config.SERVER_PORT,8088);
cnf.setProperty(Config.LOG_LEVEL,"DEBUG"); // Set BEFORE load()
cnf.load();
JettyApp app=new DemoApp();
app.run(cnf);
}
@@ -57,11 +62,13 @@ public class DemoApp extends JettyApp implements AppModule{
// install file sever endpoint
FileServer fs=new FileServer("/static","/public");
fs.publish(app);
// publish DemoApp's own routes
this.publish(app);
Menu top_menu=Menu.request(Menu.TOP);
top_menu.add(new MenuItem("home")).addSpacer().add(new MenuItem("login"));
top_menu.setTitle("Jabba3");
app.getRouter().compile();
System.out.println(app.getRouter().regex);
log().debug("Router regex:{}",app.getRouter().regex);
}
@Override
public void publish(App app) {
@@ -74,7 +81,7 @@ public class DemoApp extends JettyApp implements AppModule{
String ret="";
try {
Template t=Template.find("/templates/login.hbs");
System.out.println("Template:"+t);
log().debug("Template:{}",t);
ret = t.render(context).toString();
} catch (IOException e) {
e.printStackTrace();
@@ -127,11 +134,10 @@ public class DemoApp extends JettyApp implements AppModule{
// here we need to process login and redirect
AppSession ass=AppSession.getInstance();
try{
System.out.println("Post login");
log().debug("Post login");
String userid=(String)req.getParam("userid",null);
String pwd=(String)req.getParam("password",null);
System.out.println("SS:"+ass);
System.out.println("P:"+userid+"/"+pwd);
log().debug("Session:{}",ass);
SecurityPolicy secpol=ass.getApp().getSecurityPolicy();
SecurityActor user=secpol.authenticate(userid, pwd);
if(user==null) throw new NotAuthentic("invalid credentials");
@@ -0,0 +1,185 @@
/*
Copyright (c) 2011-2022 Reliancy LLC
Licensed under the GNU LESSER GENERAL PUBLIC LICENSE Version 3.
You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html.
You may not use this file except in compliance with the License.
*/
package com.reliancy.jabba;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.servlet.JettyApp;
/**
* Integration tests for JettyApp regular (non-async) functionality.
*/
public class JettyAppTest {
public static class SimpleTestApp extends JettyApp implements AppModule {
@Override
public void configure(Config conf) throws Exception {
super.configure(conf);
// Set up router and import methods
Router router = getRouter();
if(router == null){
router = new Router();
setRouter(router);
}
router.importMethods(this);
router.compile();
}
@Override
public void publish(App app) {
app.getRouter().importMethods(this);
}
@Routed(path="/test")
public String test() {
return "test response";
}
@Routed(path="/testPlain")
public void testPlain(Request req, Response resp) throws java.io.IOException {
resp.getEncoder().writeln("plain response");
}
@Routed(path="/testParam/{id:int}")
public String testParam(int id) {
return "param: " + id;
}
@Routed(path="/testQuery")
public String testQuery(String name) {
return "query: " + name;
}
@Routed(path="/testNoArg")
public String testNoArg() {
return "no arg response";
}
}
private SimpleTestApp app;
private int testPort;
private String baseUrl;
@Before
public void setUp() throws Exception {
// Use a random port to avoid conflicts
testPort = 18090 + (int)(Math.random() * 1000);
baseUrl = "http://localhost:" + testPort;
app = new SimpleTestApp();
ArgsConfig config = new ArgsConfig();
Config.SERVER_PORT.set(config, testPort);
config.load();
app.begin(config);
// Wait for server to be started
int attempts = 0;
while(!app.isStarted() && attempts < 20){
Thread.sleep(100);
attempts++;
}
if(!app.isStarted()){
throw new Exception("Server failed to start on port " + testPort);
}
// Give server a moment to be ready
Thread.sleep(200);
}
@After
public void tearDown() throws Exception {
if(app != null){
try {
if(app.isStarted()){
app.end();
// Give server a moment to stop
Thread.sleep(300);
}
} catch (Exception e) {
// Ignore cleanup errors
}
app = null;
}
}
/**
* Helper method to make HTTP GET request
*/
private String httpGet(String path) throws Exception {
URL url = new URL(baseUrl + path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int responseCode = conn.getResponseCode();
if(responseCode == HttpURLConnection.HTTP_OK){
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
StringBuilder response = new StringBuilder();
String line;
while((line = in.readLine()) != null){
response.append(line);
}
in.close();
return response.toString();
}else{
// Read error stream for more info
String errorMsg = "HTTP request failed with code: " + responseCode;
try {
BufferedReader err = new BufferedReader(new InputStreamReader(conn.getErrorStream()));
String errLine;
while((errLine = err.readLine()) != null){
errorMsg += "\n" + errLine;
}
err.close();
} catch (Exception e) {
// Ignore
}
throw new Exception(errorMsg);
}
}
@Test
public void testSimpleStringReturn() throws Exception {
String result = httpGet("/test");
assertEquals("Simple string return should work", "test response", result);
}
@Test
public void testPlainRequestResponse() throws Exception {
String result = httpGet("/testPlain");
assertTrue("Plain request/response should work", result.contains("plain response"));
}
@Test
public void testPathParameter() throws Exception {
String result = httpGet("/testParam/42");
assertEquals("Path parameter should work", "param: 42", result);
}
@Test
public void testQueryParameter() throws Exception {
String result = httpGet("/testQuery?name=testvalue");
assertEquals("Query parameter should work", "query: testvalue", result);
}
@Test
public void testNoArgMethod() throws Exception {
String result = httpGet("/testNoArg");
assertEquals("No-arg method should work", "no arg response", result);
}
}
@@ -13,6 +13,9 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.Test;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.servlet.JettyApp;
/**
* Unit test for simple App.
*/
@@ -0,0 +1,163 @@
/*
Copyright (c) 2011-2022 Reliancy LLC
Licensed under the GNU LESSER GENERAL PUBLIC LICENSE Version 3.
You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html.
You may not use this file except in compliance with the License.
*/
package com.reliancy.jabba;
import static org.junit.Assert.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.junit.Test;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.sec.SecurityPolicy;
import com.reliancy.jabba.sec.plain.PlainSecurityStore;
import com.reliancy.util.Handy;
/**
* Security tests for authentication and routing.
*/
public class SecurityTest {
/** Minimal test implementation of Response for testing */
static class TestResponse extends Response {
private int status = 200;
public TestResponse(Request request) {
super(request);
}
@Override public void setContentType(String type) {}
@Override public void setStatus(int status) { this.status = status; }
@Override public String getHeader(String name) {
for(HTTP.Header header : headers) {
if(header.key.equalsIgnoreCase(name)) return header.value;
}
return null;
}
@Override public Response setHeader(String name, String value) {
headers.add(new HTTP.Header(name.toLowerCase(), value));
return this;
}
public String getCookie(String name) {
for(HTTP.Cookie cookie : cookies) {
if(cookie.key.equals(name)) return cookie.value;
}
return null;
}
@Override public Response setCookie(String name, String value, int maxAge, boolean secure) {
cookies.add(new HTTP.Cookie(name, value, maxAge, secure, false));
return this;
}
@Override public boolean isCommitted() { return false; }
@Override public void commit() {}
@Override public boolean isCompleted() { return false; }
@Override public void complete() {}
@Override public java.io.OutputStream getOutputStream() throws IOException { return null; }
@Override public java.io.Writer getWriter() throws IOException { return null; }
@Override public com.reliancy.jabba.WebSocketSession upgradeToWebSocket(String route, com.reliancy.jabba.Session appSession) throws IOException {
throw new UnsupportedOperationException("WebSocket not supported in test");
}
}
@Test
public void testSecretKeyFromEnvironment() throws Exception {
// Test that secret key can be loaded from environment using reflection
String originalKey = System.getenv("JABBA_SECRET_KEY");
try {
System.setProperty("jabba.secret.key", "test-secret-key-12345");
SecurityPolicy policy = new SecurityPolicy();
java.lang.reflect.Method getSecretMethod = SecurityPolicy.class.getDeclaredMethod("getSecret");
getSecretMethod.setAccessible(true);
String secret = (String) getSecretMethod.invoke(policy);
assertNotNull("Secret should not be null", secret);
assertFalse("Secret should not be empty", secret.isEmpty());
} finally {
if (originalKey != null) {
System.setProperty("jabba.secret.key", originalKey);
} else {
System.clearProperty("jabba.secret.key");
}
}
}
@Test
public void testAESEncryption() {
// Test AES encryption/decryption
String key = "test-secret-key-for-encryption-12345678901234567890";
Map<String, String> data = new HashMap<>();
data.put("user", "testuser");
data.put("pass", "testpass");
String encrypted = Handy.encrypt(key, data);
assertNotNull("Encrypted data should not be null", encrypted);
assertFalse("Encrypted data should not be empty", encrypted.isEmpty());
Map<String, String> decrypted = Handy.decrypt(key, encrypted);
assertEquals("Decrypted user should match", "testuser", decrypted.get("user"));
assertEquals("Decrypted pass should match", "testpass", decrypted.get("pass"));
}
@Test
public void testInputValidation() throws Exception {
// Test input validation in MethodEndPoint using reflection to access protected method
MethodEndPoint endpoint = new MethodEndPoint(new TestEndpoint(),
TestEndpoint.class.getMethod("testMethod", String.class));
java.lang.reflect.Method validateMethod = MethodEndPoint.class.getDeclaredMethod(
"validateInput", Object.class, Class.class, String.class);
validateMethod.setAccessible(true);
// Test normal input
Object valid = validateMethod.invoke(endpoint, "normal string", String.class, "testParam");
assertEquals("Normal string should pass validation", "normal string", valid);
// Test null input
Object nullVal = validateMethod.invoke(endpoint, null, String.class, "testParam");
assertNull("Null input should return null", nullVal);
// Test very long string (should be truncated)
StringBuilder longStr = new StringBuilder();
for (int i = 0; i < 100001; i++) {
longStr.append("a");
}
Object longInput = validateMethod.invoke(endpoint, longStr.toString(), String.class, "testParam");
assertNotNull("Long input should not be null", longInput);
assertTrue("Long input should be truncated", ((String)longInput).length() <= 100000);
}
@Test
public void testCookieSecurity() throws IOException {
// Test that cookies are set with HttpOnly flag
Response response = new TestResponse((Request)null);
response.setCookie("test", "value", 3600, true, true);
// Verify cookie was added
assertNotNull("Cookie should be added", response.getCookie("test"));
assertEquals("Cookie value should match", "value", response.getCookie("test"));
}
@Test
public void testResponseHeaderLookup() {
// Test that header lookup works correctly (bug fix verification)
Response response = new TestResponse((Request)null);
response.setHeader("Content-Type", "application/json");
String header = response.getHeader("content-type");
assertEquals("Header lookup should be case-insensitive", "application/json", header);
}
// Test endpoint class for testing
public static class TestEndpoint {
@Routed
public String testMethod(String param) {
return param;
}
}
}
@@ -0,0 +1,363 @@
/*
Copyright (c) 2011-2022 Reliancy LLC
Licensed under the GNU LESSER GENERAL PUBLIC LICENSE Version 3.
You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html.
You may not use this file except in compliance with the License.
*/
package com.reliancy.jabba;
import java.net.URI;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.Session.Listener;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.decor.WebSocket;
import com.reliancy.jabba.servlet.JettyApp;
/**
* Integration tests for WebSocket functionality.
* Tests the new WebSocket architecture using @WebSocket + @Routed annotations
* and WebSocketSession argument-based endpoints.
*/
public class WebSocketTest {
/**
* Test application with WebSocket endpoints using new API.
*/
public static class TestWebSocketApp extends JettyApp {
private int messageCount = 0;
public TestWebSocketApp() {
super();
}
@Override
public void configure(Config conf) throws Exception {
super.configure(conf);
// Import methods from this class to the router
Router router = getRouter();
if(router != null) {
router.importMethods(this);
router.compile();
}
}
// Echo endpoint - sends back what it receives
@Routed(path="/ws/echo")
@WebSocket
public void echoEndpoint(com.reliancy.jabba.WebSocketSession session) {
session.onText(msg -> {
try {
session.sendText("Echo: " + msg);
} catch (Exception e) {
e.printStackTrace();
}
});
}
// Simple endpoint that counts messages
@Routed(path="/ws/counter")
@WebSocket
public void counterEndpoint(com.reliancy.jabba.WebSocketSession session) {
session.onText(msg -> {
try {
messageCount++;
session.sendText("Message #" + messageCount);
} catch (Exception e) {
e.printStackTrace();
}
});
}
// Endpoint with immediate response on connect
@Routed(path="/ws/session")
@WebSocket
public void sessionEndpoint(com.reliancy.jabba.WebSocketSession session) {
session.onText(msg -> {
try {
session.sendText("Connected: " + session.getId());
} catch (Exception e) {
e.printStackTrace();
}
});
}
// HTTP endpoint for comparison
@Routed(path="/test")
public String testHttp() {
return "HTTP works";
}
public int getMessageCount() {
return messageCount;
}
}
/**
* Simple WebSocket client for testing (using Jetty 12 Session.Listener API).
*/
public static class TestWebSocketClient implements Session.Listener.AutoDemanding {
private final BlockingQueue<String> messages = new LinkedBlockingQueue<>();
private final CompletableFuture<Session> connectFuture = new CompletableFuture<>();
private final CompletableFuture<Void> closeFuture = new CompletableFuture<>();
private Session session;
@Override
public void onWebSocketOpen(Session session) {
this.session = session;
connectFuture.complete(session);
}
@Override
public void onWebSocketText(String message) {
messages.add(message);
}
@Override
public void onWebSocketClose(int statusCode, String reason) {
closeFuture.complete(null);
}
@Override
public void onWebSocketError(Throwable cause) {
cause.printStackTrace();
}
public void send(String message) throws Exception {
if (session != null && session.isOpen()) {
// Jetty 12 API: Session.sendText() directly
session.sendText(message, null);
}
}
public String receiveMessage(long timeout, TimeUnit unit) throws InterruptedException {
return messages.poll(timeout, unit);
}
public void close() {
if (session != null) {
session.close();
}
}
public CompletableFuture<Session> getConnectFuture() {
return connectFuture;
}
public CompletableFuture<Void> getCloseFuture() {
return closeFuture;
}
}
private TestWebSocketApp app;
private WebSocketClient wsClient;
private int testPort;
private String baseWsUrl;
@Before
public void setUp() throws Exception {
// Use a random port to avoid conflicts
testPort = 18090 + (int)(Math.random() * 1000);
baseWsUrl = "ws://localhost:" + testPort;
// Start test app
app = new TestWebSocketApp();
ArgsConfig config = new ArgsConfig();
Config.SERVER_PORT.set(config, testPort);
config.load();
app.begin(config);
// Wait for server to start
int attempts = 0;
while(!app.isStarted() && attempts < 20){
Thread.sleep(100);
attempts++;
}
if(!app.isStarted()){
throw new Exception("Server failed to start on port " + testPort);
}
Thread.sleep(200);
// Create WebSocket client
wsClient = new WebSocketClient();
wsClient.start();
}
@After
public void tearDown() throws Exception {
if (wsClient != null) {
try {
wsClient.stop();
} catch (Exception e) {
// Ignore
}
}
if(app != null){
try {
if(app.isStarted()){
app.end();
Thread.sleep(300);
}
} catch (Exception e) {
// Ignore cleanup errors
}
app = null;
}
}
@Test
public void testWebSocketEchoEndpoint() throws Exception {
TestWebSocketClient client = new TestWebSocketClient();
// Connect to echo endpoint
URI uri = new URI(baseWsUrl + "/ws/echo");
wsClient.connect(client, uri);
// Wait for connection
Session session = client.getConnectFuture().get(5, TimeUnit.SECONDS);
assertNotNull("Connection should be established", session);
assertTrue("Session should be open", session.isOpen());
// Send a message
client.send("Hello WebSocket");
// Receive echo response
String response = client.receiveMessage(5, TimeUnit.SECONDS);
assertNotNull("Should receive response", response);
assertEquals("Should echo back message", "Echo: Hello WebSocket", response);
// Send another message
client.send("Test 123");
response = client.receiveMessage(5, TimeUnit.SECONDS);
assertEquals("Should echo second message", "Echo: Test 123", response);
// Close connection
client.close();
client.getCloseFuture().get(5, TimeUnit.SECONDS);
}
@Test
public void testWebSocketCounterEndpoint() throws Exception {
TestWebSocketClient client = new TestWebSocketClient();
// Connect to counter endpoint
URI uri = new URI(baseWsUrl + "/ws/counter");
wsClient.connect(client, uri);
// Wait for connection
Session session = client.getConnectFuture().get(5, TimeUnit.SECONDS);
assertNotNull("Connection should be established", session);
// Send multiple messages
client.send("msg1");
String response1 = client.receiveMessage(5, TimeUnit.SECONDS);
assertTrue("First response should contain counter", response1.contains("Message #"));
client.send("msg2");
String response2 = client.receiveMessage(5, TimeUnit.SECONDS);
assertTrue("Second response should contain counter", response2.contains("Message #"));
// Counter should have incremented
assertNotEquals("Responses should be different", response1, response2);
// Close connection
client.close();
client.getCloseFuture().get(5, TimeUnit.SECONDS);
}
@Test
public void testWebSocketSessionEndpoint() throws Exception {
TestWebSocketClient client = new TestWebSocketClient();
// Connect to session endpoint
URI uri = new URI(baseWsUrl + "/ws/session");
wsClient.connect(client, uri);
// Wait for connection
Session session = client.getConnectFuture().get(5, TimeUnit.SECONDS);
assertNotNull("Connection should be established", session);
// Send a message to trigger the response
client.send("ping");
// Should receive message with session ID
String response = client.receiveMessage(5, TimeUnit.SECONDS);
assertNotNull("Should receive connection message", response);
assertTrue("Message should contain 'Connected'", response.startsWith("Connected:"));
// Close connection
client.close();
client.getCloseFuture().get(5, TimeUnit.SECONDS);
}
@Test
public void testMultipleWebSocketClients() throws Exception {
TestWebSocketClient client1 = new TestWebSocketClient();
TestWebSocketClient client2 = new TestWebSocketClient();
// Connect both clients to echo endpoint
URI uri = new URI(baseWsUrl + "/ws/echo");
wsClient.connect(client1, uri);
wsClient.connect(client2, uri);
// Wait for connections
Session session1 = client1.getConnectFuture().get(5, TimeUnit.SECONDS);
Session session2 = client2.getConnectFuture().get(5, TimeUnit.SECONDS);
assertNotNull("Client 1 should connect", session1);
assertNotNull("Client 2 should connect", session2);
assertTrue("Client 1 session should be open", session1.isOpen());
assertTrue("Client 2 session should be open", session2.isOpen());
// Send messages from both clients
client1.send("From Client 1");
client2.send("From Client 2");
// Receive responses
String response1 = client1.receiveMessage(5, TimeUnit.SECONDS);
String response2 = client2.receiveMessage(5, TimeUnit.SECONDS);
assertEquals("Client 1 should receive its echo", "Echo: From Client 1", response1);
assertEquals("Client 2 should receive its echo", "Echo: From Client 2", response2);
// Close connections
client1.close();
client2.close();
client1.getCloseFuture().get(5, TimeUnit.SECONDS);
client2.getCloseFuture().get(5, TimeUnit.SECONDS);
}
@Test
public void testHttpStillWorksWithWebSocket() throws Exception {
// Verify HTTP endpoints still work when WebSocket is enabled
java.net.URL url = new java.net.URL("http://localhost:" + testPort + "/test");
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int responseCode = conn.getResponseCode();
assertEquals("HTTP endpoint should work", 200, responseCode);
java.io.BufferedReader in = new java.io.BufferedReader(
new java.io.InputStreamReader(conn.getInputStream()));
String response = in.readLine();
in.close();
assertEquals("HTTP response should be correct", "HTTP works", response);
}
}