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
+10 -1
View File
@@ -81,7 +81,16 @@ public class Entity extends Hdr{
try {
String sf_name=field.getName();
Field slot=(Field) field.get(cls);
slot.setId(sf_name);
// Only set ID if not already set (allows explicit database column name mapping)
// Use Field's name (database column name) if available, otherwise use Java field name
if(slot.getId()==null || slot.getId().isEmpty()){
String dbName=slot.getName(); // This is the name passed to Field constructor (e.g., "created_on")
if(dbName!=null && !dbName.isEmpty()){
slot.setId(dbName);
}else{
slot.setId(sf_name); // Fallback to Java field name
}
}
slot.setPosition(position0+slots.size());
slots.add(slot);
//System.out.println(sf_name+":"+slot+" atpos:"+slot.getPosition());
+19 -8
View File
@@ -115,7 +115,9 @@ public final class SQL implements Appendable{
String alias=getAlias(e);
//System.out.println("It:"+index+":/"+f+"/"+e+"/"+alias);
append(index==0?" ":",");
append(alias).append(".").id(f.getName());
// Use getId() if set (database column name), otherwise use getName()
String colName=(f.getId()!=null && !f.getId().isEmpty())?f.getId():f.getName();
append(alias).append(".").id(colName);
}
from();
String eAlias=getAlias(ent);
@@ -127,9 +129,12 @@ public final class SQL implements Appendable{
on();
Field bPk=b.getPk();
Field ePk=ent.getPk();
append(eAlias).append(".").id(ePk.getName());
// Use getId() if set (database column name), otherwise use getName()
String ePkName=(ePk.getId()!=null && !ePk.getId().isEmpty())?ePk.getId():ePk.getName();
String bPkName=(bPk.getId()!=null && !bPk.getId().isEmpty())?bPk.getId():bPk.getName();
append(eAlias).append(".").id(ePkName);
append("=");
append(bAlias).append(".").id(bPk.getName());
append(bAlias).append(".").id(bPkName);
}
return this;
}
@@ -154,7 +159,9 @@ public final class SQL implements Appendable{
if(filter.isLeaf()){
Check.Op op=filter.getCode();
Field field=filter.getField();
String fname=wrap(filter.getField().getName());
// Use getId() if set (database column name), otherwise use getName()
String fieldName=(field.getId()!=null && !field.getId().isEmpty())?field.getId():field.getName();
String fname=wrap(fieldName);
String opname=op.toString();
String arg="?";
Object val=filter.getValue();
@@ -236,14 +243,16 @@ public final class SQL implements Appendable{
String delim="";
Field pk=entity.getPk();
if(!entity.isOwned(pk)){
append(delim).id(pk.getName());
String pkName=(pk.getId()!=null && !pk.getId().isEmpty())?pk.getId():pk.getName();
append(delim).id(pkName);
ext.append(delim).append("?");
delim=",";
}
for(int index=0;index<supplied.size();index++){
Field f=supplied.get(index);
if(index>0) delim=",";
append(delim).id(f.getName());
String fName=(f.getId()!=null && !f.getId().isEmpty())?f.getId():f.getName();
append(delim).id(fName);
ext.append(delim).append("?");
}
append(") VALUES (").append(ext).append(")");
@@ -256,11 +265,13 @@ public final class SQL implements Appendable{
Field f=supplied.get(index);
String delim=index==0?"":",";
append(delim);
id(f.getName()).append("=?");
String fName=(f.getId()!=null && !f.getId().isEmpty())?f.getId():f.getName();
id(fName).append("=?");
}
where();
Field pk=entity.getPk();
id(pk.getName()).append("=?");
String pkName=(pk.getId()!=null && !pk.getId().isEmpty())?pk.getId():pk.getName();
id(pkName).append("=?");
return this;
}
public final SQL delete(Entity entity){
+9 -77
View File
@@ -11,11 +11,10 @@ package com.reliancy.jabba;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import com.reliancy.dbo.Terminal;
import com.reliancy.jabba.sec.SecurityPolicy;
import com.reliancy.jabba.ui.Rendering;
import com.reliancy.jabba.ui.Template;
import com.reliancy.util.CodeException;
import com.reliancy.util.ResultCode;
@@ -50,12 +49,6 @@ public abstract class App extends Processor{
public App(String id) {
super(id);
}
/** does nothing. */
public void before(Request request,Response response) throws IOException{
}
/** does nothing. */
public void after(Request request,Response response) throws IOException{
}
/** app serves by processing first-last chain then router.
* always conditional on status being null otherwise it skips.
*/
@@ -63,70 +56,6 @@ public abstract class App extends Processor{
if(first!=null && resp.getStatus()==null) first.process(req, resp);
if(router!=null && resp.getStatus()==null) router.process(req,resp);
}
/** When an error occurs we need properly render exception.
* if html is accepted we try to render a valid response with n error within a template so it fits with the app.
* for all others we set error status code.
* for json,xml and plain we render into a message template for the rest we do nothing.
* this method returns true if a response was generated. in overloaded methods
* if false is returned we can generate response the status is set to 500 already.
* @param req incoming request
* @param ex exception state
* @param resp response to generate
* @return true if handled else it signifies we should do somthing in overloads.
* @throws IOException
*/
public boolean processError(com.reliancy.jabba.Request req,Throwable ex,com.reliancy.jabba.Response resp) throws IOException{
log().error("error:",ex);
String accepted_format=req.getHeader("Accept");
boolean present=accepted_format!=null;
if(present && (
accepted_format.contains("/html")
|| accepted_format.contains("/xhtml")
)){
// we have html request
resp.setContentType(HTTP.MIME_HTML);
Template t=Template.find("/templates/error.hbs");
if(t==null){ // no template found
resp.setStatus(Response.HTTP_INTERNAL_ERROR);
if(ex instanceof IOException) throw ((IOException)ex);
else throw new RuntimeException(ex);
}
Rendering.begin(t).with(ex).end(resp);
return true;
}else{
// for all other cases we first flag it as error
resp.setStatus(Response.HTTP_INTERNAL_ERROR);
}
// next we format a few common and supported messages
if(present && accepted_format.contains("/json")){
ResponseEncoder enc=resp.getEncoder();
if(enc.getErrorFormat()==null){
String template="'{'\n\t\"status\":\"error\",\n\t\"title\":\"{0}\",\n\t\"message\":\"{1}\"\n'}'\n";
enc.setErrorFormat(template);
}
enc.writeError(ex);
return true;
}
if(present && accepted_format.contains("/xml")){
ResponseEncoder enc=resp.getEncoder();
if(enc.getErrorFormat()==null){
String template="<response>\n\t<status>error</status>\n\t<title>{0}</title>\n\t<message>{1}</message>\n</response>\n";
enc.setErrorFormat(template);
}
enc.writeError(ex);
return true;
}
if(present && accepted_format.contains("text/plain")){
ResponseEncoder enc=resp.getEncoder();
if(enc.getErrorFormat()==null){
String template="status=error\n\ntitle={0}\n\nmessage={1}\n\n";
enc.setErrorFormat(template);
}
enc.writeError(ex);
return true;
}
return false;
}
/** add one or a chain of processors. */
public <T extends Processor> T addMiddleWare(T m){
@@ -218,12 +147,15 @@ public abstract class App extends Processor{
for(Processor p=first;p!=null;p=p.getNext()){
p.end();
}
log().info("stopped:"+getId());
super.end(); // detaches from config
}finally{
// we notify all of end (especially cleaner thread)
synchronized(this){
this.notifyAll();
try{
// detaches from config
super.end();
}finally{
// we notify all of end (especially cleaner thread)
synchronized(this){
this.notifyAll();
}
}
}
}
@@ -9,6 +9,7 @@ package com.reliancy.jabba;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/** AppSession middleware will inject an appsession object into callsession.
* During each request,response we will if not alrady present extract a cookie or param
@@ -29,7 +30,7 @@ public class AppSessionFilter extends Processor{
factory=f;
}
@Override
public void before(Request request, Response response) throws IOException {
public void beforeServe(Request request, Response response) throws IOException {
String ssid=(String)request.getParam(KEY_NAME,null);
if(ssid==null){
UUID uuid = UUID.randomUUID();
@@ -54,13 +55,13 @@ public class AppSessionFilter extends Processor{
css.setAppSession(ss);
}
@Override
public void after(Request request, Response response) throws IOException {
public void afterServe(Request request, Response response) throws IOException {
CallSession css=CallSession.getInstance();
AppSession ss=(AppSession) css.getAppSession();
response.setCookie(KEY_NAME,ss.id,15*60,false);
}
@Override
public void serve(Request request, Response response) throws IOException{
// Determine if request is HTTPS
boolean isSecure="https".equalsIgnoreCase(request.getProtocol()) ||
"https".equalsIgnoreCase(request.getScheme());
// Set secure=true for HTTPS, HttpOnly=true always for security
response.setCookie(KEY_NAME,ss.id,15*60,isSecure,true);
}
}
@@ -130,9 +130,10 @@ public class ArgsConfig extends Config.Base{
APP_SETTINGS.set(this, cwd);
}
// also logging level and format
// System.out.println("LogLog:"+LOG_LEVEL.get(this));
// System.out.println("ENV:"+System.getenv("LOG_LEVEL"));
// LOG_LEVEL.set(this,"INFO");
// Set default log level to INFO if not specified
//if(LOG_LEVEL.get(this) == null) {
// LOG_LEVEL.set(this,"INFO");
//}
Logger root=Log.setup();
Log.setLevel(root,LOG_LEVEL.get(this));
return this;
@@ -8,31 +8,118 @@ You may not use this file except in compliance with the License.
package com.reliancy.jabba;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.Executor;
/**
* Thread local object that lets us access some variables in specialized handler methods.
* For example request and response objects are accessible.
* The session is updated at process phase of each processor.
* The session is updated at process phase of each processor.
*
* <h3>Instance Counting and Multi-Threading</h3>
* CallSession tracks how many threads are currently using it via an atomic counter.
* This enables safe async processing where a single session is shared across multiple threads:
*
* <ul>
* <li><b>beginFresh()</b> - Initialize session at the top of request processing (main thread)</li>
* <li><b>beginAgain()</b> - Reattach session when switching threads (async workers)</li>
* <li><b>end()</b> - Detach from current thread, decrement counter. Only cleans up when count reaches zero.</li>
* </ul>
*
* <p><b>Async Flow Example:</b></p>
* <pre>
* // Main thread:
* session.beginFresh(appSession, request, response); // count = 1
* // ... processing ...
*
* // Fork to async thread:
* CompletableFuture.supplyAsync(() -> {
* session.beginAgain(); // count = 2
* // ... async work ...
* session.end(); // count = 1, session still alive
* });
*
* // Main thread completes:
* session.end(); // count = 0, session cleanup happens
* </pre>
*
* <p>This ensures the session and its resources remain valid until ALL threads complete.</p>
*/
public class CallSession implements Session{
ArrayList<Processor> callers=new ArrayList<>();
Session appSession;
Request request;
Response response;
Executor executor;
/** Atomic counter tracking how many threads are currently using this session */
transient AtomicInteger instanceCount=new AtomicInteger(0);
public CallSession(){
}
protected void end(){
appSession=null;
request=null;
response=null;
callers.clear();
/** End the current session.
* If the session is not the current one, do nothing.
* If the session is the current one, remove it from the thread local.
* If the session is the current one and there are no more instances, clear the session.
* If the session is the current one and there are more instances, decrement the instance count.
*/
public synchronized boolean end(){
CallSession current=instance.get();
if(current!=this) return false; // not the current session
instance.remove(); // remove from this thread
int count=instanceCount.updateAndGet(i -> i>0 ? i-1 : 0);
if(count==0){
// if no more instances, clear the session
try{
while(callers.size()>0){
Processor last=callers.remove(callers.size()-1);
if(last!=null && last.isActive()){
try{
last.afterServe(request, response); // call after to ensure proper cleanup
}catch(Exception e){
// Log but don't throw - we're in cleanup
last.log().error("Error calling after() on processor " + last.getId() + ": " + e.getMessage());
}
}
};
}finally{
appSession=null;
request=null;
response=null;
executor=null;
callers.clear();
}
return true;
}
return false;
}
protected void begin(Session ss,Request req,Response resp){
/** Begins session at the top of the call stack.
* If the session is already in use, throw an exception.
* If the session is not in use, set the session to the new one.
* @param ss
* @param req
* @param resp
* @return true if the session was successfully begun, false otherwise
*/
public synchronized boolean beginFresh(Session ss,Request req,Response resp){
appSession=ss;
request=req;
response=resp;
executor=null;
callers.clear();
return beginAgain();
}
/** Begins session again in a different thread..
* @return true if the session was successfully begun, false otherwise
*/
public synchronized boolean beginAgain(){
CallSession current=instance.get();
if(this==current) return true;
if(current!=null) current.end(); // end previous one if any
instance.set(this); // add to this thread
instanceCount.incrementAndGet(); // increment count
return true;
}
protected void enter(Processor c){callers.add(c);}
protected void leave(Processor c){
@@ -45,6 +132,14 @@ public class CallSession implements Session{
// bad last is not same c, some processors have not left properly
do{
last=callers.remove(callers.size()-1);
if(last!=null && last.isActive()){
try{
last.afterServe(request, response); // call after to ensure proper cleanup
}catch(Exception e){
// Log but don't throw - we're in cleanup
last.log().error("Error calling after() on processor " + last.getId() + ": " + e.getMessage());
}
}
}while(last!=c);
}
}
@@ -74,6 +169,12 @@ public class CallSession implements Session{
public void setResponse(Response response) {
this.response = response;
}
public Executor getExecutor() {
return executor;
}
public void setExecutor(Executor executor) {
this.executor = executor;
}
public Processor getCaller() {
int len=callers.size();
return len>0?callers.get(len-1):null;
@@ -88,4 +189,18 @@ public class CallSession implements Session{
if(ret==null) instance.set(ret=new CallSession());
return ret;
}
/** Set the current call session.
* If the session is the same as the current one, do nothing.
* If the session is null, end the current one if any.
* If the session is new, end the current one if any and set the new one.
*/
// public static void setInstance(CallSession ss){
// CallSession current=instance.get();
// if(ss==current) return;
// if(current!=null) current.end(); // end previous one if any
// if(ss!=null){
// instance.set(ss); // add to this thread
// ss.instanceCount.incrementAndGet(); // increment count
// }
// }
}
@@ -150,6 +150,8 @@ public interface Config extends Iterable<Config.Property<?>>{
public static final Property<String> APP_SETTINGS=new Property<>("APP_SETTINGS",String.class);
public static final Property<String> APP_CLASS=new Property<>("APP_CLASS",String.class);
public static final Property<List> APP_ARGS=new Property<>("APP_ARGS",List.class);
public static final Property<String> SECRET_KEY=new Property<>("SECRET_KEY",String.class);
public static final Property<Integer> SERVER_PORT=new Property<>("SERVER_PORT",Integer.class).setInitial(8090);
public default Config getParent(){return null;};
public Config clear();
@@ -8,6 +8,7 @@ You may not use this file except in compliance with the License.
package com.reliancy.jabba;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
/** EndPoint is a special processor usually the last in chain.
*
@@ -17,12 +18,5 @@ public abstract class EndPoint extends Processor{
public EndPoint(String id) {
super(id);
}
@Override
public void before(Request request, Response response) throws IOException {
}
@Override
public void after(Request request, Response response) throws IOException {
}
public abstract void serve(Request request, Response response) throws IOException;
public abstract void serve(Request request, Response response) throws IOException;
}
@@ -90,8 +90,45 @@ public class FileConfig extends Config.Base{
if(changing) p.setString(this,sval);
}
}while(changing && iterations<7);
// Validate configuration
validate();
return this;
}
/**
* Validates configuration values after loading.
* @throws IllegalArgumentException if validation fails
*/
protected void validate() throws IllegalArgumentException{
// Validate SERVER_PORT if present
if(hasProperty(Config.SERVER_PORT)){
Integer port=getProperty(Config.SERVER_PORT,null);
if(port!=null && (port<1 || port>65535)){
throw new IllegalArgumentException("SERVER_PORT must be between 1 and 65535, got: "+port);
}
}
// Validate LOG_LEVEL if present
if(hasProperty(Config.LOG_LEVEL)){
String level=getProperty(Config.LOG_LEVEL,"");
if(!level.isEmpty() && !isValidLogLevel(level)){
throw new IllegalArgumentException("Invalid LOG_LEVEL: "+level+". Must be one of: TRACE, DEBUG, INFO, WARN, ERROR");
}
}
// Validate required properties from schema
for(Property<?> p:getSchema()){
if(p.isRequired() && !hasProperty(p)){
throw new IllegalArgumentException("Required property '"+p.getName()+"' is missing");
}
}
}
/**
* Checks if log level is valid.
*/
protected boolean isValidLogLevel(String level){
if(level==null) return false;
String upper=level.toUpperCase();
return "TRACE".equals(upper) || "DEBUG".equals(upper) ||
"INFO".equals(upper) || "WARN".equals(upper) || "ERROR".equals(upper);
}
@Override
public Config save() throws IOException{
return this;
@@ -17,6 +17,7 @@ import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
/** FileServer is an module and endpoint that exposes multiple URLs thru which files are served.
+6 -1
View File
@@ -25,6 +25,7 @@ public final class HTTP {
public static String MIME_JSON="application/json";
public static String MIME_BYTES="application/octet-stream";
public static String MIME_HTML="text/html";
public static String MIME_XML="application/xml";
public static HashMap<String,String> MIME_MAP=new HashMap<>();
public static class Header{
@@ -39,8 +40,12 @@ public final class HTTP {
public String value;
public int maxAge;
public boolean secure;
public boolean httpOnly;
public Cookie(String k,String v, int maxAge, boolean sec, boolean httpOnly){
key=k;value=v;this.maxAge=maxAge;secure=sec;this.httpOnly=httpOnly;
}
public Cookie(String k,String v, int maxAge, boolean sec){
key=k;value=v;this.maxAge=maxAge;secure=sec;
this(k,v,maxAge,sec,true);
}
}
/** maps extension to mime type.
@@ -11,24 +11,93 @@ import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
import com.reliancy.jabba.decor.Async;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.decor.WebSocket;
import com.reliancy.util.Handy;
public class MethodEndPoint extends EndPoint{
enum InvokeProfile{
PLAIN, // no return, request, response as argument
NOARG, // no arguments, possible return
FULL, // one or more arguments need to do casting
// Inner Servant classes for each invoke type
private final Servant INVOKE_PLAIN = new Servant() {
@Override
public void serve(Request request, Response response) throws IOException {
try {
method.invoke(target, request, response);
} catch (Exception ex) {
if(ex instanceof IOException) throw ((IOException)ex);
else throw new IOException(ex);
}
}
};
private final Servant INVOKE_NOARG = new Servant() {
@Override
public void serve(Request request, Response response) throws IOException {
try {
Object ret = method.invoke(target);
encodeResponse(ret, response);
} catch (Exception ex) {
if(ex instanceof IOException) throw ((IOException)ex);
else throw new IOException(ex);
}
}
};
private final Servant INVOKE_FULL = new Servant() {
@Override
public void serve(Request request, Response response) throws IOException {
try {
Object[] argVals = decodeRequest(request);
Object ret = method.invoke(target, argVals);
encodeResponse(ret, response);
} catch (Exception ex) {
if(ex instanceof IOException) throw ((IOException)ex);
else throw new IOException(ex);
}
}
};
private final Servant INVOKE_WEBSOCKET = new Servant() {
@Override
public void serve(Request request, Response response) throws IOException {
try {
// 1. Get AppSession from CallSession (set by middleware during upgrade request)
CallSession cs = CallSession.getInstance();
Session appSession = cs != null ? cs.getAppSession() : null;
// 2. Get route path for this WebSocket endpoint
String routePath = route != null ? route.path() : request.getPath();
// 3. Upgrade HTTP response to WebSocket
// TODO: ServletResponse.upgradeToWebSocket() needs implementation
WebSocketSession wsSession = response.upgradeToWebSocket(routePath, appSession);
// 4. Invoke user method to setup callbacks
// User method signature: void methodName(WebSocketSession session)
method.invoke(target, wsSession);
// 5. Don't complete response - WebSocket connection stays open
// CallSession.end() will happen in finally block but WebSocketSession lives on
// TODO: Verify response handling - should we mark as async or handled differently?
} catch (Exception ex) {
if(ex instanceof IOException) throw ((IOException)ex);
else throw new IOException(ex);
}
}
};
}
Routed route;
Object target;
Method method;
Parameter[] params;
Class<?> retType;
InvokeProfile invokeType;
Servant invokeType;
ArrayList<MethodDecorator> decorators=new ArrayList<>();
public MethodEndPoint(Object target,Method m) {
super(target.getClass().getSimpleName()+"."+m.getName());
this.route=m.getAnnotation(Routed.class);
@@ -36,13 +105,28 @@ public class MethodEndPoint extends EndPoint{
this.method=m;
this.params=m.getParameters();
this.retType=m.getReturnType();
this.invokeType=InvokeProfile.FULL;
this.invokeType=INVOKE_FULL;
if(params.length==2 && params[0].getType()==Request.class && params[1].getType()==Response.class){
invokeType=InvokeProfile.PLAIN;
invokeType=INVOKE_PLAIN;
}
if(params.length==0){
invokeType=InvokeProfile.NOARG;
invokeType=INVOKE_NOARG;
}
// Check for WebSocket endpoint
if(m.isAnnotationPresent(WebSocket.class)) {
// WebSocket methods must have exactly one parameter of type WebSocketSession
if(params.length != 1 || params[0].getType() != WebSocketSession.class) {
throw new RuntimeException(
"@WebSocket method must have exactly one WebSocketSession parameter: " +
m.getName()
);
}
invokeType = INVOKE_WEBSOCKET;
// TODO: WebSocket endpoints should probably always be async?
// For now, let user control with @Async if needed
}
// Auto-detect async from @Async annotation OR CompletableFuture return type
setAsync(m.getAnnotation(Async.class) != null || CompletableFuture.class.isAssignableFrom(retType));
bindDecorators();
}
public String getVerb(){
@@ -64,30 +148,29 @@ public class MethodEndPoint extends EndPoint{
if(d!=null) decorators.add(d);
}
}
/** serves the request by invoking invokeType.serve(request, response).
* this method will lift execution of invoketype into async task if desired and possible.
* Methods returning CompletableFuture are handled by encodeResponse() to avoid double wrapping.
*/
@Override
public void serve(Request request, Response response) throws IOException{
log().debug("Serving method....{}",invokeType);
try{
Object ret=null;
switch(invokeType){
case PLAIN:{ // plain profile just passes req,resp
method.invoke(target,request,response);
break;
// Only lift to async if @Async annotation but NOT returning CompletableFuture
// (CompletableFuture returns are handled by encodeResponse to avoid double wrapping)
boolean needsAsyncWrapper = isAsync() && !CompletableFuture.class.isAssignableFrom(retType);
if(needsAsyncWrapper && request.goAsync()) {
// Start async promise chain for @Async annotated methods
response.promiseFirst(v -> {
try {
invokeType.serve(request, response);
} catch (IOException e) {
throw new RuntimeException(e);
}
case NOARG:{ // no args will not pass any arguments, will deal with return (marshalling)
ret=method.invoke(target);
encodeResponse(ret,response);
break;
}
default:{ // here we do full unmarshalling, marshalling
Object[] argVals=decodeRequest(request);
ret=method.invoke(target,argVals);
encodeResponse(ret,response);
}
}
}catch(Exception ex2){
if(ex2 instanceof IOException) throw ((IOException)ex2);
else throw new IOException(ex2);
return null;
});
}else{
// Sync execution or CompletableFuture return (async handled in encodeResponse)
invokeType.serve(request, response);
}
}
protected Object[] decodeRequest(Request request){
@@ -98,11 +181,92 @@ public class MethodEndPoint extends EndPoint{
String byName=p.getName();
String byPos="_arg"+i;
Object val=request.getParam(byName,request.getParam(byPos,null)); // get by name or pos
// Validate input before normalization
val=validateInput(val,cls,byName);
argVals[i]=Handy.normalize(cls,val);
}
return argVals;
}
/**
* Validates input before processing to prevent injection attacks and malformed data.
* @param val raw input value
* @param expectedType expected type
* @param paramName parameter name for error messages
* @return validated value (may be modified or rejected)
* @throws IllegalArgumentException if validation fails
*/
protected Object validateInput(Object val, Class<?> expectedType, String paramName){
if(val==null) return null;
// String validation
if(val instanceof String){
String str=(String)val;
// Limit string length to prevent DoS
if(str.length()>100000){
log().warn("Input parameter '{}' exceeds maximum length, truncated",paramName);
str=str.substring(0,100000);
}
// For string types, return as-is (normalization will handle conversion)
if(expectedType==String.class || expectedType==CharSequence.class){
return str;
}
}
// Array validation
if(val instanceof String[]){
String[] arr=(String[])val;
if(arr.length>1000){
log().warn("Input parameter '{}' array exceeds maximum size",paramName);
throw new IllegalArgumentException("Array parameter '"+paramName+"' exceeds maximum size");
}
for(String s:arr){
if(s!=null && s.length()>100000){
log().warn("Input parameter '{}' array element exceeds maximum length",paramName);
throw new IllegalArgumentException("Array element in '"+paramName+"' exceeds maximum length");
}
}
}
// Type validation - ensure value can be converted to expected type
if(expectedType.isPrimitive() || Number.class.isAssignableFrom(expectedType) ||
Boolean.class.isAssignableFrom(expectedType) || expectedType==Boolean.class){
// These will be validated during normalization
return val;
}
return val;
}
/** Encodes the response to the response encoder.
* We handle here future value as well by chaining.
*/
protected void encodeResponse(Object ret, Response response) throws IOException{
final Request request=response.getRequest();
if(ret instanceof CompletableFuture){
// Method returns a future - we turn async
@SuppressWarnings("unchecked")
CompletableFuture<Object> future = (CompletableFuture<Object>)ret;
// Check if we can go async - we are not using isAsync here
if(request.goAsync()) {
// we can go async
// Chain the future directly - NO BLOCKING!
response.promiseFirst(v -> future) // Returns the future, Response will flatten it
.promiseNext(result -> {
// Encode the result (recursive call, but result is not a future)
try {
encodeResponse(result, response);
} catch (IOException e) {
throw new RuntimeException(e);
}
return null;
});
} else {
// Blocking fallback - wait for future synchronously
Object result = future.join();
encodeResponse(result, response);
}
return; // Important: exit after setting up async chain
}
if(ret instanceof Response){
// we have a response return - take its status and content type
Response resp=(Response)ret;
@@ -116,6 +280,10 @@ public class MethodEndPoint extends EndPoint{
String ctype=route.return_mime();
if(Handy.isBlank(ctype)) ctype=HTTP.guess_mime(ret);
response.setContentType(ctype);
// Set status to OK if not already set
if(response.getStatus()==null){
response.setStatus(Response.HTTP_OK);
}
if(ret!=null){
response.getEncoder().writeObject(ret);
}
+74 -25
View File
@@ -7,6 +7,7 @@ You may not use this file except in compliance with the License.
*/
package com.reliancy.jabba;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -14,18 +15,20 @@ import org.slf4j.LoggerFactory;
* App is a processor and under it a router and a chain of filters are also processors.
* Also endpoints are processors too.
*/
public abstract class Processor {
public abstract class Processor implements Servant {
protected Processor parent;
protected Processor next;
protected String id;
protected boolean active;
protected transient Config config;
protected Logger logger;
protected boolean isAsync;
public Processor(String id){
next=null;
this.id=id!=null?id:this.getClass().getSimpleName();
active=true;
isAsync=false;
}
public String getId(){
return id;
@@ -37,6 +40,20 @@ public abstract class Processor {
public void setNext(Processor next) {
this.next = next;
}
/**
* Find a processor of the given class type in the parent chain.
* @param cls the class type to search for
* @return the processor if found, null otherwise
*/
@SuppressWarnings("unchecked")
public <T extends Processor> T getParent(Class<T> cls) {
Processor p=parent;
while(p!=null){
if(cls.isAssignableFrom(p.getClass())) return (T) p;
p=p.getParent();
}
return null;
}
public Processor getParent() {
return parent;
}
@@ -57,33 +74,44 @@ public abstract class Processor {
if(parent!=null) return parent.getConfig();
return null;
}
// using config as a marker of a run so set during begin
// public void setConfig(Config config) {
// this.config = config;
// }
/**
* Main event processing chain.
* Will go down the chain until result code is set.
/** Internal processing method that can handle async and non-async use cases.
* Process the request and response, handling async if needed.
* @param request
* @param response
* @param isAsync
* @throws IOException
*/
public void process(Request request,Response response) throws IOException {
CallSession ss=CallSession.getInstance();
try{
ss.enter(this);
if(!active){
if(next!=null) next.process(request, response);
}else{
before(request, response);
if(response.getStatus()==null) serve(request, response);
if(next!=null && response.getStatus()==null) next.process(request, response);
after(request, response);
protected void process(Request request,Response response) throws IOException {
final CallSession ss=CallSession.getInstance();
// now we must account for async downstream
final Processor thisProcessor=this;
ss.enter(thisProcessor);
if(!active){
if(next!=null){
next.process(request, response);
return;
}
}else{
beforeServe(request, response);
serve(request, response);
if(response.isPromised()==false){
afterServe(request, response);
ss.leave(thisProcessor);
}else{
response.promiseNext((value) -> {
try {
afterServe(request, response);
return value;
} catch (IOException e) {
throw new RuntimeException(e);
}finally{
ss.leave(thisProcessor);
}
});
}
}finally{
ss.leave(this);
}
}
/** Place to prepare for a run. */
public void begin(Config conf) throws Exception{
this.config=conf;
@@ -109,10 +137,31 @@ public abstract class Processor {
if(ret==null) ret=logger=LoggerFactory.getLogger(this.getId());
return ret;
}
/**
* Check if this endpoint handles async requests.
* @return true if method returns CompletableFuture
*/
public boolean isAsync() {
return isAsync;
}
public void setAsync(boolean isAsync) {
this.isAsync = isAsync;
}
/** called before serve. */
public abstract void before(Request request,Response response) throws IOException;
public void beforeServe(Request request,Response response) throws IOException{
}
/** called after serve. */
public abstract void after(Request request,Response response) throws IOException;
/** main processing and subprocessing happens here. */
public abstract void serve(Request request,Response response) throws IOException;
public void afterServe(Request request,Response response) throws IOException{
}
/** default implementation of work.
* if next processor is not null and response status is null, it will process the next processor.
* otherwise it will return null if sync, or a completed future if async.
*/
public void serve(Request request,Response response) throws IOException{
if(next==null || response.getStatus()!=null) return;
next.process(request, response);
}
}
+54 -100
View File
@@ -7,116 +7,70 @@ You may not use this file except in compliance with the License.
*/
package com.reliancy.jabba;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import com.reliancy.util.Handy;
/**
* Abstract representation of an HTTP request.
* Provides container-agnostic access to request properties.
*/
public abstract class Request {
protected final HashMap<String,String> pathParams=new HashMap<>();
protected String pathOverride;
protected Runnable finisher;
protected CallSession session;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
public class Request {
final HttpServletRequest http_request;
final HashMap<String,String> pathParams=new HashMap<>();
String pathOverride;
public Request(HttpServletRequest http_request) {
this.http_request = http_request;
public Request() {
finisher = () -> {};
}
public CallSession getSession() {
return session;
}
public void setSession(CallSession session) {
this.session = session;
}
public void setFinisher(Runnable finisher) {
this.finisher = finisher;
}
public boolean isFinished() {
return finisher == null;
}
public abstract void finish();
public abstract boolean isAsync();
public abstract boolean goAsync();
public Map<String,String> getPathParams(){
return pathParams;
}
public String getPath() {
if(pathOverride!=null){
return pathOverride;
}else{
return http_request.getPathInfo();
}
}
public Request setPath(String path){
pathOverride=path;
return this;
}
public String getVerb() {
return http_request.getMethod();
}
/**
* Look for this parameter in pathParam, queryParams and forms.
* @param pname
* @return
*/
public Object getParam(String pname,Object def){
if(pathParams.containsKey(pname)) return pathParams.get(pname);
String[] vals=http_request.getParameterValues(pname);
if(vals!=null) return vals.length==1?vals[0]:vals;
String hdr=getHeader(pname);
if(hdr!=null) return hdr;
String cook=getCookie(pname,null);
if(cook!=null) return cook;
return def;
}
public Request setParam(String pname,Object val){
if(pathParams.containsKey(pname)){
pathParams.put(pname,String.valueOf(Handy.nz(val,"")));
}else{
throw new IllegalArgumentException("invalid param name:"+pname);
}
return this;
}
public String getHeader(String key){
return http_request.getHeader(key);
}
public String getCookie(String name,String def){
Cookie[] all=http_request.getCookies();
if(all!=null) for(Cookie c:all){
if(name.equalsIgnoreCase(c.getName())) return c.getValue();
}
return def;
}
private static final String[] HEADERS4IP = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR" };
/**
* This method will consult several headers to obain ip address.
* @return best guess for remote address.
*/
public String getRemoteAddress() {
for (String header : HEADERS4IP) {
String ip = getHeader(header);
if(ip==null || ip.length()==0 || "unknown".equalsIgnoreCase(ip)) continue;
return ip.contains(",")?ip.split(",",2)[0]:ip;
}
return http_request.getRemoteAddr();
}
/**
* will return shema://host:port/context
* @return everything preceeding the path.
*/
public String getMount(){
String scheme = http_request.getScheme();
String host = http_request.getHeader("Host"); // includes server name and server port
if(host==null || host.trim().isEmpty()){
// try differenty for host
String serverName = http_request.getServerName();
int serverPort = http_request.getServerPort();
host=serverName+":"+serverPort;
}
String resultPath = scheme + "://" + host;
String contextPath = http_request.getContextPath(); // includes leading forward slash
if(contextPath!=null){
resultPath+= contextPath;
}
return resultPath;
}
public String getProtocol(){
return http_request.getProtocol();
}
public abstract String getPath();
public abstract String getVerb();
public abstract Object getParam(String pname, Object def);
public abstract Request setParam(String pname, Object val);
public abstract String getHeader(String key);
public abstract String getCookie(String name, String def);
public abstract String getRemoteAddress();
public abstract String getMount();
public abstract String getProtocol();
public abstract String getScheme();
}
+197 -88
View File
@@ -14,62 +14,79 @@ import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.BiConsumer;
import java.util.function.Function;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
/**
* Our representation of the response.
* We usually wrap servlet response with this object and use in write mode.
* But we can also create it with no servletresponse then it represents delayed response to be
* read out later and written somewhere.
* Abstract representation of an HTTP response.
* Provides container-agnostic response handling with async support.
*/
public class Response {
// status codes
public static final int HTTP_OK=HttpServletResponse.SC_OK;
public static final int HTTP_BAD_REQUEST=HttpServletResponse.SC_BAD_REQUEST;
public static final int HTTP_NOT_FOUND=HttpServletResponse.SC_NOT_FOUND;
public static final int HTTP_UNAUTHORIZED=HttpServletResponse.SC_UNAUTHORIZED;
public static final int HTTP_FORBIDDEN=HttpServletResponse.SC_FORBIDDEN;
public static final int HTTP_TEMPORARY_REDIRECT=HttpServletResponse.SC_TEMPORARY_REDIRECT;
public static final int HTTP_FOUND_REDIRECT=HttpServletResponse.SC_FOUND;
public static final int HTTP_NOT_MODIFIED=HttpServletResponse.SC_NOT_MODIFIED;
public static final int HTTP_INTERNAL_ERROR=HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
public abstract class Response {
// HTTP status codes
public static final int HTTP_OK=200;
public static final int HTTP_BAD_REQUEST=400;
public static final int HTTP_NOT_FOUND=404;
public static final int HTTP_UNAUTHORIZED=401;
public static final int HTTP_FORBIDDEN=403;
public static final int HTTP_TEMPORARY_REDIRECT=307;
public static final int HTTP_FOUND_REDIRECT=302;
public static final int HTTP_NOT_MODIFIED=304;
public static final int HTTP_INTERNAL_ERROR=500;
final protected HttpServletResponse http_response;
final protected Writer char_response;
final protected OutputStream byte_response;
protected final Request request;
protected final Writer char_response;
protected final OutputStream byte_response;
protected ResponseEncoder encoder;
protected String content_type;
protected Integer status;
protected ResponseState state = ResponseState.CREATED;
protected final ArrayList<HTTP.Header> headers=new ArrayList<>();
protected final ArrayList<HTTP.Cookie> cookies=new ArrayList<>();
protected CompletableFuture<Object> promise;
public Response(HttpServletResponse http_response) {
this.http_response = http_response;
this.char_response=null;
this.byte_response=null;
protected Response(Request request) {
this.request = request;
this.char_response = null;
this.byte_response = null;
}
public Response(Writer w) {
this.http_response = null;
protected Response(Writer w) {
this.request = null;
this.char_response=w;
this.byte_response=null;
}
public Response(OutputStream w) {
this.http_response = null;
protected Response(OutputStream w) {
this.request = null;
this.char_response=null;
this.byte_response=w;
}
public Response() {
this.http_response = null;
protected Response() {
this.request = null;
this.char_response=new StringWriter();
this.byte_response=null;
}
public ResponseState getState() {
return state;
}
public void transitionTo(ResponseState newState) {
this.state = this.state.transitionTo(newState);
}
public Request getRequest() {
return request;
}
public ResponseEncoder getEncoder(){
if(encoder==null) encoder=new ResponseEncoder(this);
return encoder;
}
/**returns accumulated string body content if in stringwriter mode or possibly bytearray*/
public Object getContent(){
if(char_response instanceof StringWriter){
return ((StringWriter)char_response).toString();
@@ -77,9 +94,7 @@ public class Response {
return ((ByteArrayOutputStream)byte_response).toByteArray();
}else return null;
}
/** similar to get content only sends own content to external encoder.
* @throws IOException
**/
public void exportContent(ResponseEncoder ext) throws IOException {
if(char_response instanceof StringWriter){
ext.writeString(((StringWriter)char_response).toString());
@@ -88,76 +103,170 @@ public class Response {
ext.writeBytes(buf,0,buf.length);
}
}
public void setContentType(String ctype) {
content_type=ctype;
if(http_response!=null) http_response.setContentType(ctype);
public OutputStream getOutputStream() throws IOException{
return byte_response;
}
public Writer getWriter() throws IOException{
return char_response;
}
public abstract void setContentType(String ctype);
public String getContentType(){
return content_type;
}
public void setStatus(int status) {
this.status=status;
if(http_response!=null) http_response.setStatus(status);
}
public abstract void setStatus(int status);
public Integer getStatus(){
return status;
}
public String getHeader(String key){
for(HTTP.Header hdr:headers){
if(key.equalsIgnoreCase(key)) return hdr.value;
}
if(http_response!=null){
return http_response.getHeader(key);
}else{
return null;
}
}
public Response setHeader(String key,String val){
HTTP.Header sel=null;
for(HTTP.Header hdr:headers){
if(key.equalsIgnoreCase(key)){
sel=hdr;
break;
}
}
if(sel!=null) sel.value=val; else headers.add(new HTTP.Header(key,val));
if(http_response!=null) http_response.setHeader(key,val);
return this;
}
public abstract String getHeader(String key);
public abstract Response setHeader(String key, String val);
public List<HTTP.Header> getHeaders(){
return headers;
}
public String getCookie(String key){
for(HTTP.Cookie c:cookies){
if(key.equalsIgnoreCase(key)) return c.value;
if(key.equalsIgnoreCase(c.key)) return c.value;
}
return null;
}
public Response setCookie(String key,String val,int maxAge,boolean secure){
HTTP.Cookie sel=null;
for(HTTP.Cookie hdr:cookies){
if(key.equalsIgnoreCase(key)){
sel=hdr;
break;
}
}
if(sel!=null){
sel.value=val;
sel.maxAge=maxAge;
sel.secure=secure;
} else{
cookies.add(new HTTP.Cookie(key,val,maxAge,secure));
}
if(http_response!=null){
Cookie c=new Cookie(key,val);
c.setMaxAge(maxAge);
c.setSecure(secure);
http_response.addCookie(c);
}
return this;
public abstract Response setCookie(String key, String val, int maxAge, boolean secure);
public Response setCookie(String key, String val, int maxAge, boolean secure, boolean httpOnly){
return setCookie(key, val, maxAge, secure);
}
public List<HTTP.Cookie> getCookies(){
return cookies;
}
public abstract boolean isCommitted();
public abstract void commit();
public abstract boolean isCompleted();
public abstract void complete();
public boolean isPromised() {
return promise!=null;
}
/**
* Initiate an async promise chain using supplyAsync.
* Gets executor from request's CallSession.
* Automatically attaches/detaches CallSession for the executing thread.
* Can be called multiple times - will chain after existing promise.
* Automatically flattens if supplier returns a CompletableFuture.
* @param supplier lambda that accepts one value and returns one value (or CompletableFuture)
* @return this Response for chaining
*/
public Response promiseFirst(Function<Object, Object> supplier) {
if(request == null) {
throw new IllegalStateException("Cannot create promise without request");
}
final CallSession session = request.getSession();
Executor executorTemp = session != null ? session.getExecutor() : null;
if(executorTemp == null) {
executorTemp = java.util.concurrent.ForkJoinPool.commonPool();
}
final Executor executor = executorTemp;
Function<Object, CompletableFuture<Object>> newTask = (prevValue) -> {
CompletableFuture<Object> innerFuture = CompletableFuture.supplyAsync(() -> {
session.beginAgain();
try {
return supplier.apply(prevValue);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
session.end();
}
}, executor);
return innerFuture.thenCompose(result -> {
if(result instanceof CompletableFuture) {
@SuppressWarnings("unchecked")
CompletableFuture<Object> futureResult = (CompletableFuture<Object>)result;
return futureResult;
} else {
return CompletableFuture.completedFuture(result);
}
});
};
if(promise != null) {
promise = promise.thenCompose(newTask);
} else {
promise = newTask.apply(null);
}
return this;
}
/**
* Add a step to the promise chain.
* Automatically attaches/detaches CallSession for the executing thread.
* @param step lambda that accepts the value from previous step and returns a value
* @return this Response for chaining
*/
public Response promiseNext(Function<Object, Object> step) {
if(promise == null) {
throw new IllegalStateException("Promise chain not initiated. Call promiseFirst() first.");
}
final CallSession session = request.getSession();
promise = promise.thenApply(value -> {
session.beginAgain();
try {
return step.apply(value);
} catch (Exception e) {
throw (e instanceof RuntimeException) ? (RuntimeException)e : new RuntimeException(e);
} finally {
session.end();
}
});
return this;
}
/**
* Final step in the promise chain - finalizes response and handles errors.
* Automatically attaches/detaches CallSession for the executing thread.
* @param callback BiConsumer that receives result and error
* @return this Response for chaining
*/
public Response promiseLast(BiConsumer<Object, Throwable> callback) {
if(promise == null) {
throw new IllegalStateException("Promise chain not initiated. Call promiseFirst() first.");
}
final CallSession session = request.getSession();
promise = promise.whenComplete((result, error) -> {
session.beginAgain();
try {
callback.accept(result, error);
} catch (Exception e) {
e.printStackTrace();
} finally {
session.end();
}
}).thenApply(v -> null);
return this;
}
/**
* Upgrade HTTP response to WebSocket.
* Called by INVOKE_WEBSOCKET in MethodEndPoint.
*
* @param route The WebSocket route path
* @param appSession User session from CallSession (can be null)
* @return WebSocketSession for this connection
* @throws IOException if upgrade fails
*
* TODO: Implement in ServletResponse using Jakarta WebSocket API
*/
public abstract WebSocketSession upgradeToWebSocket(String route, Session appSession) throws IOException;
}
@@ -22,8 +22,14 @@ import java.text.MessageFormat;
import java.util.Collection;
import java.util.Iterator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.reliancy.rec.JSONEncoder;
import com.reliancy.util.CodeException;
import com.reliancy.jabba.ui.Rendering;
import com.reliancy.jabba.ui.Template;
/**
* This class will replace the Java writer.
@@ -39,6 +45,11 @@ public class ResponseEncoder implements Appendable,Closeable{
protected OutputStream out;
protected Charset charSet;
protected String errorFmt;
private static final Logger logger = LoggerFactory.getLogger(ResponseEncoder.class);
protected Logger log(){
return logger;
}
public ResponseEncoder(Response r){
this(r,StandardCharsets.UTF_8);
@@ -48,7 +59,7 @@ public class ResponseEncoder implements Appendable,Closeable{
public ResponseEncoder(Response r,Charset chset){
response=r;
//locale=loc;
charSet=StandardCharsets.UTF_8;
charSet=chset;
}
public ResponseEncoder setCharSet(Charset set){
charSet=set;
@@ -56,13 +67,9 @@ public class ResponseEncoder implements Appendable,Closeable{
}
public OutputStream getOutputStream() throws IOException{
if(out!=null) return out;
if(response.getStatus()==null) response.setStatus(Response.HTTP_OK);
if(response.getContentType()==null) response.setContentType("application/octet-stream");
if(response.http_response!=null){
out=response.http_response.getOutputStream();
}else if(response.byte_response!=null){
out=response.byte_response;
}else{
response.commit();
out=response.getOutputStream();
if(out==null){
out=new ByteArrayOutputStream();
}
writer=new OutputStreamWriter(out,charSet);
@@ -70,56 +77,107 @@ public class ResponseEncoder implements Appendable,Closeable{
}
public Writer getWriter() throws IOException{
if(writer!=null) return writer;
if(response.getStatus()==null) response.setStatus(Response.HTTP_OK);
if(response.getContentType()==null) response.setContentType("text/plain;charset=utf-8");
if(response.http_response!=null){
writer=response.http_response.getWriter();
}else if(response.char_response!=null){
writer=response.char_response;
}else if(response.byte_response!=null){
out=response.byte_response;
writer=new OutputStreamWriter(out,charSet);
}else{
response.commit();
writer=response.getWriter();
if(writer==null){
writer=new StringWriter();
}
return writer;
}
public void flush() throws IOException{
if(writer!=null) writer.flush();
if(out!=null) out.flush();
}
public ResponseEncoder writeBytes(byte[] buf,int offset,int len) throws IOException{
getOutputStream().write(buf,offset, len);
try{
response.transitionTo(ResponseState.WRITING);
getOutputStream().write(buf,offset, len);
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
public ResponseEncoder writeString(CharSequence str) throws IOException{
getWriter().append(str);
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=getWriter();
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
wr.append(str);
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
public ResponseEncoder writeStream(InputStream is) throws IOException{
byte[] buf=new byte[2*4096];
int bytesRead=-1;
while((bytesRead=is.read(buf))!=-1){
writeBytes(buf,0,bytesRead);
// Get output stream first (this will commit if still in CONFIGURING)
OutputStream os=getOutputStream();
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
while((bytesRead=is.read(buf))!=-1){
os.write(buf,0,bytesRead);
}
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
public ResponseEncoder writeln(CharSequence msg,Object ... args) throws IOException{
if(args.length==0){
getWriter().append(msg).append("\n");
}else{
String str=MessageFormat.format(msg.toString(),args);
getWriter().append(str).append("\n");
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=getWriter();
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
if(args.length>0){
msg=MessageFormat.format(msg.toString(),args);
}
wr.append(msg).append("\n");
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
public ResponseEncoder writeIterator(Iterator<String> it) throws IOException{
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=getWriter();
while(it.hasNext()) wr.append(it.next());
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
while(it.hasNext()) wr.append(it.next());
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
public ResponseEncoder writeReader(Reader rd) throws IOException{
char[] buffer = new char[2*4096];
int n = 0;
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=this.getWriter();
while (-1 != (n = rd.read(buffer))) {
wr.write(buffer, 0, n);
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
while (-1 != (n = rd.read(buffer))) {
wr.write(buffer, 0, n);
}
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
@@ -130,48 +188,148 @@ public class ResponseEncoder implements Appendable,Closeable{
public String getErrorFormat(){
return this.errorFmt;
}
/** When an error occurs we need properly render exception.
* if html is accepted we try to render a valid response with n error within a template so it fits with the app.
* for all others we set error status code.
* for json,xml and plain we render into a message template for the rest we do nothing.
* this method returns true if a response was generated. in overloaded methods
* if false is returned we can generate response the status is set to 500 already.
* @param req incoming request
* @param ex exception state
* @param resp response to generate
* @return true if handled else it signifies we should do somthing in overloads.
* @throws IOException
*/
public ResponseEncoder writeError(Throwable ex) throws IOException{
if(errorFmt==null){
this.writeString(ex.toString());
}else{
log().error("error:",ex);
Request req=response.getRequest();
if(response.getStatus()==null) response.setStatus(Response.HTTP_INTERNAL_ERROR);
String accepted_format=req!=null?req.getHeader("Accept"):null;
boolean present=accepted_format!=null;
if(present && (accepted_format.contains("/html") || accepted_format.contains("/xhtml"))){
// we have html request
response.setContentType(HTTP.MIME_HTML);
Template t=Template.find("/templates/error.hbs");
if(t==null){ // no template found
if(ex instanceof IOException) throw ((IOException)ex);
else throw new RuntimeException(ex);
}
Rendering.begin(t).with(ex).end(response);
return this;
}
// next we format a few common and supported messages
if(present && accepted_format.contains("/json")){
response.setContentType(HTTP.MIME_JSON);
String template=getErrorFormat();
if(template==null){
template="'{'\n\t\"status\":\"error\",\n\t\"title\":\"{0}\",\n\t\"message\":\"{1}\"\n'}'\n";
}
StringBuilder title=new StringBuilder();
StringBuilder detail=new StringBuilder();
CodeException.fillUserMessage(ex, detail, title);
String body=MessageFormat.format(
errorFmt,
JSONEncoder.escape(title),
JSONEncoder.escape(detail));
String body=MessageFormat.format(template,JSONEncoder.escape(title),JSONEncoder.escape(detail));
writeString(body);
return this;
}
if(present && accepted_format.contains("/xml")){
response.setContentType(HTTP.MIME_XML);
String template=getErrorFormat();
if(template==null){
template="<response>\n\t<status>error</status>\n\t<title>{0}</title>\n\t<message>{1}</message>\n</response>\n";
}
StringBuilder title=new StringBuilder();
StringBuilder detail=new StringBuilder();
CodeException.fillUserMessage(ex, detail, title);
String body=MessageFormat.format(template,title,detail);
writeString(body);
return this;
}
if(present && accepted_format.contains("text/plain")){
response.setContentType(HTTP.MIME_PLAIN);
String template=getErrorFormat();
if(template==null){
template="status=error\n\ntitle={0}\n\nmessage={1}\n\n";
}
StringBuilder title=new StringBuilder();
StringBuilder detail=new StringBuilder();
CodeException.fillUserMessage(ex, detail, title);
String body=MessageFormat.format(template,title,detail);
writeString(body);
return this;
}
String body=ex.toString();
String template=getErrorFormat();
if(template!=null){
StringBuilder title=new StringBuilder();
StringBuilder detail=new StringBuilder();
CodeException.fillUserMessage(ex, detail, title);
body=MessageFormat.format(template,title,detail);
}
this.writeString(body);
return this;
}
public ResponseEncoder writeObject(Object ret) throws IOException{
if(ret==null) return this;
Writer wr=getWriter();
if(ret instanceof Iterator){
Iterator<?> it=(Iterator<?>)ret;
while(it.hasNext()){
Object obj=it.next();
writeObject(obj);
try{
log().debug("ResponseEncoder.writeObject(): ret={}, retType={}", ret, ret.getClass().getName());
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=getWriter();
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
log().debug("ResponseEncoder.writeObject(): got writer={}", wr != null ? wr.getClass().getName() : "null");
if(ret instanceof Iterator){
Iterator<?> it=(Iterator<?>)ret;
while(it.hasNext()){
Object obj=it.next();
writeObject(obj);
}
}else if(ret instanceof Collection){
Collection<?> cret=(Collection<?>) ret;
for(Object o:cret) writeObject(o);
}else if(ret instanceof Reader){
writeReader((Reader)ret);
}else if(ret instanceof byte[]){
byte[] bret=(byte[])ret;
writeBytes(bret,0,bret.length);
}else if(ret instanceof Throwable){
writeError((Throwable)ret);
}else{
String str = ret.toString();
log().debug("ResponseEncoder.writeObject(): writing string, length={}", str.length());
wr.append(str);
log().debug("ResponseEncoder.writeObject(): string written");
}
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}else if(ret instanceof Collection){
Collection<?> cret=(Collection<?>) ret;
for(Object o:cret) writeObject(o);
}else if(ret instanceof Reader){
writeReader((Reader)ret);
}else if(ret instanceof byte[]){
byte[] bret=(byte[])ret;
writeBytes(bret,0,bret.length);
}else{
wr.append(ret.toString());
}
//wr.append("\n");
return this;
}
////// Interface implementations
@Override
public void close() throws IOException {
getWriter().close();
try {
// If we're still writing, mark as written before closing
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
// Close the writer/stream
if(writer != null) {
writer.close();
} else if(out != null) {
out.close();
}
} catch(IOException e) {
// Ensure state is correct even if close fails
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
throw e;
}
}
@Override
public Appendable append(CharSequence csq) throws IOException {
@@ -179,12 +337,32 @@ public class ResponseEncoder implements Appendable,Closeable{
}
@Override
public Appendable append(CharSequence csq, int start, int end) throws IOException {
this.getWriter().append(csq,start,end);
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=this.getWriter();
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
wr.append(csq,start,end);
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
@Override
public Appendable append(char c) throws IOException {
this.getWriter().append(c);
// Get writer first (this will commit if still in CONFIGURING)
Writer wr=this.getWriter();
try{
// Now transition to WRITING (state should be COMMITTED at this point)
response.transitionTo(ResponseState.WRITING);
wr.append(c);
}finally{
if(response.getState() == ResponseState.WRITING) {
response.transitionTo(ResponseState.WRITTEN);
}
}
return this;
}
}
@@ -0,0 +1,152 @@
/*
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;
/**
* Represents the lifecycle state of a Response object.
* Response goes through stages: created -> configuring -> committed -> writing <-> written -> completed
*/
public enum ResponseState {
/** Response object created, nothing configured yet */
CREATED,
/** Headers, status, content type can be set */
CONFIGURING,
/** Response committed to HttpServletResponse (headers locked) */
COMMITTED,
/** Body content is being written */
WRITING,
/** Body content has been written */
WRITTEN,
/** Response fully done */
COMPLETED;
/**
* Check if state allows setting headers/status/content type.
* @return true if headers can be modified
*/
public boolean canConfigure() {
return this == CREATED || this == CONFIGURING;
}
/**
* Check if state allows writing body content.
* @return true if body can be written
*/
public boolean canWrite() {
return this == COMMITTED || this == WRITING || this == WRITTEN;
}
/**
* Check if state allows flushing.
* @return true if response can be flushed
*/
public boolean canFlush() {
return this == WRITING || this == WRITTEN || this == COMMITTED;
}
/**
* Check if response has been written (body content exists).
* @return true if body has been written
*/
public boolean isWritten() {
return this == WRITTEN || this == COMPLETED;
}
/**
* Check if response is committed (headers locked).
* @return true if response is committed
*/
public boolean isCommitted() {
return this == COMMITTED || this == WRITING || this == WRITTEN || this == COMPLETED;
}
/**
* Check if response is completed (fully done).
* @return true if response is completed
*/
public boolean isCompleted() {
return this == COMPLETED;
}
/**
* Transition from this state to a new state. Validates the transition.
* Automatically handles intermediate state transitions (e.g., CREATED -> CONFIGURING when configuring,
* CONFIGURING -> WRITING when writing).
* @param to the target state
* @return the new state if transition is valid
* @throws IllegalStateException if transition is invalid
*/
public ResponseState transitionTo(ResponseState to) {
if(this == to) return this;
// Auto-transition to intermediate states if needed
if(to == CONFIGURING) {
// Allow transition to CONFIGURING from CREATED
if(this == CREATED) {
return CONFIGURING;
}
// If already in CONFIGURING or later, check if we can still configure
if(!this.canConfigure()) {
throw new IllegalStateException("Cannot configure in state: " + this);
}
} else if(to == COMMITTED) {
// Allow transition to COMMITTED from CONFIGURING
if(this == CONFIGURING) {
return COMMITTED;
}
// If already committed or later, stay in current state
if(this == COMMITTED || this == WRITING || this == WRITTEN || this == COMPLETED) {
return this;
}
} else if(to == WRITING) {
// Allow transition to WRITING from COMMITTED, WRITING, or WRITTEN
if(this == COMMITTED) {
return WRITING;
}
if(this == WRITTEN) {
return WRITING; // Can go back to writing to append more content
}
// Check if we can write
if(!this.canWrite()) {
throw new IllegalStateException("Cannot write in state: " + this);
}
}
// Validate direct state transitions
switch(this) {
case CREATED:
if(to != CONFIGURING && to != COMPLETED) {
throw new IllegalStateException("Invalid transition from CREATED to " + to);
}
break;
case CONFIGURING:
if(to != COMMITTED && to != WRITING && to != COMPLETED) {
throw new IllegalStateException("Invalid transition from CONFIGURING to " + to);
}
break;
case WRITING:
if(to != WRITTEN && to != COMPLETED) {
throw new IllegalStateException("Invalid transition from WRITING to " + to);
}
break;
case WRITTEN:
if(to != WRITING && to != COMPLETED) {
throw new IllegalStateException("Invalid transition from WRITTEN to " + to);
}
break;
case COMMITTED:
if(to != WRITING && to != COMPLETED) {
throw new IllegalStateException("Invalid transition from COMMITTED to " + to);
}
break;
case COMPLETED:
throw new IllegalStateException("Cannot transition from COMPLETED state");
}
return to;
}
}
+31 -25
View File
@@ -14,15 +14,20 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.reliancy.jabba.decor.Async;
import com.reliancy.jabba.decor.Routed;
import com.reliancy.jabba.decor.WebSocket;
/** Router is a special Processor which redirects requests to endpoints.
*
* Handles HTTP endpoints.
*/
public class Router extends Processor{
HashMap<String,EndPoint> routes=new HashMap<>(); // route pattern to endpoint
HashMap<String,EndPoint> routes=new HashMap<>(); // HTTP route pattern to endpoint
ArrayList<RouteDetector> detectors=new ArrayList<>(); // route patterns ordered
int[] indexes; // indexes for each route within regex
Pattern regex;
@@ -31,12 +36,6 @@ public class Router extends Processor{
super("Router");
}
@Override
public void before(Request request, Response response) throws IOException {
}
@Override
public void after(Request request, Response response) throws IOException {
}
@Override
public void serve(Request req, Response resp) throws IOException {
//System.out.println(req.http_request);
String verb=req.getVerb();
@@ -44,25 +43,26 @@ public class Router extends Processor{
log().info("serving:{}",path);
Matcher m=match(verb,path);
//Matcher m=rep.match("GET","/helloP");
if(m!=null){
//HashMap<String,String> pms=new HashMap<>();
String rt=evalMatcher(m,req.getPathParams());
//System.out.println(req.getPathParams());
EndPoint ep=getRoute(rt);
if(ep!=null){
ep.process(req, resp);
}else{
log().error("no endpoint for:{}",rt);
resp.setContentType("text/plain;charset=utf-8");
resp.setStatus(Response.HTTP_NOT_FOUND);
resp.getEncoder().writeln("no endpoint for :"+rt);
}
}else{
if(m==null){
log().error("could not resolve path:{}",path);
resp.setContentType("text/plain;charset=utf-8");
resp.setStatus(Response.HTTP_NOT_FOUND);
resp.getEncoder().writeln("could not resolve path:"+path);
//return isAsync()?CompletableFuture.completedFuture(null):null;
return;
}
String rt=evalMatcher(m,req.getPathParams());
EndPoint ep=getRoute(rt);
if(ep==null){
log().error("no endpoint for:{}",rt);
resp.setContentType("text/plain;charset=utf-8");
resp.setStatus(Response.HTTP_NOT_FOUND);
resp.getEncoder().writeln("no endpoint for :"+rt);
//return isAsync()?CompletableFuture.completedFuture(null):null;
return;
}
log().info("Router: matched route={}, endpoint={}", rt, ep != null ? ep.getId() : "null");
ep.process(req, resp);
}
/** Lookup of endpoints by full routing string.
* that includes verb.
@@ -158,25 +158,31 @@ public class Router extends Processor{
}
/**
* Will import endpoints to serve various paths.
* Scans for @Routed (HTTP) annotations.
* We can call this multiple times for multiple targets.
* @param target
* @return
*/
public Router importMethods(Object target){
//RoutedEndPoint ret=new RoutedEndPoint();
LinkedList<Method> routes=new LinkedList<>();
LinkedList<Method> httpRoutes=new LinkedList<>();
Class<?> type=target.getClass();
while (type != null) {
for(Method m : type.getDeclaredMethods()){
//System.out.println("Method:"+m.toString());
if(m.getAnnotation(Routed.class)!=null){
routes.add(0,m);
httpRoutes.add(0,m);
}
}
type = type.getSuperclass();
}
for(Method m:routes){
// Process HTTP routes
for(Method m:httpRoutes){
MethodEndPoint mm=new MethodEndPoint(target,m);
// Check for @Async annotation and set async flag
if(m.getAnnotation(Async.class)!=null){
mm.setAsync(true);
}
addRoute(mm.getVerb(),mm.getPath(),mm);
}
return this;
@@ -0,0 +1,25 @@
/*
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.IOException;
import java.util.concurrent.CompletableFuture;
/**
* Interface for objects that can serve responses for requests.
*/
public interface Servant {
/**
* Process a request and generate a response.
* @param request the request to process
* @param response the response to populate
* @throws IOException if processing fails
*/
void serve(Request request, Response response) throws IOException;
}
@@ -2,6 +2,8 @@ package com.reliancy.jabba;
import java.io.IOException;
import com.reliancy.jabba.decor.Routed;
public class StatusMod implements AppModule{
@Override
public void publish(App app) {
@@ -0,0 +1,269 @@
/*
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.IOException;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Abstract WebSocket session representing a single client connection.
* Provides callback-based API for handling WebSocket events.
*
* Each connection gets its own session instance with:
* - AppSession from the upgrade request (user context)
* - Callback handlers for text, binary, error, close events
* - Static registry for broadcasting to multiple clients
*/
public abstract class WebSocketSession {
// Static registry of all active WebSocket sessions
private static final Map<String, WebSocketSession> allSessions = new ConcurrentHashMap<>();
// Instance fields
protected String id; // Unique session ID: route + "/" + remoteAddress
protected String route; // WebSocket route path (e.g., "/ws/echo")
protected String remoteAddress; // Client address
protected Session appSession; // User session from HTTP upgrade request
// Callback handlers (set by user code)
protected Consumer<String> textHandler;
protected Consumer<byte[]> binaryHandler;
protected Consumer<Throwable> errorHandler;
protected BiConsumer<Integer, String> closeHandler;
/**
* Constructor - automatically registers session in static registry
*/
protected WebSocketSession(String route, String remoteAddress, Session appSession) {
this.route = route;
this.appSession = appSession;
setRemoteAddress(remoteAddress); // Sets address and builds ID
allSessions.put(this.id, this);
}
/**
* Set remote address and rebuild session ID.
* Allows updating from IP to resolved name later.
*/
public void setRemoteAddress(String remoteAddress) {
// Remove old ID from registry if exists
if (this.id != null) {
allSessions.remove(this.id);
}
this.remoteAddress = remoteAddress;
this.id = route + "/" + remoteAddress;
// Re-register with new ID
allSessions.put(this.id, this);
}
// ========== Send Methods (abstract - implemented by servlet/native) ==========
/**
* Send text message to this client
*/
public abstract void sendText(String message) throws IOException;
/**
* Send binary data to this client
*/
public abstract void sendBinary(byte[] data) throws IOException;
/**
* Close this WebSocket connection
*/
public abstract void close() throws IOException;
/**
* Close with status code and reason
*/
public abstract void close(int code, String reason) throws IOException;
/**
* Check if connection is open
*/
public abstract boolean isOpen();
// ========== Callback Setters (used by application code) ==========
/**
* Set handler for incoming text messages
*/
public void onText(Consumer<String> handler) {
this.textHandler = handler;
}
/**
* Set handler for incoming binary messages
*/
public void onBinary(Consumer<byte[]> handler) {
this.binaryHandler = handler;
}
/**
* Set handler for errors
*/
public void onError(Consumer<Throwable> handler) {
this.errorHandler = handler;
}
/**
* Set handler for connection close
* @param handler receives (closeCode, reason)
*/
public void onClose(BiConsumer<Integer, String> handler) {
this.closeHandler = handler;
}
// ========== Internal Callback Invocation (called by implementation) ==========
/**
* Internal: Dispatch text message to handler
*/
protected void handleText(String message) {
if (textHandler != null) {
try {
textHandler.accept(message);
} catch (Exception e) {
handleError(e);
}
}
}
/**
* Internal: Dispatch binary message to handler
*/
protected void handleBinary(byte[] data) {
if (binaryHandler != null) {
try {
binaryHandler.accept(data);
} catch (Exception e) {
handleError(e);
}
}
}
/**
* Internal: Dispatch error to handler
*/
protected void handleError(Throwable error) {
if (errorHandler != null) {
try {
errorHandler.accept(error);
} catch (Exception e) {
// Log error in error handler?
e.printStackTrace();
}
}
}
/**
* Internal: Dispatch close event to handler and cleanup
*/
protected void handleClose(int code, String reason) {
try {
if (closeHandler != null) {
closeHandler.accept(code, reason);
}
} finally {
// Remove from registry
allSessions.remove(this.id);
}
}
// ========== Getters ==========
public String getId() {
return id;
}
public String getRoute() {
return route;
}
public String getRemoteAddress() {
return remoteAddress;
}
public Session getAppSession() {
return appSession;
}
// ========== Static Registry Methods for Broadcasting ==========
/**
* Get all sessions for a specific route
*/
public static Collection<WebSocketSession> getSessionsForRoute(String route) {
return allSessions.values().stream()
.filter(s -> s.route.equals(route))
.collect(Collectors.toList());
}
/**
* Get all active sessions
*/
public static Collection<WebSocketSession> getAllSessions() {
return allSessions.values();
}
/**
* Broadcast text message to all clients on a route
*/
public static void broadcast(String route, String message) {
getSessionsForRoute(route).forEach(session -> {
try {
session.sendText(message);
} catch (IOException e) {
session.handleError(e);
}
});
}
/**
* Broadcast text message to all connected clients
*/
public static void broadcastAll(String message) {
getAllSessions().forEach(session -> {
try {
session.sendText(message);
} catch (IOException e) {
session.handleError(e);
}
});
}
/**
* Get session by ID
*/
public static WebSocketSession getSession(String id) {
return allSessions.get(id);
}
/**
* Get count of active sessions
*/
public static int getSessionCount() {
return allSessions.size();
}
/**
* Get count of sessions for a route
*/
public static int getSessionCount(String route) {
return (int) allSessions.values().stream()
.filter(s -> s.route.equals(route))
.count();
}
}
@@ -0,0 +1,20 @@
/*
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.decor;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Annotation to mark methods as async endpoints.
* This annotation can be used in conjunction with @Routed.
*/
@Retention(RetentionPolicy.RUNTIME)
public @interface Async {
}
@@ -1,19 +1,19 @@
/*
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.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface Routed {
String path() default "{method}";
String verb() default "GET|POST|DELETE";
String return_mime() default "";
}
/*
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.decor;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface Routed {
String path() default "{method}";
String verb() default "GET|POST|DELETE";
String return_mime() default "";
}
@@ -0,0 +1,52 @@
/*
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.decor;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a method as a WebSocket endpoint.
* WebSocket endpoints handle bidirectional communication with clients.
* Works in conjunction with @Routed annotation for path mapping.
*
* Example usage:
* <pre>
* {@literal @}Routed(path="/ws/chat")
* {@literal @}WebSocket
* public void handleChat(WebSocketSession session) {
* session.onMessage(msg -> {
* session.send("Echo: " + msg);
* });
* }
* </pre>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WebSocket {
/**
* Optional subprotocols supported by this endpoint.
* Example: {"mqtt", "stomp"}
*/
String[] subprotocols() default {};
/**
* Maximum message size in bytes (default 64KB).
* Set to -1 for unlimited.
*/
int maxMessageSize() default 65536;
/**
* Idle timeout in milliseconds (default 5 minutes).
* Connection closes if no messages received within this time.
*/
long idleTimeout() default 300000;
}
@@ -13,6 +13,7 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.concurrent.CompletableFuture;
import java.util.Map;
import java.util.regex.Pattern;
@@ -24,8 +25,10 @@ import com.reliancy.jabba.Response;
import com.reliancy.jabba.RouteDetector;
import com.reliancy.jabba.MethodDecorator;
import com.reliancy.jabba.MethodEndPoint;
import com.reliancy.jabba.Config;
import com.reliancy.util.CodeException;
import com.reliancy.util.Handy;
import java.security.SecureRandom;
/**
* SecurityPolicy is a filter/processor that implements various auth protocols but also sources users.
@@ -40,7 +43,7 @@ import com.reliancy.util.Handy;
public class SecurityPolicy extends Processor implements MethodDecorator.Factory{
public static String REALM="reliancy";
public static final String KEY_NAME="jbauth";
protected String secret="sdfklgj 7150 9178-54=09";
protected String secret=null;
protected ArrayList<SecurityProtocol> protocols;
protected SecurityActor admin;
protected SecurityActor guest;
@@ -54,6 +57,29 @@ public class SecurityPolicy extends Processor implements MethodDecorator.Factory
protocols.add(new SecurityProtocol.Basic());
}
protected String getSecret(){
if(secret==null){
// Try to load from config first
Config conf=getConfig();
if(conf!=null){
secret=Config.SECRET_KEY.get(conf,null);
}
// Try environment variable
if(secret==null || secret.isEmpty()){
secret=System.getenv("JABBA_SECRET_KEY");
}
// Try system property
if(secret==null || secret.isEmpty()){
secret=System.getProperty("jabba.secret.key");
}
// Generate secure random secret if still not found
if(secret==null || secret.isEmpty()){
SecureRandom random=new SecureRandom();
byte[] bytes=new byte[32];
random.nextBytes(bytes);
secret=java.util.Base64.getEncoder().encodeToString(bytes);
log().warn("No secret key configured. Generated a random secret. This should be set via SECRET_KEY config, JABBA_SECRET_KEY environment variable, or jabba.secret.key system property for production use.");
}
}
return secret;
}
public SecurityPolicy setSecured(String path,Secured info){
@@ -70,7 +96,7 @@ public class SecurityPolicy extends Processor implements MethodDecorator.Factory
return null;
}
@Override
public void before(Request request, Response response) throws IOException {
public void beforeServe(Request request, Response response) throws IOException {
// we will recover a user here
CallSession css=CallSession.getInstance();
AppSession ass=(AppSession) css.getAppSession();
@@ -104,11 +130,7 @@ public class SecurityPolicy extends Processor implements MethodDecorator.Factory
}
}
@Override
public void after(Request request, Response response) throws IOException {
}
@Override
public void serve(Request request, Response response) throws IOException {
// nothing to do here
public void afterServe(Request request, Response response) throws IOException {
}
/** authenticates or establishes user based on user and password.
* same as loadActor but with first param being admin account.
@@ -213,7 +235,7 @@ public class SecurityPolicy extends Processor implements MethodDecorator.Factory
@Override
public MethodDecorator assertDecorator(MethodEndPoint mep, Annotation ann) {
if(!(ann instanceof Secured)) return null;
System.out.println("Assert decorator for:"+mep.getPath());
log().debug("Assert decorator for:{}",mep.getPath());
String verb=mep.getVerb();
String path=mep.getPath();
String pat=RouteDetector.toPattern(verb, path);
@@ -1,276 +1,366 @@
/*
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.IOException;
import java.text.MessageFormat;
import java.util.EventListener;
import com.reliancy.jabba.sec.SecurityPolicy;
import com.reliancy.jabba.sec.plain.PlainSecurityStore;
import com.reliancy.jabba.ui.Menu;
import com.reliancy.jabba.ui.MenuItem;
import com.reliancy.jabba.ui.Rendering;
import com.reliancy.jabba.ui.Template;
import com.reliancy.rec.JSONEncoder;
import com.reliancy.util.CodeException;
import com.reliancy.util.Log;
import com.reliancy.util.Resources;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.ForwardedRequestCustomizer;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.MultiPartFormDataCompliance;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Router is entry point and servlet implementation that dispatches messages to our endpoints.
* It will launch an embedded jetty server.
* It will provide facilities to register endpoints via router.
* Mostly new routes are injected via AppModules which publish themselves.
* JettyApp installs ForwardCustomizer to react to reverse proxy setups.
*
*/
public class JettyApp extends App implements Handler{
enum State{
STOPPED,
FAILED,
STARTING,
STARTED,
STOPPING,
RUNNING
}
protected Connector[] connectors;
protected Server jetty;
private volatile State _state;
public JettyApp() {
super("JettyApp");
jetty = new Server();
jetty.setHandler(this);
_state=State.STOPPED;
this.addShutdownHook();
}
public Connector[] getConnectors(){
if(connectors!=null) return connectors;
// Create HTTP Config
HttpConfiguration httpConfig = new HttpConfiguration();
// Add support for X-Forwarded headers
httpConfig.addCustomizer( new ForwardedRequestCustomizer() );
// Create the http connector
HttpConnectionFactory http11 = new HttpConnectionFactory( httpConfig );
HTTP2ServerConnectionFactory h2c = new HTTP2CServerConnectionFactory(httpConfig);
ServerConnector httpConn = new ServerConnector(jetty,http11,h2c);
httpConn.setReuseAddress(false);
httpConn.setPort(8090);
connectors=new Connector[] {httpConn};
return connectors;
}
/** implementation of jetty handler interface */
@Override
public Server getServer() {
return jetty;
}
@Override
public void setServer(Server arg0) {
jetty=arg0;
}
@Override
public boolean addEventListener(EventListener arg0) {
return false;
}
@Override
public boolean removeEventListener(EventListener arg0) {
return false;
}
protected void setState(State s){
_state=s;
}
@Override
public boolean isFailed() {
return _state==State.FAILED;
}
@Override
public boolean isRunning() {
return _state==State.RUNNING;
}
@Override
public boolean isStarted() {
return _state==State.STARTED;
}
@Override
public boolean isStarting() {
return _state==State.STARTING;
}
@Override
public boolean isStopped() {
return _state==State.STOPPED;
}
@Override
public boolean isStopping() {
return _state==State.STOPPING;
}
@Override
public void start() throws Exception {
_state=State.STARTED;
jetty.setConnectors(getConnectors());
}
@Override
public void stop() throws Exception {
_state=State.STOPPED;
Connector[] connectors=jetty.getConnectors();
if(connectors==null || connectors.length==0) return;
for(Connector c:connectors){
ServerConnector cc=(ServerConnector) c;
//System.out.println("stopping connecor:"+cc);
try{
cc.stop();
cc.getConnectedEndPoints().forEach((endpoint)-> {
//System.out.println("closing endpoint:"+endpoint);
endpoint.close();
});
}finally{
cc.close();
//System.out.println("closing connecor:"+cc.getState());
}
}
}
@Override
public void destroy() {
}
/**
* Our implementation of a handle process.
* In case of exception if we can locate /tempaltes/error.hbs we use it else we re-throw.
*/
@Override
public void handle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException
{
com.reliancy.jabba.Request req=new com.reliancy.jabba.Request(request);
Response resp=new Response(response);
CallSession ss=CallSession.getInstance();
try{
ss.begin(null, req, resp);
process(req,resp);
}catch(Exception ioex){
processError(req,ioex,resp);
}finally{
baseRequest.setHandled(true);
ss.end();
}
}
/** our own interface specific to jetty engine*/
public void begin(Config conf) throws Exception{
// step 2: configure application, might add processors, adjust config
configure(conf);
// step 1: install config then begin by signaling all middleware
super.begin(conf);
// step 2: start jetty
try{
log().info("starting...");
jetty.start();
}catch(Exception ex){
setState(State.FAILED);
if(ex.getCause() instanceof java.net.BindException){
log().error("bind issue",ex);
Thread.sleep(3000);
}else throw ex;
}
}
public void work() throws InterruptedException{
setState(State.RUNNING);
if(jetty!=null) jetty.join();
}
public void end() throws Exception{
super.end();
Log.cleanup(); // release logging in case we deferred
System.gc(); // sweep memory just in caser
}
/** Registers a shutdown hook to interrup jetty.
* ctrl-c works but does not perform our shutdown sequence.
* this code interrupts jetty and then waits for app to finish.
*/
protected final void addShutdownHook(){
final JettyApp app=this;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if(app.isRunning()){
try {
app.jetty.stop();
synchronized(app){
app.wait(5000);
}
} catch (Exception e) {
app.log().error("shutdown cleanup:", e);
}
}
}));
}
/** called from begin just before jetty starts.
* this method is called before middleware is notified so we can add or adjust config.
* override to hook up your application.
* normally follows configuraion and does common sense steps.
* might install middleware (processors) which are later passed config.
*/
public void configure(Config conf) throws Exception{
App app=this;
// setup global search path - include workdir first, then get class and app.class
Class<?> cls=getClass();
if(cls!=JettyApp.class) Resources.appendSearch(0,JettyApp.class);
Resources.appendSearch(0,cls);
String work_dir=ArgsConfig.APP_WORKDIR.get(conf);
if(work_dir!=null) Resources.appendSearch(0,work_dir);
//for(Object p:Resources.search_path){
// System.out.println("sp:"+p);
//}
//Template.search_path(work_dir,App.class); -- not needed anymore
// install app session middleware
app.addAppSession();
// set security policy
SecurityPolicy secpol=new SecurityPolicy().setStore(new PlainSecurityStore());
app.setSecurityPolicy(secpol);
// install router
app.setRouter(new Router());
StatusMod ep=new StatusMod();
ep.publish(app);
// install file sever endpoint
FileServer fs=new FileServer("/static","/public");
fs.publish(app);
Menu top_menu=Menu.request(Menu.TOP);
top_menu.add(new MenuItem("home")).addSpacer().add(new MenuItem("login"));
top_menu.setTitle("Jabba3");
}
public static void main( String[] args ) throws Exception{
Config cnf=new ArgsConfig(args).load();
JettyApp app=new JettyApp();
app.run(cnf);
}
}
/*
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.servlet;
import java.io.IOException;
import com.reliancy.jabba.App;
import com.reliancy.jabba.ArgsConfig;
import com.reliancy.jabba.CallSession;
import com.reliancy.jabba.Config;
import com.reliancy.jabba.FileServer;
import com.reliancy.jabba.Response;
import com.reliancy.jabba.Router;
import com.reliancy.jabba.StatusMod;
import com.reliancy.jabba.sec.SecurityPolicy;
import com.reliancy.jabba.sec.plain.PlainSecurityStore;
import com.reliancy.jabba.ui.Menu;
import com.reliancy.jabba.ui.MenuItem;
import com.reliancy.util.Log;
import com.reliancy.util.Resources;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.ForwardedRequestCustomizer;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.ee10.servlet.ServletHolder;
import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
import jakarta.servlet.Servlet;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Router is entry point and servlet implementation that dispatches messages to our endpoints.
* It will launch an embedded jetty server.
* It will provide facilities to register endpoints via router.
* Mostly new routes are injected via AppModules which publish themselves.
* JettyApp installs ForwardCustomizer to react to reverse proxy setups.
*
*/
public class JettyApp extends App implements Servlet {
enum State{
STOPPED,
FAILED,
STARTING,
STARTED,
STOPPING,
RUNNING
}
protected Connector[] connectors;
protected Server jetty;
protected ServletConfig servletConfig;
private volatile State _state;
public JettyApp() {
super("JettyApp");
jetty = new Server();
_state=State.STOPPED;
this.addShutdownHook();
}
public Connector[] getConnectors(){
if(connectors!=null) return connectors;
// Create HTTP Config
HttpConfiguration httpConfig = new HttpConfiguration();
// Add support for X-Forwarded headers
httpConfig.addCustomizer( new ForwardedRequestCustomizer() );
// Create the http connector
HttpConnectionFactory http11 = new HttpConnectionFactory( httpConfig );
HTTP2ServerConnectionFactory h2c = new HTTP2CServerConnectionFactory(httpConfig);
ServerConnector httpConn = new ServerConnector(jetty,http11,h2c);
httpConn.setReuseAddress(false);
// Get port from config, environment variable, or default to 8090
int port=8090;
Config conf=getConfig();
if(conf!=null){
port=Config.SERVER_PORT.get(conf,8090);
}
// Check environment variable
String envPort=System.getenv("JABBA_SERVER_PORT");
if(envPort!=null && !envPort.isEmpty()){
try{
port=Integer.parseInt(envPort);
}catch(NumberFormatException e){
log().warn("Invalid JABBA_SERVER_PORT environment variable: {}, using default",envPort);
}
}
// Check system property
String sysPort=System.getProperty("jabba.server.port");
if(sysPort!=null && !sysPort.isEmpty()){
try{
port=Integer.parseInt(sysPort);
}catch(NumberFormatException e){
log().warn("Invalid jabba.server.port system property: {}, using default",sysPort);
}
}
httpConn.setPort(port);
log().info("Server configured to listen on port {}",port);
connectors=new Connector[] {httpConn};
return connectors;
}
/** implementation of jetty handler interface */
public Server getServer() {
return jetty;
}
public void setServer(Server arg0) {
jetty=arg0;
}
protected void setState(State s){
_state=s;
}
public boolean isFailed() {
return _state==State.FAILED;
}
public boolean isRunning() {
return _state==State.RUNNING;
}
public boolean isStarted() {
return _state==State.STARTED;
}
public boolean isStarting() {
return _state==State.STARTING;
}
public boolean isStopped() {
return _state==State.STOPPED;
}
public boolean isStopping() {
return _state==State.STOPPING;
}
public void start() throws Exception {
_state=State.STARTING;
jetty.setConnectors(getConnectors());
jetty.start();
_state=State.STARTED;
}
public void stop() throws Exception {
log().info("Stopping Jetty server...");
_state=State.STOPPING;
Connector[] connectors=jetty.getConnectors();
if(connectors==null || connectors.length==0){
_state=State.STOPPED;
log().info("No connectors to stop.");
return;
}
for(Connector c:connectors){
ServerConnector cc=(ServerConnector) c;
try{
int port = ((ServerConnector)c).getPort();
log().info("Closing connector on port {}...", port);
cc.stop();
cc.getConnectedEndPoints().forEach((endpoint)-> {
endpoint.close();
});
}finally{
cc.close();
}
}
_state=State.STOPPED;
log().info("Jetty server stopped.");
}
public void destroy() {
}
// Servlet interface methods
@Override
public void init(ServletConfig config) throws ServletException {
this.servletConfig = config;
}
@Override
public ServletConfig getServletConfig() {
return servletConfig;
}
@Override
public String getServletInfo() {
return "JettyApp - Jabba Framework Servlet";
}
/**
* Our servlet service implementation.
* In case of exception if we can locate /templates/error.hbs we use it else we re-throw.
*/
@Override
public void service(ServletRequest request, ServletResponse response) throws IOException, ServletException {
// Cast to HTTP versions (this servlet only handles HTTP)
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
final com.reliancy.jabba.servlet.ServletRequest req =
new com.reliancy.jabba.servlet.ServletRequest(httpRequest);
final com.reliancy.jabba.servlet.ServletResponse resp =
new com.reliancy.jabba.servlet.ServletResponse(req, httpResponse);
final CallSession ss=CallSession.getInstance();
// install executor just in case we need it, especially for async processing
ss.setExecutor(
jetty.getThreadPool() != null ?
jetty.getThreadPool() : java.util.concurrent.ForkJoinPool.commonPool()
);
req.setSession(ss);
try{
ss.beginFresh(null, req, resp);
process(req,resp);
}catch(Exception ioex){
try{
resp.getEncoder().writeError(ioex);
}catch(IOException e){
resp.setStatus(Response.HTTP_INTERNAL_ERROR);
}
}finally{
// Only mark as handled if not async (async will be completed later)
// Only end session if not async (async will end session when completing)
if(resp.isPromised()==false){
ss.end();
resp.complete();
}else{
resp.promiseLast((result, error) -> {
if(result instanceof Exception){
error=(Exception)result;
result=null;
}
if(error!=null){
try{
resp.getEncoder().writeError(error);
}catch(IOException e){
resp.setStatus(Response.HTTP_INTERNAL_ERROR);
}
}else if(result!=null){
// it should never get here we expect null unless we have an error
try{
resp.getEncoder().writeObject(result);
}catch(IOException e){
resp.setStatus(Response.HTTP_INTERNAL_ERROR);
}
}
resp.complete();
});
}
}
}
/** our own interface specific to jetty engine*/
public void begin(Config conf) throws Exception{
// step 1: configure application, might add processors, adjust config
configure(conf);
// step 2: install config then begin by signaling all middleware
super.begin(conf);
// step 3: create servlet context and mount this servlet
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
context.setContextPath("/");
context.addServlet(new ServletHolder(this), "/*");
// step 3a: initialize Jakarta WebSocket support
// IMPORTANT: must be called before context is started
JakartaWebSocketServletContainerInitializer.configure(context, (servletContext, serverContainer) -> {
// Optional: tune WebSocket defaults
// serverContainer.setDefaultMaxSessionIdleTimeout(Duration.ofMinutes(5));
// serverContainer.setDefaultMaxTextMessageBufferSize(64 * 1024);
log().info("WebSocket support initialized");
});
jetty.setHandler(context);
// step 4: set connectors and start jetty
try{
log().info("starting...");
start();
}catch(Exception ex){
setState(State.FAILED);
if(ex.getCause() instanceof java.net.BindException){
log().error("bind issue",ex);
Thread.sleep(3000);
}else throw ex;
}
}
public void work() throws InterruptedException{
setState(State.RUNNING);
log().info("Server is running. Press Ctrl-C to exit.");
if(jetty!=null) jetty.join();
}
public void end() throws Exception{
log().info("JettyApp cleanup starting...");
stop();
log().info("Cleaning up application processors...");
super.end();
log().info("Application cleanup complete.");
Log.cleanup();
System.gc();
}
/** Registers a shutdown hook to interrupt jetty.
* ctrl-c works but does not perform our shutdown sequence.
* this code interrupts jetty and then waits for app to finish.
*/
protected final void addShutdownHook(){
final JettyApp app=this;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if(app.isRunning()){
try {
app.jetty.stop();
synchronized(app){
app.wait(5000);
}
} catch (Exception e) {
app.log().error("shutdown cleanup:", e);
}
}
}));
}
/** called from begin just before jetty starts.
* this method is called before middleware is notified so we can add or adjust config.
* override to hook up your application.
* normally follows configuraion and does common sense steps.
* might install middleware (processors) which are later passed config.
*/
public void configure(Config conf) throws Exception{
App app=this;
// setup global search path - include workdir first, then get class and app.class
Class<?> cls=getClass();
if(cls!=JettyApp.class) Resources.appendSearch(0,JettyApp.class);
Resources.appendSearch(0,cls);
String work_dir=ArgsConfig.APP_WORKDIR.get(conf);
if(work_dir!=null) Resources.appendSearch(0,work_dir);
// install app session middleware
app.addAppSession();
// set security policy
SecurityPolicy secpol=new SecurityPolicy().setStore(new PlainSecurityStore());
app.setSecurityPolicy(secpol);
// install router
app.setRouter(new Router());
StatusMod ep=new StatusMod();
ep.publish(app);
// install file sever endpoint
FileServer fs=new FileServer("/static","/public");
fs.publish(app);
Menu top_menu=Menu.request(Menu.TOP);
top_menu.add(new MenuItem("home")).addSpacer().add(new MenuItem("login"));
top_menu.setTitle("Jabba3");
}
public static void main( String[] args ) throws Exception{
Config cnf=new ArgsConfig(args).load();
JettyApp app=new JettyApp();
app.run(cnf);
}
}
@@ -0,0 +1,173 @@
/*
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.servlet;
import com.reliancy.jabba.Request;
import com.reliancy.util.Handy;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
/**
* Servlet-based implementation of Request.
* Wraps HttpServletRequest to provide request functionality.
*/
public class ServletRequest extends Request {
protected final HttpServletRequest http_request;
protected AsyncContext asyncContext;
public ServletRequest(HttpServletRequest http_request) {
super();
this.http_request = http_request;
}
@Override
public void finish() {
if(finisher != null){
finisher.run();
finisher = null;
}
if(asyncContext != null){
asyncContext.complete();
asyncContext = null;
}
}
@Override
public boolean isAsync() {
return asyncContext != null;
}
/**
* Start asynchronous processing if supported.
* @return true if async is supported and started, false otherwise
*/
public boolean goAsync() {
if(asyncContext == null && http_request.isAsyncSupported()){
asyncContext = http_request.startAsync();
return true;
}
return false;
}
@Override
public String getPath() {
if(pathOverride!=null){
return pathOverride;
}else{
return http_request.getPathInfo();
}
}
@Override
public String getVerb() {
return http_request.getMethod();
}
@Override
public Object getParam(String pname, Object def){
if(pathParams.containsKey(pname)) {
Object val = pathParams.get(pname);
return val;
}
String[] vals=http_request.getParameterValues(pname);
if(vals!=null) {
Object result = vals.length==1?vals[0]:vals;
return result;
}
String hdr=getHeader(pname);
if(hdr!=null) return hdr;
String cook=getCookie(pname,null);
if(cook!=null) return cook;
return def;
}
@Override
public Request setParam(String pname, Object val){
if(pathParams.containsKey(pname)){
pathParams.put(pname,String.valueOf(Handy.nz(val,"")));
}else{
throw new IllegalArgumentException("invalid param name:"+pname);
}
return this;
}
@Override
public String getHeader(String key){
return http_request.getHeader(key);
}
@Override
public String getCookie(String name, String def){
Cookie[] all=http_request.getCookies();
if(all!=null) for(Cookie c:all){
if(name.equalsIgnoreCase(c.getName())) return c.getValue();
}
return def;
}
private static final String[] HEADERS4IP = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR" };
@Override
public String getRemoteAddress() {
for (String header : HEADERS4IP) {
String ip = getHeader(header);
if(ip==null || ip.length()==0 || "unknown".equalsIgnoreCase(ip)) continue;
return ip.contains(",")?ip.split(",",2)[0]:ip;
}
return http_request.getRemoteAddr();
}
@Override
public String getMount(){
String scheme = http_request.getScheme();
String host = http_request.getHeader("Host");
if(host==null || host.trim().isEmpty()){
String serverName = http_request.getServerName();
int serverPort = http_request.getServerPort();
host=serverName+":"+serverPort;
}
String resultPath = scheme + "://" + host;
String contextPath = http_request.getContextPath();
if(contextPath!=null){
resultPath+= contextPath;
}
return resultPath;
}
@Override
public String getProtocol(){
return http_request.getProtocol();
}
@Override
public String getScheme(){
return http_request.getScheme();
}
/**
* Get the underlying HttpServletRequest.
* @return the HttpServletRequest
*/
public HttpServletRequest getHttpServletRequest(){
return http_request;
}
}
@@ -0,0 +1,192 @@
/*
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.servlet;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Writer;
import com.reliancy.jabba.HTTP;
import com.reliancy.jabba.Request;
import com.reliancy.jabba.Response;
import com.reliancy.jabba.ResponseState;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
/**
* Servlet-based implementation of Response.
* Wraps HttpServletResponse to provide response functionality.
*/
public class ServletResponse extends Response {
protected final HttpServletResponse http_response;
public ServletResponse(Request request, HttpServletResponse http_response) {
super(request);
this.http_response = http_response;
}
@Override
public OutputStream getOutputStream() throws IOException{
if(http_response!=null) return http_response.getOutputStream();
return byte_response;
}
@Override
public Writer getWriter() throws IOException{
if(http_response!=null) return http_response.getWriter();
return char_response;
}
@Override
public void setContentType(String ctype) {
transitionTo(ResponseState.CONFIGURING);
content_type=ctype;
if(http_response!=null) http_response.setContentType(ctype);
}
@Override
public void setStatus(int status) {
transitionTo(ResponseState.CONFIGURING);
this.status=status;
if(http_response!=null) http_response.setStatus(status);
}
@Override
public String getHeader(String key){
for(HTTP.Header hdr:headers){
if(key.equalsIgnoreCase(hdr.key)) return hdr.value;
}
if(http_response!=null){
return http_response.getHeader(key);
}else{
return null;
}
}
@Override
public Response setHeader(String key, String val){
transitionTo(ResponseState.CONFIGURING);
if(!state.canConfigure()) {
throw new IllegalStateException("Cannot set header in state: " + state);
}
HTTP.Header sel=null;
for(HTTP.Header hdr:headers){
if(key.equalsIgnoreCase(hdr.key)){
sel=hdr;
break;
}
}
if(sel!=null) sel.value=val; else headers.add(new HTTP.Header(key,val));
if(http_response!=null) http_response.setHeader(key,val);
return this;
}
@Override
public Response setCookie(String key, String val, int maxAge, boolean secure){
return setCookie(key, val, maxAge, secure, true);
}
public Response setCookie(String key, String val, int maxAge, boolean secure, boolean httpOnly){
transitionTo(ResponseState.CONFIGURING);
if(!state.canConfigure()) {
throw new IllegalStateException("Cannot set cookie in state: " + state);
}
HTTP.Cookie sel=null;
for(HTTP.Cookie hdr:cookies){
if(key.equalsIgnoreCase(hdr.key)){
sel=hdr;
break;
}
}
if(sel!=null){
sel.value=val;
sel.maxAge=maxAge;
sel.secure=secure;
sel.httpOnly=httpOnly;
} else{
cookies.add(new HTTP.Cookie(key,val,maxAge,secure,httpOnly));
}
if(http_response!=null){
Cookie c=new Cookie(key,val);
c.setMaxAge(maxAge);
c.setSecure(secure);
c.setHttpOnly(httpOnly);
http_response.addCookie(c);
}
return this;
}
@Override
public boolean isCommitted(){
return state.isCommitted();
}
@Override
public void commit() {
if(isCommitted()) return;
if(getState() == ResponseState.CREATED || getState() == ResponseState.CONFIGURING){
if(getStatus()==null) setStatus(Response.HTTP_OK);
if(getContentType()==null) setContentType("text/plain;charset=utf-8");
transitionTo(ResponseState.CONFIGURING);
}
if(http_response!=null && getState() == ResponseState.CONFIGURING){
if(!http_response.isCommitted()){
try {
http_response.flushBuffer();
} catch (IOException e) {
throw new RuntimeException("Failed to commit response", e);
}
}
}
transitionTo(ResponseState.COMMITTED);
}
@Override
public boolean isCompleted(){
return state.isCompleted();
}
@Override
public void complete() {
try {
if(encoder!=null) encoder.flush();
if(http_response!=null) http_response.flushBuffer();
} catch (IOException e) {
throw new RuntimeException("Failed to complete response", e);
}
transitionTo(ResponseState.COMPLETED);
request.finish();
}
/**
* Get the underlying HttpServletResponse.
* @return the HttpServletResponse
*/
public HttpServletResponse getHttpServletResponse(){
return http_response;
}
/**
* Upgrade HTTP response to WebSocket.
*
* TODO: Implementation needed:
* 1. Get HttpServletRequest from request (cast to ServletRequest)
* 2. Get ServerContainer from ServletContext
* 3. Create ServerEndpointConfig programmatically
* 4. Call container.upgradeHttpToWebSocket(request, response, config, pathParams)
* 5. Create ServletWebSocketSession wrapping Jakarta WebSocket Session
* 6. Wire up message handlers to bridge Jakarta events to our callbacks
*
* See: jakarta.websocket.server.ServerContainer
* See: org.eclipse.jetty.ee10.websocket APIs
*/
@Override
public com.reliancy.jabba.WebSocketSession upgradeToWebSocket(String route, com.reliancy.jabba.Session appSession) throws IOException {
return ServletWebSocketSession.create(this,route, appSession);
}
}
@@ -0,0 +1,254 @@
/*
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.servlet;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.websocket.CloseReason;
import jakarta.websocket.DeploymentException;
import jakarta.websocket.Endpoint;
import jakarta.websocket.EndpointConfig;
import jakarta.websocket.MessageHandler;
import jakarta.websocket.server.ServerContainer;
import jakarta.websocket.server.ServerEndpointConfig;
import com.reliancy.jabba.WebSocketSession;
import com.reliancy.jabba.Session;
/**
* Servlet-based implementation of WebSocketSession.
* Wraps Jakarta WebSocket Session to provide WebSocket functionality.
*/
class ServletWebSocketSession extends WebSocketSession {
/** The underlying Jakarta WebSocket session. */
private jakarta.websocket.Session nativeSession;
public ServletWebSocketSession(String route, String remoteAddress, Session appSession) {
super(route, remoteAddress, appSession);
}
// ========== Native Session Accessor ==========
/**
* Get the underlying Jakarta WebSocket session.
* @return the native session, or null if not yet set
*/
public jakarta.websocket.Session getNativeSession() {
return nativeSession;
}
/**
* Set the underlying Jakarta WebSocket session.
* Called after the upgrade completes to wire up the native session.
* @param nativeSession the Jakarta WebSocket session
*/
public void setNativeSession(jakarta.websocket.Session nativeSession) {
this.nativeSession = nativeSession;
}
// ========== Abstract Method Implementations ==========
/**
* Send text message to this client.
*/
@Override
public void sendText(String message) throws IOException {
if (nativeSession == null || !nativeSession.isOpen()) {
throw new IOException("WebSocket session is not open");
}
nativeSession.getBasicRemote().sendText(message);
}
/**
* Send binary data to this client.
*/
@Override
public void sendBinary(byte[] data) throws IOException {
if (nativeSession == null || !nativeSession.isOpen()) {
throw new IOException("WebSocket session is not open");
}
nativeSession.getBasicRemote().sendBinary(ByteBuffer.wrap(data));
}
/**
* Close this WebSocket connection.
*/
@Override
public void close() throws IOException {
if (nativeSession != null && nativeSession.isOpen()) {
nativeSession.close();
}
}
/**
* Close with status code and reason.
*/
@Override
public void close(int code, String reason) throws IOException {
if (nativeSession != null && nativeSession.isOpen()) {
CloseReason.CloseCode closeCode = CloseReason.CloseCodes.getCloseCode(code);
nativeSession.close(new CloseReason(closeCode, reason));
}
}
/**
* Check if connection is open.
*/
@Override
public boolean isOpen() {
return nativeSession != null && nativeSession.isOpen();
}
// ========== Jakarta WebSocket Event Bridge ==========
// These methods would be called by the Jakarta WebSocket endpoint to dispatch events
/**
* Called by Jakarta WebSocket endpoint when text message received.
* Bridges to our callback system.
*/
void onNativeTextMessage(String message) {
handleText(message);
}
/**
* Called by Jakarta WebSocket endpoint when binary message received.
* Bridges to our callback system.
*/
void onNativeBinaryMessage(byte[] data) {
handleBinary(data);
}
/**
* Called by Jakarta WebSocket endpoint when error occurs.
* Bridges to our callback system.
*/
void onNativeError(Throwable error) {
handleError(error);
}
/**
* Called by Jakarta WebSocket endpoint when connection closes.
* Bridges to our callback system.
*/
void onNativeClose(int code, String reason) {
handleClose(code, reason);
}
/**
* Endpoint instance that bridges Jakarta WebSocket callbacks into ServletWebSocketSession.
* Must be public for Jakarta WebSocket to instantiate it.
*/
public static final class BridgingEndpoint extends Endpoint {
private final ServletWebSocketSession wrapper;
public BridgingEndpoint(ServletWebSocketSession wrapper) {
this.wrapper = wrapper;
}
@Override
public void onOpen(jakarta.websocket.Session session, EndpointConfig config) {
wrapper.setNativeSession(session);
// Text messages - use explicit type registration for Jakarta WebSocket API
session.addMessageHandler(String.class, new MessageHandler.Whole<String>() {
@Override
public void onMessage(String message) {
wrapper.onNativeTextMessage(message);
}
});
// Binary messages - use explicit type registration for Jakarta WebSocket API
session.addMessageHandler(ByteBuffer.class, new MessageHandler.Whole<ByteBuffer>() {
@Override
public void onMessage(ByteBuffer bb) {
byte[] data = new byte[bb.remaining()];
bb.get(data);
wrapper.onNativeBinaryMessage(data);
}
});
}
@Override
public void onClose(jakarta.websocket.Session session, CloseReason closeReason) {
wrapper.onNativeClose(closeReason.getCloseCode().getCode(), closeReason.getReasonPhrase());
}
@Override
public void onError(jakarta.websocket.Session session, Throwable thr) {
wrapper.onNativeError(thr);
}
}
/**
* Creates a new websocket session and upgrades the HTTP response to a websocket.
*
* TODO: Implementation needed:
* 1. Get ServerContainer from ServletContext
* 2. Create ServerEndpointConfig programmatically
* 3. Call upgrade on the container
* 4. Wire up Jakarta WebSocket events to our callbacks (handleText, handleBinary, etc.)
*
* @param response the response to upgrade to a websocket
* @param route the route to upgrade to a websocket
* @param appSession the app session to attach to the websocket session
* @return the new websocket session
*/
public static ServletWebSocketSession create(ServletResponse response, String route, Session appSession) {
ServletRequest request = (ServletRequest) response.getRequest();
ServletWebSocketSession session = new ServletWebSocketSession(route, request.getRemoteAddress(), appSession);
HttpServletRequest httpReq = request.getHttpServletRequest();
HttpServletResponse httpResp = response.getHttpServletResponse();
// TODO: Perform the actual WebSocket upgrade here
// ServerContainer container = (ServerContainer) httpReq.getServletContext()
// .getAttribute(ServerContainer.class.getName());
// ... configure endpoint and upgrade ...
// 1) Get ServerContainer from ServletContext using standard Jakarta WebSocket attribute
Object attr = httpReq.getServletContext()
.getAttribute(ServerContainer.class.getName());
if (!(attr instanceof ServerContainer serverContainer)) {
throw new IllegalStateException(
"No jakarta.websocket.server.ServerContainer found in ServletContext. " +
"Did you initialize Jakarta WebSocket in Jetty? " +
"Ensure JettyWebSocketServletContainerInitializer is configured in JettyApp."
);
}
// 2) Create endpoint instance and ServerEndpointConfig that returns THIS instance
BridgingEndpoint endpoint = new BridgingEndpoint(session);
ServerEndpointConfig.Configurator configurator = new ServerEndpointConfig.Configurator() {
@Override
public <T> T getEndpointInstance(Class<T> endpointClass) {
return endpointClass.cast(endpoint);
}
};
ServerEndpointConfig sec = ServerEndpointConfig.Builder
.create(BridgingEndpoint.class, route) // path is required by the builder
.configurator(configurator)
.build();
// 3) Upgrade (this performs the handshake + switches protocols). :contentReference[oaicite:4]{index=4}
// Path params: pass empty unless you need them.
Map<String, String> pathParams = Collections.emptyMap();
try {
serverContainer.upgradeHttpToWebSocket(httpReq, httpResp, sec, pathParams);
} catch (IOException | DeploymentException e) {
// Make sure your response is sane if upgrade fails (often the container already wrote).
throw new RuntimeException("WebSocket upgrade failed", e);
}
return session;
}
}
@@ -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);
}
}