Compare commits
3 Commits
v1.0.0
...
release/2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6efc097544 | ||
|
|
38603744e2 | ||
|
|
5f36b3d3e2 |
@@ -1,13 +1,13 @@
|
||||
arguments=
|
||||
auto.sync=false
|
||||
arguments=--init-script /home/amer/.config/Code/User/globalStorage/redhat.java/1.50.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle --init-script /home/amer/.config/Code/User/globalStorage/redhat.java/1.50.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/protobuf/init.gradle
|
||||
auto.sync=true
|
||||
build.scans.enabled=false
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
gradle.user.home=
|
||||
java.home=
|
||||
java.home=/home/amer/.vscode/extensions/redhat.java-1.50.0-linux-x64/jre/21.0.9-linux-x86_64
|
||||
jvm.arguments=
|
||||
offline.mode=false
|
||||
override.workspace.settings=false
|
||||
show.console.view=false
|
||||
show.executions.view=false
|
||||
override.workspace.settings=true
|
||||
show.console.view=true
|
||||
show.executions.view=true
|
||||
|
||||
@@ -42,9 +42,9 @@ org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
|
||||
org.eclipse.jdt.core.compiler.codegen.lambda.genericSignature=do not generate
|
||||
org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
|
||||
org.eclipse.jdt.core.compiler.codegen.shareCommonFinallyBlocks=disabled
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=11
|
||||
org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
|
||||
org.eclipse.jdt.core.compiler.compliance=1.8
|
||||
org.eclipse.jdt.core.compiler.compliance=11
|
||||
org.eclipse.jdt.core.compiler.debug.lineNumber=generate
|
||||
org.eclipse.jdt.core.compiler.debug.localVariable=generate
|
||||
org.eclipse.jdt.core.compiler.debug.sourceFile=generate
|
||||
@@ -169,7 +169,7 @@ org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
|
||||
org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
|
||||
org.eclipse.jdt.core.compiler.processAnnotations=disabled
|
||||
org.eclipse.jdt.core.compiler.release=disabled
|
||||
org.eclipse.jdt.core.compiler.source=1.8
|
||||
org.eclipse.jdt.core.compiler.source=11
|
||||
org.eclipse.jdt.core.compiler.storeAnnotations=disabled
|
||||
org.eclipse.jdt.core.compiler.taskCaseSensitive=enabled
|
||||
org.eclipse.jdt.core.compiler.taskPriorities=NORMAL,HIGH,NORMAL
|
||||
|
||||
+44
-8
@@ -9,7 +9,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.reliancy</groupId>
|
||||
<artifactId>jabba</artifactId>
|
||||
<version>0.1</version>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>The Apache License, Version 2.0</name>
|
||||
@@ -20,38 +20,74 @@
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-server</artifactId>
|
||||
<version>12.0.0.alpha1</version>
|
||||
<version>12.0.15</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.http2</groupId>
|
||||
<artifactId>jetty-http2-server</artifactId>
|
||||
<version>12.0.15</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-servlet</artifactId>
|
||||
<version>12.0.15</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10.websocket</groupId>
|
||||
<artifactId>jetty-ee10-websocket-jakarta-server</artifactId>
|
||||
<version>12.0.15</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<version>6.0.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>2.0.0-alpha0</version>
|
||||
<artifactId>slf4j-jdk14</artifactId>
|
||||
<version>2.0.16</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.jknack</groupId>
|
||||
<artifactId>handlebars</artifactId>
|
||||
<version>4.3.0</version>
|
||||
<version>4.4.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.1.214</version>
|
||||
<version>2.3.232</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.5.0</version>
|
||||
<version>42.7.4</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<version>5.1.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.13.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.websocket</groupId>
|
||||
<artifactId>jetty-websocket-jetty-client</artifactId>
|
||||
<version>12.0.15</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Jabba the easy going java web app plumber
|
||||
Jabba is a java library that gets its inspiration from Python Flask. It will expose all the elementary features needed for deveopment of web apps and microservices.
|
||||
Jabba is a java library that gets its inspiration from Python Flask. It will expose all the elementary features needed for development of web apps and microservices.
|
||||
|
||||
# How to Build Things
|
||||
|
||||
* running a build via: gradle jar
|
||||
* running a test via: gradle test
|
||||
* running a continouse server via: gradle --watch-fs -t runServer, then work on code (every save will rebuild and restart so you just refresh browser)
|
||||
* running a continuous server via: gradle --watch-fs -t runServer, then work on code (every save will rebuild and restart so you just refresh browser)
|
||||
|
||||
Jabba depends on following libraries:
|
||||
|
||||
@@ -40,12 +40,83 @@ This is a sonatype nexus repository manager we host here at reliancy.
|
||||
* ~~Error page~~
|
||||
* ~~Menu handling~~
|
||||
* ~~Database layer or serial/deserial system like SQL Alchemy~~
|
||||
* ~~Asynchronous request processing with CompletableFuture support~~
|
||||
* ~~WebSocket support for real-time bidirectional communication~~
|
||||
|
||||
With above things complete we ~~will~~ have a library that can be used for new webapps.
|
||||
Don't have a profile page yet (which goes into app templates) and the dbo layer is basic not like sql alchemy but mostly things are in place.
|
||||
|
||||
At this point we could use jabba to spawn new apps.
|
||||
|
||||
# Security Best Practices
|
||||
|
||||
When deploying Jabba applications in production, follow these security guidelines:
|
||||
|
||||
## Secret Key Management
|
||||
|
||||
**Critical:** Always set a strong, unique secret key for encryption. Never use the default or commit secrets to version control.
|
||||
|
||||
* Set `SECRET_KEY` in your configuration file, or
|
||||
* Set `JABBA_SECRET_KEY` environment variable, or
|
||||
* Set `jabba.secret.key` system property
|
||||
|
||||
The secret key should be:
|
||||
- At least 32 characters long
|
||||
- Randomly generated (use a secure random generator)
|
||||
- Unique per application instance
|
||||
- Stored securely (use a secrets manager in production)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
export JABBA_SECRET_KEY=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
## Cookie Security
|
||||
|
||||
Session cookies are automatically set with `HttpOnly` flag to prevent XSS attacks. The `Secure` flag is automatically set when requests are made over HTTPS.
|
||||
|
||||
## Input Validation
|
||||
|
||||
Jabba includes basic input validation to prevent DoS attacks:
|
||||
- String parameters are limited to 100,000 characters
|
||||
- Array parameters are limited to 1,000 elements
|
||||
|
||||
For additional validation, implement custom validation in your endpoint methods.
|
||||
|
||||
## Authentication
|
||||
|
||||
* Use strong password policies
|
||||
* Never log passwords or sensitive credentials
|
||||
* Use HTTPS in production to protect credentials in transit
|
||||
* Implement proper session timeout and expiration
|
||||
|
||||
## Configuration
|
||||
|
||||
* Validate all configuration values on startup
|
||||
* Use environment variables for sensitive configuration
|
||||
* Never commit `.env` files or configuration with secrets to version control
|
||||
* Use `.gitignore` to exclude sensitive files
|
||||
|
||||
## Database Security
|
||||
|
||||
* Use parameterized queries (Jabba uses PreparedStatement by default)
|
||||
* Never construct SQL queries by concatenating user input
|
||||
* Use connection pooling with appropriate limits
|
||||
* Restrict database user permissions to minimum required
|
||||
|
||||
## Error Handling
|
||||
|
||||
* In production, sanitize error messages to avoid information disclosure
|
||||
* Log detailed errors server-side, but return generic messages to clients
|
||||
* Use proper HTTP status codes
|
||||
|
||||
## Server Configuration
|
||||
|
||||
* Configure request size limits to prevent DoS attacks
|
||||
* Set appropriate timeouts
|
||||
* Use reverse proxy (nginx, Apache) in front of Jabba for additional security layers
|
||||
* Keep dependencies up to date
|
||||
|
||||
# Code Structure
|
||||
|
||||
There are 4 major modules all located under com.reliancy.
|
||||
@@ -55,7 +126,7 @@ They are:
|
||||
* rec - slot based object and array definition, akin to json, base of dbo and any data access object (DAO)
|
||||
* dbo - database access layer on top of rec
|
||||
* jabba - web application layer
|
||||
* util - ulity methods maximally independent
|
||||
* util - utility methods maximally independent
|
||||
|
||||
## util
|
||||
This module is a treasure trove of useful classes and methods. Most standalone methods are implemented in Handy class. One very useful class is the Tokenizer which starts out as a static method and is then wrapped by an iterator.
|
||||
@@ -88,16 +159,141 @@ Use slot based value access instead of by string names. The reason is that chang
|
||||
## dbo
|
||||
Here we define a very generic DAO interface. At the center is a Terminal which represents a data store and allows us to perform CRUD with some extra facility for complex queries. For database purposes we implement DBO or database object as a special instance of Rec interface. Finally to make this a useful module we provide SQLTerminal and helper classes to deal with Read, Create/Update, Delete actions using SQL language. Of course read action deals with querying.
|
||||
|
||||
Plese note one thing about SQL in particular. SQL and related RDBMs are nothing ground breaking or throughput busting. They are an old and messy protocol to access data. The most important point is do not treat SQL connections as an open file handle. Instead you connect, you CRUD, you disconnect (and if that sounds inefficient it is). If you forget this, as I did, and you build your entire app on the premise that you can reuse a connection by multiplexing commands down the pipe you will be in a world of hurt. The hurt does not manifest during development but once tens or tousands of sessions start working the same few connections.
|
||||
Please note one thing about SQL in particular. SQL and related RDBMS systems are nothing ground breaking or throughput busting. They are an old and messy protocol to access data. The most important point is do not treat SQL connections as an open file handle. Instead you connect, you CRUD, you disconnect (and if that sounds inefficient it is). If you forget this, as I did, and you build your entire app on the premise that you can reuse a connection by multiplexing commands down the pipe you will be in a world of hurt. The hurt does not manifest during development but once tens or thousands of sessions start working the same few connections.
|
||||
|
||||
In any case this module tries to be SQL agnostic while sharing nomenclature. If you stick with the interface your app will be too. In my decades old experience there is no way to allow just a little SQL in your code. So treat your database layer as if it was not a SQL database and maybe you will be able to later switch to something else. Otherwise it will SQL(squeel) till the judgement day.
|
||||
|
||||
## jabba
|
||||
|
||||
Finally the center of the library is the module that implements an HTTP servlet (jetty handler actually). Entire machinery is added to perform marshalling and unmarshaling of HTTP requests into and out of java methods. Along the way we also deal with sessions and security and errors and also server side templating. Ideally your app will be a set of REST endpoints that are used by ReactJS or similar front end GUIs but in case you like server-side templating it is available.
|
||||
Finally the center of the library is the module that implements an HTTP servlet (jetty handler actually). Entire machinery is added to perform marshalling and unmarshalling of HTTP requests into and out of java methods. Along the way we also deal with sessions and security and errors and also server side templating. Ideally your app will be a set of REST endpoints that are used by ReactJS or similar front end GUIs but in case you like server-side templating it is available.
|
||||
|
||||
You application can be any POJO, the entry point is JettyApp from there a request processor is installed which parses your class and discovers all the endpoints. We chain request processors landing on a MethodEndPoint. Middleware is injected, you guessed it, as a request processor in the middle of the chain. Examples include session management, security policy.
|
||||
|
||||
### Asynchronous Support
|
||||
|
||||
Jabba provides first-class support for asynchronous request processing, allowing your application to handle long-running operations efficiently without blocking server threads. Async support is automatically detected and requires no special configuration.
|
||||
|
||||
**Automatic Detection:**
|
||||
- Methods returning `CompletableFuture<T>` are automatically handled asynchronously
|
||||
- Methods annotated with `@Async` are executed in a thread pool
|
||||
- Regular synchronous methods continue to work as expected
|
||||
|
||||
**Benefits:**
|
||||
- Improved throughput for I/O-bound operations
|
||||
- Efficient handling of concurrent requests
|
||||
- Non-blocking execution for database queries, external API calls, and file operations
|
||||
- Seamless integration with Java's `CompletableFuture` API
|
||||
|
||||
**Example:**
|
||||
|
||||
```java
|
||||
// Async endpoint - automatically detected by return type
|
||||
@Routed(path="/users/{id}")
|
||||
public CompletableFuture<User> getUser(int id) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
// Long-running database query
|
||||
return database.findUserById(id);
|
||||
});
|
||||
}
|
||||
|
||||
// Async endpoint using @Async annotation
|
||||
@Routed(path="/report")
|
||||
@Async
|
||||
public Report generateReport(String month, int year) {
|
||||
// Heavy computation executed in thread pool
|
||||
return reportService.generate(month, year);
|
||||
}
|
||||
|
||||
// Regular synchronous endpoint - no changes needed
|
||||
@Routed(path="/ping")
|
||||
public String ping() {
|
||||
return "pong";
|
||||
}
|
||||
```
|
||||
|
||||
The async implementation handles proper resource cleanup, session management, and error propagation automatically. Both synchronous and asynchronous endpoints can coexist in the same application without any special routing or configuration.
|
||||
|
||||
### WebSocket Support
|
||||
|
||||
Jabba provides built-in support for WebSocket connections, enabling real-time bidirectional communication between clients and servers. WebSocket endpoints are treated as first-class citizens alongside HTTP endpoints, with automatic lifecycle management and session integration.
|
||||
|
||||
**Key Features:**
|
||||
- Declarative WebSocket endpoints using `@WebSocket` annotation
|
||||
- Automatic protocol upgrade from HTTP to WebSocket
|
||||
- Session management and authentication integration
|
||||
- Callback-based message handling (text and binary)
|
||||
- Built-in support for broadcasting to multiple clients
|
||||
- Seamless integration with application security policies
|
||||
|
||||
**Architecture:**
|
||||
- WebSocket endpoints work in conjunction with `@Routed` for path mapping
|
||||
- Full access to `AppSession` context for authenticated connections
|
||||
- Automatic session tracking with built-in registry for broadcasting
|
||||
- Clean separation between Jabba abstractions and underlying Jakarta WebSocket implementation
|
||||
|
||||
**Example:**
|
||||
|
||||
```java
|
||||
// Simple echo endpoint
|
||||
@Routed(path="/ws/echo")
|
||||
@WebSocket
|
||||
public void echoEndpoint(WebSocketSession session) {
|
||||
session.onText(message -> {
|
||||
try {
|
||||
session.sendText("Echo: " + message);
|
||||
} catch (IOException e) {
|
||||
log().error("Failed to send message", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Chat room with broadcasting
|
||||
@Routed(path="/ws/chat")
|
||||
@WebSocket
|
||||
public void chatEndpoint(WebSocketSession session) {
|
||||
String route = session.getRoute();
|
||||
|
||||
session.onText(message -> {
|
||||
// Broadcast to all clients on this route
|
||||
WebSocketSession.broadcast(route, "User says: " + message);
|
||||
});
|
||||
|
||||
session.onClose((code, reason) -> {
|
||||
WebSocketSession.broadcast(route, "User disconnected");
|
||||
});
|
||||
|
||||
// Welcome message
|
||||
try {
|
||||
session.sendText("Welcome to the chat room!");
|
||||
} catch (IOException e) {
|
||||
log().error("Failed to send welcome message", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated WebSocket with AppSession access
|
||||
@Routed(path="/ws/notifications")
|
||||
@WebSocket
|
||||
@Secured
|
||||
public void notificationEndpoint(WebSocketSession session) {
|
||||
Session appSession = session.getAppSession();
|
||||
String userId = appSession != null ? appSession.getId() : "anonymous";
|
||||
|
||||
session.onText(message -> {
|
||||
log().info("Received from user {}: {}", userId, message);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket connections can be tested using standard WebSocket clients. In JavaScript:
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8090/ws/echo');
|
||||
ws.onopen = () => ws.send('Hello Server');
|
||||
ws.onmessage = (event) => console.log('Received:', event.data);
|
||||
```
|
||||
|
||||
The WebSocket implementation ensures proper cleanup of resources, handles reconnection scenarios gracefully, and maintains compatibility with standard Jakarta WebSocket clients while providing a cleaner, callback-based API.
|
||||
|
||||
### Where to Start
|
||||
|
||||
As I said any POJO will do. You can look into JettyApp for an example. You can derive from JettyApp and then code something like this:
|
||||
@@ -124,7 +320,7 @@ public class App extends JettyApp{
|
||||
// it helps to support static file serving too
|
||||
FileServer fs=new FileServer("/static",work_dir+"/public");
|
||||
fs.exportRoutes(app.getRouter());
|
||||
// setup menu if you gonna use templates
|
||||
// setup menu if you are going to use templates
|
||||
Menu top_menu=Menu.request(Menu.TOP);
|
||||
top_menu.add(new MenuItem("home")).addSpacer().add(new MenuItem("login"));
|
||||
top_menu.setTitle("Jabba");
|
||||
@@ -196,12 +392,9 @@ public class App extends JettyApp{
|
||||
if(req.getVerb().equals("POST")){
|
||||
// here we need to process login and redirect
|
||||
try{
|
||||
System.out.println("Post login");
|
||||
String userid=(String)req.getParam("userid",null);
|
||||
String pwd=(String)req.getParam("password",null);
|
||||
AppSession ass=AppSession.getInstance();
|
||||
System.out.println("SS:"+ass);
|
||||
System.out.println("P:"+userid+"/"+pwd);
|
||||
SecurityPolicy secpol=ass.getApp().getSecurityPolicy();
|
||||
SecurityActor user=secpol.authenticate(userid, pwd);
|
||||
if(user==null) throw new NotAuthentic("invalid credentials");
|
||||
|
||||
+33
-14
@@ -12,28 +12,36 @@ apply from: 'extra.gradle'
|
||||
|
||||
project.buildDir = 'target'
|
||||
group='com.reliancy'
|
||||
version = '0.3-SNAPSHOT'
|
||||
version = '2.0.0-SNAPSHOT'
|
||||
application{
|
||||
mainClass=(group+'.'+name+'.JettyApp')
|
||||
}
|
||||
java{
|
||||
// make our library a bit more compatible (jetty forced 11 else it would have been 1.8)
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
// Updated to Java 21 for modern features and better performance
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile) {
|
||||
options.compilerArgs << '-parameters'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
def jettyVersion="11.0.18"
|
||||
def jettyVersion="12.0.15"
|
||||
implementation "org.eclipse.jetty:jetty-server:${jettyVersion}"
|
||||
implementation "org.eclipse.jetty.http2:http2-server:${jettyVersion}"
|
||||
implementation "org.slf4j:slf4j-jdk14:2.0.10"
|
||||
//implementation "org.slf4j:slf4j-simple:2.0.10"
|
||||
implementation "org.eclipse.jetty.http2:jetty-http2-server:${jettyVersion}"
|
||||
implementation "org.eclipse.jetty.ee10:jetty-ee10-servlet:${jettyVersion}"
|
||||
implementation "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server:${jettyVersion}"
|
||||
implementation "jakarta.servlet:jakarta.servlet-api:6.0.0"
|
||||
implementation "org.slf4j:slf4j-jdk14:2.0.16"
|
||||
//implementation "org.slf4j:slf4j-simple:2.0.16"
|
||||
//implementation 'com.hubspot.jinjava:jinjava:2.5.10'
|
||||
implementation 'com.github.jknack:handlebars:4.3.0'
|
||||
implementation 'com.h2database:h2:2.1.214'
|
||||
implementation 'org.postgresql:postgresql:42.5.0'
|
||||
implementation 'com.zaxxer:HikariCP:5.0.0'
|
||||
testImplementation "junit:junit:4.12"
|
||||
implementation 'com.github.jknack:handlebars:4.4.0'
|
||||
implementation 'com.h2database:h2:2.3.232'
|
||||
implementation 'org.postgresql:postgresql:42.7.4'
|
||||
implementation 'com.zaxxer:HikariCP:5.1.0'
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "org.eclipse.jetty.websocket:jetty-websocket-jetty-client:${jettyVersion}"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@@ -59,7 +67,7 @@ processResources {
|
||||
}
|
||||
repositories {
|
||||
//mavenLocal()
|
||||
//mavenCentral()
|
||||
mavenCentral()
|
||||
maven{
|
||||
url "https://repo.reliancy.com/repository/maven-hub"
|
||||
}
|
||||
@@ -153,3 +161,14 @@ eclipse{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Task to run DemoApp demonstration application
|
||||
task runDemo(type: JavaExec, dependsOn: testClasses) {
|
||||
group = 'application'
|
||||
description = 'Run the DemoApp demonstration application'
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'com.reliancy.jabba.DemoApp'
|
||||
args = project.hasProperty('appArgs') ? project.appArgs.split('\\s+') : []
|
||||
workingDir = projectDir
|
||||
standardInput = System.in
|
||||
}
|
||||
|
||||
+18
-3
@@ -92,16 +92,31 @@ class Server implements Runnable{
|
||||
info("stopping server");
|
||||
if(driver!=null){
|
||||
driver.interrupt();
|
||||
//driver.join();
|
||||
try{
|
||||
driver.join(5000); // Wait up to 5 seconds for graceful shutdown
|
||||
}catch(InterruptedException e){
|
||||
info("interrupted while waiting for driver to stop");
|
||||
}
|
||||
}
|
||||
// Clean up stale threads using proper interruption
|
||||
for(Thread th:Thread.getAllStackTraces().keySet()){
|
||||
if(th.getName().equalsIgnoreCase("executor")){
|
||||
info("cleaning up stale driver:"+th.toString())
|
||||
th.stop();
|
||||
th.interrupt();
|
||||
try{
|
||||
th.join(2000); // Wait up to 2 seconds
|
||||
}catch(InterruptedException e){
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
if(th.getName().equalsIgnoreCase("server.driver")){
|
||||
info("cleaning up stale driver:"+th.toString())
|
||||
th.stop();
|
||||
th.interrupt();
|
||||
try{
|
||||
th.join(2000); // Wait up to 2 seconds
|
||||
}catch(InterruptedException e){
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <h2>Instance Counting and Multi-Threading</h2>
|
||||
* 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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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,146 @@ 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 ex exception state
|
||||
* @return this encoder for chaining
|
||||
* @throws IOException if writing the error response fails
|
||||
*/
|
||||
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 +335,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 {
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ 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;
|
||||
package com.reliancy.jabba.decor;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
@@ -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);
|
||||
|
||||
+154
-64
@@ -5,20 +5,22 @@ 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;
|
||||
package com.reliancy.jabba.servlet;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.MessageFormat;
|
||||
import java.util.EventListener;
|
||||
|
||||
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.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;
|
||||
|
||||
@@ -26,14 +28,19 @@ 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 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;
|
||||
|
||||
@@ -46,7 +53,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
* JettyApp installs ForwardCustomizer to react to reverse proxy setups.
|
||||
*
|
||||
*/
|
||||
public class JettyApp extends App implements Handler{
|
||||
public class JettyApp extends App implements Servlet {
|
||||
enum State{
|
||||
STOPPED,
|
||||
FAILED,
|
||||
@@ -57,12 +64,12 @@ public class JettyApp extends App implements Handler{
|
||||
}
|
||||
protected Connector[] connectors;
|
||||
protected Server jetty;
|
||||
protected ServletConfig servletConfig;
|
||||
private volatile State _state;
|
||||
|
||||
public JettyApp() {
|
||||
super("JettyApp");
|
||||
jetty = new Server();
|
||||
jetty.setHandler(this);
|
||||
_state=State.STOPPED;
|
||||
this.addShutdownHook();
|
||||
}
|
||||
@@ -77,125 +84,205 @@ public class JettyApp extends App implements Handler{
|
||||
HTTP2ServerConnectionFactory h2c = new HTTP2CServerConnectionFactory(httpConfig);
|
||||
ServerConnector httpConn = new ServerConnector(jetty,http11,h2c);
|
||||
httpConn.setReuseAddress(false);
|
||||
httpConn.setPort(8090);
|
||||
// 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 */
|
||||
@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;
|
||||
_state=State.STARTING;
|
||||
jetty.setConnectors(getConnectors());
|
||||
jetty.start();
|
||||
_state=State.STARTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
_state=State.STOPPED;
|
||||
log().info("Stopping Jetty server...");
|
||||
_state=State.STOPPING;
|
||||
Connector[] connectors=jetty.getConnectors();
|
||||
if(connectors==null || connectors.length==0) return;
|
||||
if(connectors==null || connectors.length==0){
|
||||
_state=State.STOPPED;
|
||||
log().info("No connectors to stop.");
|
||||
return;
|
||||
}
|
||||
for(Connector c:connectors){
|
||||
ServerConnector cc=(ServerConnector) c;
|
||||
//System.out.println("stopping connecor:"+cc);
|
||||
try{
|
||||
int port = ((ServerConnector)c).getPort();
|
||||
log().info("Closing connector on port {}...", port);
|
||||
cc.stop();
|
||||
cc.getConnectedEndPoints().forEach((endpoint)-> {
|
||||
//System.out.println("closing endpoint:"+endpoint);
|
||||
endpoint.close();
|
||||
});
|
||||
}finally{
|
||||
cc.close();
|
||||
//System.out.println("closing connecor:"+cc.getState());
|
||||
}
|
||||
}
|
||||
_state=State.STOPPED;
|
||||
log().info("Jetty server stopped.");
|
||||
}
|
||||
|
||||
@Override
|
||||
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 implementation of a handle process.
|
||||
* In case of exception if we can locate /tempaltes/error.hbs we use it else we re-throw.
|
||||
* 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 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);
|
||||
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;
|
||||
|
||||
CallSession ss=CallSession.getInstance();
|
||||
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.begin(null, req, resp);
|
||||
ss.beginFresh(null, req, resp);
|
||||
process(req,resp);
|
||||
}catch(Exception ioex){
|
||||
processError(req,ioex,resp);
|
||||
try{
|
||||
resp.getEncoder().writeError(ioex);
|
||||
}catch(IOException e){
|
||||
resp.setStatus(Response.HTTP_INTERNAL_ERROR);
|
||||
}
|
||||
}finally{
|
||||
baseRequest.setHandled(true);
|
||||
ss.end();
|
||||
// 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 2: configure application, might add processors, adjust config
|
||||
// step 1: configure application, might add processors, adjust config
|
||||
configure(conf);
|
||||
// step 1: install config then begin by signaling all middleware
|
||||
// step 2: install config then begin by signaling all middleware
|
||||
super.begin(conf);
|
||||
// step 2: start jetty
|
||||
// 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...");
|
||||
jetty.start();
|
||||
start();
|
||||
}catch(Exception ex){
|
||||
setState(State.FAILED);
|
||||
if(ex.getCause() instanceof java.net.BindException){
|
||||
@@ -206,14 +293,19 @@ public class JettyApp extends App implements Handler{
|
||||
}
|
||||
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.cleanup(); // release logging in case we deferred
|
||||
System.gc(); // sweep memory just in caser
|
||||
log().info("Application cleanup complete.");
|
||||
Log.cleanup();
|
||||
System.gc();
|
||||
}
|
||||
/** Registers a shutdown hook to interrup jetty.
|
||||
/** 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.
|
||||
*/
|
||||
@@ -246,10 +338,6 @@ public class JettyApp extends App implements Handler{
|
||||
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
|
||||
@@ -274,3 +362,5 @@ public class JettyApp extends App implements Handler{
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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