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:
@@ -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());
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
+19
-19
@@ -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);
|
||||
|
||||
+366
-276
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user