You are viewing a plain text version of this content. The canonical link for it is here.
Posted to java-commits@axis.apache.org by ro...@apache.org on 2026/05/05 13:20:13 UTC
(axis-axis2-java-core) branch master updated (e9f1c07da4 -> db273fc655)
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
from e9f1c07da4 Merge pull request #1186 from apache/dependabot/maven/com.sun.xml.messaging.saaj-saaj-impl-3.0.5
new 4537881e39 AXIS2-6104 Error contracts, JPA/Hibernate schema generation, and pagination
new e1ec5178de AXIS2-6104 Add legacy status/errorMessage fields for backward compatibility
new 1042167cb8 AXIS2-6107 Fix broken anchor links, remove clustering docs, update Axis2/C URL
new 715f48b6cc AXIS2-6104 Sync message/errorMessage setters, restore Axis2/C anchor
new e2db339418 AXIS2-6104 Bring Moshi JSON-RPC receivers to feature parity with Gson
new 72b2ccb38b AXIS2-6104 Move shared RPC classes to library-agnostic package
new db273fc655 AXIS2-6104 Null-check getOpMethod, sanitize test data
The 7 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
AXIS2_MODERNIZATION_PLAN.md | 876 ++++++++++-----------
modules/{transport/udp => jpa-schema}/pom.xml | 169 ++--
.../axis2/jpa/schema/AnnotationIntrospector.java | 288 +++++++
.../apache/axis2/jpa/schema/EntitySchemaModel.java | 131 +++
.../axis2/jpa/schema/HbmXmlIntrospector.java | 376 +++++++++
.../axis2/jpa/schema/JpaSchemaGenerator.java | 182 +++++
.../axis2/jpa/schema/JpaSchemaGeneratorTest.java | 606 ++++++++++++++
.../src/test/resources/CompanyBO.hbm.xml | 70 ++
.../src/test/resources/DepartmentBO.hbm.xml | 213 +++++
modules/json/pom.xml | 5 +
.../gson/rpc/JsonInOnlyRPCMessageReceiver.java | 18 +-
.../json/gson/rpc/JsonRpcMessageReceiver.java | 57 +-
.../org/apache/axis2/json/gson/rpc/JsonUtils.java | 46 +-
.../moshi/rpc/JsonInOnlyRPCMessageReceiver.java | 35 +-
.../json/moshi/rpc/JsonRpcMessageReceiver.java | 59 +-
.../org/apache/axis2/json/moshi/rpc/JsonUtils.java | 51 ++
.../axis2/json/rpc/Axis2JsonErrorResponse.java | 211 +++++
.../axis2/json/rpc/JsonRpcFaultException.java | 107 +++
.../apache/axis2/json/rpc/PaginatedResponse.java | 196 +++++
.../apache/axis2/json/rpc/PaginationRequest.java | 121 +++
.../axis2/json/rpc/PaginatedResponseTest.java | 389 +++++++++
.../apache/axis2/openapi/OpenApiSpecGenerator.java | 429 ++++++++--
.../webservices/FinancialBenchmarkService.java | 56 +-
.../webservices/ScenarioAnalysisRequest.java | 2 +-
.../webservices/FinancialBenchmarkServiceTest.java | 181 +++--
src/site/xdoc/docs/contents.xml.vm | 2 +-
src/site/xdoc/docs/http2-integration-guide.xml | 2 +-
src/site/xdoc/docs/json-streaming-formatter.xml | 2 +-
src/site/xdoc/docs/toc.xml | 56 +-
src/site/xdoc/index.xml | 2 +-
30 files changed, 4186 insertions(+), 752 deletions(-)
copy modules/{transport/udp => jpa-schema}/pom.xml (55%)
create mode 100644 modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/AnnotationIntrospector.java
create mode 100644 modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/EntitySchemaModel.java
create mode 100644 modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/HbmXmlIntrospector.java
create mode 100644 modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/JpaSchemaGenerator.java
create mode 100644 modules/jpa-schema/src/test/java/org/apache/axis2/jpa/schema/JpaSchemaGeneratorTest.java
create mode 100644 modules/jpa-schema/src/test/resources/CompanyBO.hbm.xml
create mode 100644 modules/jpa-schema/src/test/resources/DepartmentBO.hbm.xml
create mode 100644 modules/json/src/org/apache/axis2/json/rpc/Axis2JsonErrorResponse.java
create mode 100644 modules/json/src/org/apache/axis2/json/rpc/JsonRpcFaultException.java
create mode 100644 modules/json/src/org/apache/axis2/json/rpc/PaginatedResponse.java
create mode 100644 modules/json/src/org/apache/axis2/json/rpc/PaginationRequest.java
create mode 100644 modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
(axis-axis2-java-core) 05/07: AXIS2-6104 Bring Moshi JSON-RPC receivers to feature parity with Gson
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit e2db339418c6c4b1922950a8cbe812d729bfedf3
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Tue May 5 03:06:36 2026 -1000
AXIS2-6104 Bring Moshi JSON-RPC receivers to feature parity with Gson
The Moshi rpc/ package (JsonRpcMessageReceiver, JsonInOnlyRPCMessageReceiver,
JsonUtils) now has identical error handling to the Gson equivalents:
- JsonRpcFaultException detection: unwraps InvocationTargetException,
checks for JsonRpcFaultException, sets HTTP status + structured
Axis2JsonErrorResponse on the outgoing MessageContext
- createSecureFault: new method in moshi JsonUtils with the same
CWE-209-safe opaque errorRef pattern (both 1-arg and 4-arg overloads)
- ITE unwrap: all catch blocks now log the root cause, not the wrapper
The shared classes (Axis2JsonErrorResponse, JsonRpcFaultException,
PaginatedResponse, PaginationRequest) live in gson.rpc and are
referenced by both implementations — they contain no Gson-specific
imports and are serialization-library-agnostic POJOs.
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
.../moshi/rpc/JsonInOnlyRPCMessageReceiver.java | 31 ++++++------
.../json/moshi/rpc/JsonRpcMessageReceiver.java | 55 +++++++++++++++-------
.../org/apache/axis2/json/moshi/rpc/JsonUtils.java | 51 ++++++++++++++++++++
3 files changed, 105 insertions(+), 32 deletions(-)
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java
index 4514906a8c..a46430f11e 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java
@@ -35,7 +35,7 @@ import java.lang.reflect.Method;
public class JsonInOnlyRPCMessageReceiver extends RPCInOnlyMessageReceiver {
private static final Log log = LogFactory.getLog(JsonInOnlyRPCMessageReceiver.class);
-
+
@Override
public void invokeBusinessLogic(MessageContext inMessage) throws AxisFault {
Object tempObj = inMessage.getProperty(JsonConstant.IS_JSON_STREAM);
@@ -70,7 +70,6 @@ public class JsonInOnlyRPCMessageReceiver extends RPCInOnlyMessageReceiver {
}
public void invokeService(JsonReader jsonReader, Object serviceObj, String operation_name, String enableJSONOnly) throws AxisFault {
- String msg;
Class implClass = serviceObj.getClass();
Method[] allMethods = implClass.getDeclaredMethods();
Method method = JsonUtils.getOpMethod(operation_name, allMethods);
@@ -78,22 +77,22 @@ public class JsonInOnlyRPCMessageReceiver extends RPCInOnlyMessageReceiver {
try {
int paramCount = paramClasses.length;
JsonUtils.invokeServiceClass(jsonReader, serviceObj, method, paramClasses, paramCount, enableJSONOnly);
- } catch (IllegalAccessException e) {
- msg = "Does not have access to " +
- "the definition of the specified class, field, method or constructor";
- log.error(msg, e);
- throw AxisFault.makeFault(e);
-
} catch (InvocationTargetException e) {
- msg = "Exception occurred while trying to invoke service method " +
- (method != null ? method.getName() : "null");
- log.error(msg, e);
- throw AxisFault.makeFault(e);
+ // Unwrap the ITE to log the real cause, not the reflection wrapper.
+ Throwable cause = e.getCause();
+ Exception rootCause;
+ if (cause instanceof Exception) {
+ rootCause = (Exception) cause;
+ } else if (cause != null) {
+ rootCause = new RuntimeException("Service threw non-Exception Throwable", cause);
+ } else {
+ rootCause = e;
+ }
+ throw JsonUtils.createSecureFault(rootCause, operation_name, false);
+ } catch (IllegalAccessException e) {
+ throw JsonUtils.createSecureFault(e, operation_name, false);
} catch (IOException e) {
- msg = "Exception occur while encording or " +
- "access to the input string at the JsonRpcMessageReceiver";
- log.error(msg, e);
- throw AxisFault.makeFault(e);
+ throw JsonUtils.createSecureFault(e, operation_name, true);
}
}
}
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
index 0c74df2010..2e6fed4a04 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
@@ -20,8 +20,11 @@ package org.apache.axis2.json.moshi.rpc;
import com.squareup.moshi.JsonReader;
import org.apache.axis2.AxisFault;
+import org.apache.axis2.Constants;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.description.AxisOperation;
+import org.apache.axis2.json.gson.rpc.Axis2JsonErrorResponse;
+import org.apache.axis2.json.gson.rpc.JsonRpcFaultException;
import org.apache.axis2.json.moshi.MoshiXMLStreamReader;
import org.apache.axis2.json.factory.JsonConstant;
import org.apache.axis2.rpc.receivers.RPCMessageReceiver;
@@ -35,7 +38,7 @@ import java.lang.reflect.Method;
public class JsonRpcMessageReceiver extends RPCMessageReceiver {
private static final Log log = LogFactory.getLog(RPCMessageReceiver.class);
-
+
@Override
public void invokeBusinessLogic(MessageContext inMessage, MessageContext outMessage) throws AxisFault {
Object tempObj = inMessage.getProperty(JsonConstant.IS_JSON_STREAM);
@@ -69,7 +72,6 @@ public class JsonRpcMessageReceiver extends RPCMessageReceiver {
}
public void invokeService(JsonReader jsonReader, Object serviceObj, String operation_name, MessageContext outMes, String enableJSONOnly) throws AxisFault {
- String msg;
Class implClass = serviceObj.getClass();
Method[] allMethods = implClass.getDeclaredMethods();
Method method = JsonUtils.getOpMethod(operation_name, allMethods);
@@ -82,22 +84,43 @@ public class JsonRpcMessageReceiver extends RPCMessageReceiver {
outMes.setProperty(JsonConstant.RETURN_OBJECT, retObj);
outMes.setProperty(JsonConstant.RETURN_TYPE, method.getReturnType());
- } catch (IllegalAccessException e) {
- msg = "Does not have access to " +
- "the definition of the specified class, field, method or constructor";
- log.error(msg, e);
- throw AxisFault.makeFault(e);
-
} catch (InvocationTargetException e) {
- msg = "Exception occurred while trying to invoke service method " +
- (method != null ? method.getName() : "null");
- log.error(msg, e);
- throw AxisFault.makeFault(e);
+ // Method.invoke() wraps any exception thrown by the service method
+ // in InvocationTargetException. Unwrap to inspect the real cause.
+ Throwable cause = e.getCause();
+
+ if (cause instanceof JsonRpcFaultException) {
+ // ── Structured error path ────────────────────────────────────
+ // Service explicitly signaled a typed error (e.g. validation 422).
+ // Set the HTTP status code and put the error response as RETURN_OBJECT
+ // so the Moshi formatter serializes it as a normal JSON body — NOT
+ // through the SOAP fault path.
+ JsonRpcFaultException fault = (JsonRpcFaultException) cause;
+ Axis2JsonErrorResponse errorResponse = fault.getErrorResponse();
+ log.warn("[errorRef=" + errorResponse.getErrorRef() + "] " +
+ errorResponse.getError() + " in operation '" + operation_name +
+ "': " + errorResponse.getMessage());
+ outMes.setProperty(Constants.HTTP_RESPONSE_STATE,
+ String.valueOf(fault.getHttpStatusCode()));
+ outMes.setProperty(JsonConstant.RETURN_OBJECT, errorResponse);
+ outMes.setProperty(JsonConstant.RETURN_TYPE, Axis2JsonErrorResponse.class);
+ } else {
+ // ── Opaque error path ────────────────────────────────────────
+ // Unexpected exception — create a CWE-209-safe AxisFault.
+ Exception rootCause;
+ if (cause instanceof Exception) {
+ rootCause = (Exception) cause;
+ } else if (cause != null) {
+ rootCause = new RuntimeException("Service threw non-Exception Throwable", cause);
+ } else {
+ rootCause = e;
+ }
+ throw JsonUtils.createSecureFault(rootCause, operation_name, false, outMes);
+ }
+ } catch (IllegalAccessException e) {
+ throw JsonUtils.createSecureFault(e, operation_name, false, outMes);
} catch (IOException e) {
- msg = "Exception occur while encording or " +
- "access to the input string at the JsonRpcMessageReceiver";
- log.error(msg, e);
- throw AxisFault.makeFault(e);
+ throw JsonUtils.createSecureFault(e, operation_name, true, outMes);
}
}
}
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java
index ca3d94c9c8..9db4673ece 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java
@@ -28,6 +28,12 @@ import com.squareup.moshi.JsonWriter;
import com.squareup.moshi.Moshi;
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter;
+import org.apache.axis2.AxisFault;
+import org.apache.axis2.Constants;
+import org.apache.axis2.context.MessageContext;
+import org.apache.axis2.json.factory.JsonConstant;
+import org.apache.axis2.json.gson.rpc.Axis2JsonErrorResponse;
+
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
@@ -36,6 +42,7 @@ import java.lang.reflect.Type;
import java.util.Date;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import jakarta.annotation.Nullable;
@@ -147,4 +154,48 @@ public class JsonUtils {
return null;
}
+ /**
+ * Build a secure {@link AxisFault} for any unexpected failure in the Moshi JSON-RPC
+ * message receivers. Mirrors the Gson equivalent in
+ * {@link org.apache.axis2.json.gson.rpc.JsonUtils#createSecureFault}.
+ *
+ * <p>The full context is logged server-side under an opaque correlation ID;
+ * only {@code "Bad Request [errorRef=<uuid>]"} or
+ * {@code "Internal Server Error [errorRef=<uuid>]"} is returned to the caller.
+ * This prevents information disclosure (CWE-209).
+ */
+ static AxisFault createSecureFault(Exception e, String operationName, boolean isParsingError) {
+ return createSecureFault(e, operationName, isParsingError, null);
+ }
+
+ /**
+ * Build a secure {@link AxisFault} and, when an outgoing {@link MessageContext}
+ * is available, also set the structured {@link Axis2JsonErrorResponse} as the
+ * return object with the appropriate HTTP status code.
+ */
+ static AxisFault createSecureFault(Exception e, String operationName, boolean isParsingError,
+ MessageContext outMessage) {
+ String errorRef = UUID.randomUUID().toString();
+ String opDisplay = operationName != null ? operationName : "<unknown>";
+ Axis2JsonErrorResponse errorResponse;
+ int httpStatus;
+ if (isParsingError) {
+ log.error("[errorRef=" + errorRef + "] Bad Request parsing JSON-RPC body " +
+ "for operation '" + opDisplay + "': " + e.getMessage(), e);
+ errorResponse = Axis2JsonErrorResponse.badRequest(errorRef);
+ httpStatus = 400;
+ } else {
+ log.error("[errorRef=" + errorRef + "] Internal error invoking operation '" +
+ opDisplay + "': " + e.getMessage(), e);
+ errorResponse = Axis2JsonErrorResponse.internalError(errorRef);
+ httpStatus = 500;
+ }
+ if (outMessage != null) {
+ outMessage.setProperty(Constants.HTTP_RESPONSE_STATE, String.valueOf(httpStatus));
+ outMessage.setProperty(JsonConstant.RETURN_OBJECT, errorResponse);
+ outMessage.setProperty(JsonConstant.RETURN_TYPE, Axis2JsonErrorResponse.class);
+ }
+ return new AxisFault(errorResponse.getMessage());
+ }
+
}
(axis-axis2-java-core) 03/07: AXIS2-6107 Fix broken anchor links, remove clustering docs, update Axis2/C URL
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit 1042167cb8d69b67e0cd21769f6eddedf9538598
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Tue May 5 02:58:58 2026 -1000
AXIS2-6107 Fix broken anchor links, remove clustering docs, update Axis2/C URL
Site anchor fixes:
- http2-integration-guide.xml: "HTTP/2" → "HTTP2" in heading to prevent
slash encoding (.2F) in generated anchor ID
- json-streaming-formatter.xml: "Problem:" → "Problem -" in section name
to prevent colon encoding (.3A) in generated anchor ID
- toc.xml: 14 broken # anchor links corrected to match actual generated
IDs (markdown .md files produce heavily-encoded anchors with .28/.29
for parens and .E2.80.94 for em-dashes)
- toc.xml: openapi-rest-userguide anchors updated (#Samples →
#Sample_Services_Overview, #Swagger_UI → #Interactive_Swagger_UI)
- toc.xml: spring-boot-starter #containers → #Container_matrix
Clustering removal (section 17 already removed from site):
- toc.xml: clustering-guide.html link removed
- contents.xml.vm: clustering-guide.html link removed
Axis2/C site URL:
- index.xml: http://axis.apache.org/axis2/c/ →
https://axis.apache.org/axis2/c/core/ (HTTPS + correct path)
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
src/site/xdoc/docs/contents.xml.vm | 2 +-
src/site/xdoc/docs/http2-integration-guide.xml | 2 +-
src/site/xdoc/docs/json-streaming-formatter.xml | 2 +-
src/site/xdoc/docs/toc.xml | 39 +++++++++++++------------
src/site/xdoc/index.xml | 2 +-
5 files changed, 24 insertions(+), 23 deletions(-)
diff --git a/src/site/xdoc/docs/contents.xml.vm b/src/site/xdoc/docs/contents.xml.vm
index 6c59b87679..66ab7c9db3 100644
--- a/src/site/xdoc/docs/contents.xml.vm
+++ b/src/site/xdoc/docs/contents.xml.vm
@@ -146,7 +146,7 @@ using an example</li>
<li><a href="ejb-provider.html">Guide to using EJB Provider in
Axis2</a> - This guide explains how to use an EJB provider in Axis2
using an example</li>
-<li><a href="clustering-guide.html">Clustering Guide</a> - This guide will show how you can leverage clustering support to improve Scalability, Failover and High Availability of your Web Services</li>
+<!-- Clustering removed: AXIS2-6107 -->
<li><a href="tomcat-http2-integration-guide.html">Apache Tomcat 11 + HTTP/2 Integration Guide</a> - Production-ready configuration guide for deploying Axis2 with HTTP/2 support on Apache Tomcat 11, featuring enhanced JSON processing and superior performance compared to WildFly</li>
<li><a href="wildfly-http2-integration-guide.html">WildFly + HTTP/2 Integration Guide</a> - Lessons learned from HTTP/2 integration attempts with WildFly, architectural insights, and the evolution to Enhanced Moshi H2 optimization</li>
diff --git a/src/site/xdoc/docs/http2-integration-guide.xml b/src/site/xdoc/docs/http2-integration-guide.xml
index 10d6673a11..ae62f203a8 100644
--- a/src/site/xdoc/docs/http2-integration-guide.xml
+++ b/src/site/xdoc/docs/http2-integration-guide.xml
@@ -70,7 +70,7 @@ The implementation is complete — see
<code>modules/transport-h2</code> and
<code>org.apache.axis2.json.streaming</code>.</em></p>
-<h2>How Axis2 HTTP/2 Differs From Other Frameworks</h2>
+<h2>How Axis2 HTTP2 Differs From Other Frameworks</h2>
<p>Most web service frameworks treat HTTP/2 as a transparent transport
upgrade — the servlet container (Tomcat, Undertow, Netty) negotiates
diff --git a/src/site/xdoc/docs/json-streaming-formatter.xml b/src/site/xdoc/docs/json-streaming-formatter.xml
index b4f5529f9d..0d860c79dc 100644
--- a/src/site/xdoc/docs/json-streaming-formatter.xml
+++ b/src/site/xdoc/docs/json-streaming-formatter.xml
@@ -38,7 +38,7 @@
their respective base formatters. No service code changes are required.</p>
</section>
- <section name="Problem: Large HTTP Responses and Reverse Proxies">
+ <section name="Problem - Large HTTP Responses and Reverse Proxies">
<p>When an Axis2 service returns a large JSON response (hundreds of MB),
the default formatter serializes the entire response into memory before
writing it to the wire. A reverse proxy (nginx, AWS ALB, or similar) may
diff --git a/src/site/xdoc/docs/toc.xml b/src/site/xdoc/docs/toc.xml
index fc273a288a..a0a10ee434 100644
--- a/src/site/xdoc/docs/toc.xml
+++ b/src/site/xdoc/docs/toc.xml
@@ -58,8 +58,8 @@ Guide</a></li>
<ul>
<li>7.1 <a href="openapi-rest-userguide.html#Introduction">Getting Started with OpenAPI</a></li>
<li>7.2 <a href="openapi-rest-userguide.html#OpenAPI_Integration_Features">OpenAPI Integration Features</a></li>
-<li>7.3 <a href="openapi-rest-userguide.html#Samples">Complete Sample Application</a></li>
-<li>7.4 <a href="openapi-rest-userguide.html#Swagger_UI">Interactive Swagger UI</a></li>
+<li>7.3 <a href="openapi-rest-userguide.html#Sample_Services_Overview">Complete Sample Application</a></li>
+<li>7.4 <a href="openapi-rest-userguide.html#Interactive_Swagger_UI">Interactive Swagger UI</a></li>
<li>7.5 <a href="openapi-rest-advanced-userguide.html">Advanced Enterprise OpenAPI Features</a></li>
<li>7.6 <a href="openapi-rest-advanced-http2-userguide.html">OpenAPI + HTTP/2 Performance Integration</a></li>
<li>7.7 <a href="openapi-jpa-schema.html">JPA/Hibernate Schema Generation</a>
@@ -91,11 +91,11 @@ Administrator's Guide</a></li>
<li>12a.6 <a href="spring-boot-starter.html#what_stays">What Stays in Your Application</a></li>
<li>12a.7 <a href="spring-boot-starter.html#openapi_mcp">OpenAPI and MCP Support</a></li>
<li>12a.8 <a href="spring-boot-starter.html#migration">Migration from Manual Configuration</a></li>
-<li>12a.9 <a href="spring-boot-starter.html#containers">Container Support</a></li>
+<li>12a.9 <a href="spring-boot-starter.html#Container_matrix">Container Support</a></li>
</ul>
</li>
<li><a href="modules.html">ModulesGuide</a></li>
-<li><a href="clustering-guide.html">Clustering Guide</a></li>
+<!-- Clustering removed: AXIS2-6107 -->
<li>ADB Data Binding
<ul>
<li>14.1 <a href="adb/adb-howto.html">Architecture</a></li>
@@ -157,8 +157,8 @@ Support</a></li>
<li><strong>23.3 <a href="openapi-rest-userguide.html">OpenAPI REST with JSON Support</a></strong>
<ul>
<li><a href="openapi-rest-userguide.html#OpenAPI_Integration_Features">Automatic OpenAPI 3.0.1 Generation</a></li>
- <li><a href="openapi-rest-userguide.html#Samples">Complete JSON REST API Examples</a></li>
- <li><a href="openapi-rest-userguide.html#Swagger_UI">Interactive API Testing with Swagger UI</a></li>
+ <li><a href="openapi-rest-userguide.html#Sample_Services_Overview">Complete JSON REST API Examples</a></li>
+ <li><a href="openapi-rest-userguide.html#Interactive_Swagger_UI">Interactive API Testing with Swagger UI</a></li>
<li><a href="openapi-rest-advanced-userguide.html">Advanced Enterprise Features</a></li>
<li><a href="openapi-rest-advanced-http2-userguide.html">HTTP/2 Performance Integration</a></li>
</ul>
@@ -173,35 +173,36 @@ Support</a></li>
<li><a href="json-rpc-mcp-guide.html#tested_features">Unit Test Feature Coverage</a></li>
<li><a href="json-rpc-mcp-guide.html#not_implemented">Not Implemented / Limitations</a></li>
<li><a href="json-streaming-formatter.html">Streaming Formatter for Large MCP Tool Responses</a></li>
- <li><a href="json-rpc-mcp-guide.html#data_api_relationship">Relationship to Data API Vision</a></li>
+ <!-- data_api_relationship anchor removed from guide; link to top-level page -->
+ <li><a href="json-rpc-mcp-guide.html">Relationship to Data API Vision</a></li>
<li><a href="json-rpc-mcp-guide.html#python_compat">Python MCP Compatibility Notes</a></li>
</ul>
</li>
<li><strong>23.5 <a href="mcp-architecture.html">MCP Architecture — Multi-Protocol Design</a></strong>
<ul>
- <li><a href="mcp-architecture.html#current-state-2026-04-09">Current State</a></li>
- <li><a href="mcp-architecture.html#track-a--openapi-driven-mcp-bridge">Track A: OpenAPI-Driven MCP Bridge</a></li>
- <li><a href="mcp-architecture.html#track-b--native-mcp-transport-axis2-transport-mcp">Track B: Native MCP Transport</a></li>
- <li><a href="mcp-architecture.html#security-architecture">Security Architecture (mTLS + X.509)</a></li>
- <li><a href="mcp-architecture.html#key-design-decisions">Key Design Decisions</a></li>
+ <li><a href="mcp-architecture.html#Current_State_.282026-04-09.29">Current State</a></li>
+ <li><a href="mcp-architecture.html#Track_A_remaining">Track A: OpenAPI-Driven MCP Bridge</a></li>
+ <li><a href="mcp-architecture.html#Track_B">Track B: Native MCP Transport</a></li>
+ <li><a href="mcp-architecture.html#Security_Architecture">Security Architecture (mTLS + X.509)</a></li>
+ <li><a href="mcp-architecture.html#Key_Design_Decisions">Key Design Decisions</a></li>
</ul>
</li>
<li><strong>23.6 <a href="json-streaming-formatter.html">Streaming JSON Message Formatter</a></strong>
<ul>
<li><a href="json-streaming-formatter.html#Overview">Overview</a></li>
- <li><a href="json-streaming-formatter.html#Problem_Large_HTTP_Responses_and_Reverse_Proxies">Problem: Large Responses and Reverse Proxies</a></li>
+ <li><a href="json-streaming-formatter.html#Problem_-_Large_HTTP_Responses_and_Reverse_Proxies">Problem: Large Responses and Reverse Proxies</a></li>
<li><a href="json-streaming-formatter.html#Configuration">Configuration (axis2.xml / services.xml)</a></li>
<li><a href="json-streaming-formatter.html#Services_That_Benefit">Services That Benefit (BigDataH2Service, FinancialBenchmarkService)</a></li>
- <li><a href="json-streaming-formatter.html#Axis2_C_Equivalent">Axis2/C Equivalent (ap_rflush)</a></li>
+ <li><a href="json-streaming-formatter.html">Axis2/C Equivalent (ap_rflush)</a></li>
</ul>
</li>
<li><strong>23.7 <a href="mcp-examples.html">MCP Examples — Financial Services Benchmarks</a></strong>
<ul>
- <li><a href="mcp-examples.html#live-examples-tested-on-wildfly-32-0-1-final-2026-04-08">Live Examples (WildFly 32)</a></li>
- <li><a href="mcp-examples.html#demo-1-stress-test--what-if-correlations-spike">Demo 1: Stress Test</a></li>
- <li><a href="mcp-examples.html#demo-2-pre-trade-risk--should-i-add-this-name">Demo 2: Pre-Trade Risk</a></li>
- <li><a href="mcp-examples.html#demo-3-convergence--how-much-compute-do-i-actually-need">Demo 3: Convergence</a></li>
- <li><a href="mcp-examples.html#full-performance-summary">Full Performance Summary (C vs Java)</a></li>
+ <li><a href="mcp-examples.html#Live_Examples_.28Tested_on_WildFly_32.0.1.Final.2C_2026-04-08.29">Live Examples (WildFly 32)</a></li>
+ <li><a href="mcp-examples.html#Demo_1.3A_Stress-test_.E2.80.94_.E2.80.9CWhat_if_correlations_spike.3F.E2.80.9D">Demo 1: Stress Test</a></li>
+ <li><a href="mcp-examples.html#Demo_2.3A_Pre-trade_risk_.E2.80.94_.E2.80.9CShould_I_add_this_name.3F.E2.80.9D">Demo 2: Pre-Trade Risk</a></li>
+ <li><a href="mcp-examples.html#Demo_3.3A_Convergence_.E2.80.94_.E2.80.9CHow_much_compute_do_I_actually_need.3F.E2.80.9D">Demo 3: Convergence</a></li>
+ <li><a href="mcp-examples.html#Full_Performance_Summary">Full Performance Summary (C vs Java)</a></li>
</ul>
</li>
<li><strong>23.8 <a href="json-pagination.html">Offset/Limit Pagination for JSON-RPC Services</a></strong>
diff --git a/src/site/xdoc/index.xml b/src/site/xdoc/index.xml
index 6872c887c8..3ce1b9d05c 100644
--- a/src/site/xdoc/index.xml
+++ b/src/site/xdoc/index.xml
@@ -46,7 +46,7 @@ native C services on edge devices, Android phones, and IoT gateways
where a JVM cannot run. The same MCP client connects to both Axis2/Java
(enterprise) and Axis2/C (embedded) with identical protocol and identical
results. See the
-<a href="http://axis.apache.org/axis2/c/">Axis2/C site</a> for details.</p>
+<a href="https://axis.apache.org/axis2/c/core/">Axis2/C site</a> for details.</p>
<p><strong>Apache Rampart 2.0.0 is ready for release</strong> and will ship
immediately after Axis2/Java 2.0.1, on which it depends. Rampart 2.0.0
brings WS-Security up to date with the Jakarta EE 9+ / Axis2 2.0.x line
(axis-axis2-java-core) 06/07: AXIS2-6104 Move shared RPC classes to library-agnostic package
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit 72b2ccb38be0e6d14267d01bee8d099e71d79fa7
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Tue May 5 03:13:13 2026 -1000
AXIS2-6104 Move shared RPC classes to library-agnostic package
Moved Axis2JsonErrorResponse, JsonRpcFaultException, PaginatedResponse,
and PaginationRequest from org.apache.axis2.json.gson.rpc to
org.apache.axis2.json.rpc — a new shared package that neither gson
nor moshi imports depend on.
Before: moshi.rpc imported gson.rpc classes, creating a misleading
cross-dependency between two implementations that should be independent.
After:
org.apache.axis2.json.rpc/ ← shared contract (no library imports)
org.apache.axis2.json.gson.rpc/ ← Gson-specific receivers + JsonUtils
org.apache.axis2.json.moshi.rpc/ ← Moshi-specific receivers + JsonUtils
Both gson and moshi receivers import from the shared rpc package.
The shared classes contain zero Gson or Moshi imports — they are
plain POJOs serializable by any JSON library.
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
.../src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java | 2 ++
modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java | 1 +
.../src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java | 4 ++--
modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java | 2 +-
.../org/apache/axis2/json/{gson => }/rpc/Axis2JsonErrorResponse.java | 2 +-
.../org/apache/axis2/json/{gson => }/rpc/JsonRpcFaultException.java | 2 +-
.../src/org/apache/axis2/json/{gson => }/rpc/PaginatedResponse.java | 2 +-
.../src/org/apache/axis2/json/{gson => }/rpc/PaginationRequest.java | 2 +-
.../org/apache/axis2/json/{gson => }/rpc/PaginatedResponseTest.java | 2 +-
.../userguide/springboot/webservices/FinancialBenchmarkService.java | 2 +-
.../springboot/webservices/FinancialBenchmarkServiceTest.java | 2 +-
11 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
index ac35a2a579..189692b41a 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
@@ -25,6 +25,8 @@ import org.apache.axis2.context.MessageContext;
import org.apache.axis2.description.AxisOperation;
import org.apache.axis2.json.gson.GsonXMLStreamReader;
import org.apache.axis2.json.factory.JsonConstant;
+import org.apache.axis2.json.rpc.Axis2JsonErrorResponse;
+import org.apache.axis2.json.rpc.JsonRpcFaultException;
import org.apache.axis2.rpc.receivers.RPCMessageReceiver;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java
index d15f3d2c0b..5e8e7d8579 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java
@@ -29,6 +29,7 @@ import org.apache.axis2.AxisFault;
import org.apache.axis2.Constants;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.json.factory.JsonConstant;
+import org.apache.axis2.json.rpc.Axis2JsonErrorResponse;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
index 2e6fed4a04..3726ca0147 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
@@ -23,8 +23,8 @@ import org.apache.axis2.AxisFault;
import org.apache.axis2.Constants;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.description.AxisOperation;
-import org.apache.axis2.json.gson.rpc.Axis2JsonErrorResponse;
-import org.apache.axis2.json.gson.rpc.JsonRpcFaultException;
+import org.apache.axis2.json.rpc.Axis2JsonErrorResponse;
+import org.apache.axis2.json.rpc.JsonRpcFaultException;
import org.apache.axis2.json.moshi.MoshiXMLStreamReader;
import org.apache.axis2.json.factory.JsonConstant;
import org.apache.axis2.rpc.receivers.RPCMessageReceiver;
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java
index 9db4673ece..d7a5cfdcf0 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonUtils.java
@@ -32,7 +32,7 @@ import org.apache.axis2.AxisFault;
import org.apache.axis2.Constants;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.json.factory.JsonConstant;
-import org.apache.axis2.json.gson.rpc.Axis2JsonErrorResponse;
+import org.apache.axis2.json.rpc.Axis2JsonErrorResponse;
import java.io.IOException;
import java.lang.annotation.Annotation;
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java b/modules/json/src/org/apache/axis2/json/rpc/Axis2JsonErrorResponse.java
similarity index 97%
rename from modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
rename to modules/json/src/org/apache/axis2/json/rpc/Axis2JsonErrorResponse.java
index 0762101dfc..12fe51a32d 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
+++ b/modules/json/src/org/apache/axis2/json/rpc/Axis2JsonErrorResponse.java
@@ -17,7 +17,7 @@
* under the License.
*/
-package org.apache.axis2.json.gson.rpc;
+package org.apache.axis2.json.rpc;
import java.time.Instant;
import java.util.UUID;
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcFaultException.java b/modules/json/src/org/apache/axis2/json/rpc/JsonRpcFaultException.java
similarity index 96%
rename from modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcFaultException.java
rename to modules/json/src/org/apache/axis2/json/rpc/JsonRpcFaultException.java
index 909ad1f3bc..fb7f504309 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcFaultException.java
+++ b/modules/json/src/org/apache/axis2/json/rpc/JsonRpcFaultException.java
@@ -17,7 +17,7 @@
* under the License.
*/
-package org.apache.axis2.json.gson.rpc;
+package org.apache.axis2.json.rpc;
/**
* Exception thrown by JSON-RPC service methods to signal a structured error
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java b/modules/json/src/org/apache/axis2/json/rpc/PaginatedResponse.java
similarity index 97%
rename from modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java
rename to modules/json/src/org/apache/axis2/json/rpc/PaginatedResponse.java
index 96b81ff5d6..a8491b1e65 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java
+++ b/modules/json/src/org/apache/axis2/json/rpc/PaginatedResponse.java
@@ -17,7 +17,7 @@
* under the License.
*/
-package org.apache.axis2.json.gson.rpc;
+package org.apache.axis2.json.rpc;
import java.util.Collections;
import java.util.List;
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java b/modules/json/src/org/apache/axis2/json/rpc/PaginationRequest.java
similarity index 95%
rename from modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java
rename to modules/json/src/org/apache/axis2/json/rpc/PaginationRequest.java
index 3a6e549c9f..7e684e252e 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java
+++ b/modules/json/src/org/apache/axis2/json/rpc/PaginationRequest.java
@@ -17,7 +17,7 @@
* under the License.
*/
-package org.apache.axis2.json.gson.rpc;
+package org.apache.axis2.json.rpc;
/**
* Pagination parameters that a client sends with a list query.
diff --git a/modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java b/modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
similarity index 97%
rename from modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java
rename to modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
index 81af02d1b3..6ec4b75dc5 100644
--- a/modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java
+++ b/modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
@@ -17,7 +17,7 @@
* under the License.
*/
-package org.apache.axis2.json.gson.rpc;
+package org.apache.axis2.json.rpc;
import org.junit.Test;
diff --git a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java
index 3d2f269cb4..4422b1eb69 100644
--- a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java
+++ b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java
@@ -18,7 +18,7 @@
*/
package userguide.springboot.webservices;
-import org.apache.axis2.json.gson.rpc.JsonRpcFaultException;
+import org.apache.axis2.json.rpc.JsonRpcFaultException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Component;
diff --git a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
index 78db0dc2db..5d96ff2539 100644
--- a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
+++ b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
@@ -18,7 +18,7 @@
*/
package userguide.springboot.webservices;
-import org.apache.axis2.json.gson.rpc.JsonRpcFaultException;
+import org.apache.axis2.json.rpc.JsonRpcFaultException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
(axis-axis2-java-core) 02/07: AXIS2-6104 Add legacy status/errorMessage fields for backward compatibility
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit e1ec5178de3bf7d6858408b2620cba754d138a32
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Mon May 4 14:39:38 2026 -1000
AXIS2-6104 Add legacy status/errorMessage fields for backward compatibility
Axis2JsonErrorResponse now includes "status":"FAILED" and "errorMessage"
alongside the new structured fields (error, errorRef, timestamp). Existing
clients that parse {"status":"FAILED","errorMessage":"..."} continue to
work unchanged. New clients use the structured error/errorRef/timestamp
fields for correlation and retry logic.
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
.../json/gson/rpc/Axis2JsonErrorResponse.java | 24 +++++++++++++++++++++-
.../webservices/FinancialBenchmarkServiceTest.java | 7 +++++++
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java b/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
index ff242a7eb5..a6f20ac15e 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
@@ -69,10 +69,24 @@ import java.util.UUID;
*/
public class Axis2JsonErrorResponse {
+ // ── Legacy fields (backward compatibility) ─────────────────────────────
+ // Existing clients check these two fields to detect errors. Removing them
+ // would break every caller that parses {"status":"FAILED","errorMessage":"..."}.
+ // Kept alongside the new structured fields so old and new clients both work
+ // from the same response body.
+
+ /** Always "FAILED" — matches the legacy PortfolioVarianceResponse.failed() contract. */
+ private final String status = "FAILED";
+
+ /** Human-readable error message (legacy field name). Same value as {@link #message}. */
+ private String errorMessage;
+
+ // ── New structured fields ────────────────────────────────────────────────
+
/** Error code — e.g. VALIDATION_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, INTERNAL_ERROR */
private String error;
- /** Human-readable error message */
+ /** Human-readable error message (new field name, same value as errorMessage) */
private String message;
/** Opaque correlation ID for server-side log lookup */
@@ -91,6 +105,7 @@ public class Axis2JsonErrorResponse {
String timestamp, Integer retryAfter) {
this.error = error;
this.message = message;
+ this.errorMessage = message; // legacy field — same value
this.errorRef = errorRef;
this.timestamp = timestamp;
this.retryAfter = retryAfter;
@@ -155,6 +170,13 @@ public class Axis2JsonErrorResponse {
// ── Getters / setters ────────────────────────────────────────────────────
+ /** Always "FAILED" — read-only for backward compatibility. */
+ public String getStatus() { return status; }
+
+ /** Legacy error message field. Same value as {@link #getMessage()}. */
+ public String getErrorMessage() { return errorMessage; }
+ public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
+
public String getError() { return error; }
public void setError(String error) { this.error = error; }
diff --git a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
index ddcac640d2..78db0dc2db 100644
--- a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
+++ b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
@@ -228,10 +228,17 @@ class FinancialBenchmarkServiceTest {
JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
() -> service.portfolioVariance(null));
assertEquals(422, ex.getHttpStatusCode());
+ // New structured fields
assertEquals("VALIDATION_ERROR", ex.getErrorResponse().getError());
assertNotNull(ex.getErrorResponse().getErrorRef(), "errorRef UUID must be present");
assertNotNull(ex.getErrorResponse().getTimestamp(), "timestamp must be present");
assertNull(ex.getErrorResponse().getRetryAfter(), "retryAfter should be null for 422");
+ // Legacy fields — backward compatible with clients that check
+ // {"status":"FAILED","errorMessage":"..."}
+ assertEquals("FAILED", ex.getErrorResponse().getStatus());
+ assertNotNull(ex.getErrorResponse().getErrorMessage());
+ assertEquals(ex.getErrorResponse().getMessage(), ex.getErrorResponse().getErrorMessage(),
+ "errorMessage must equal message for backward compatibility");
}
// ═══════════════════════════════════════════════════════════════════════
(axis-axis2-java-core) 04/07: AXIS2-6104 Sync message/errorMessage setters, restore Axis2/C anchor
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit 715f48b6cc6a2da01fc68280a835e01c579fde79
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Tue May 5 03:01:29 2026 -1000
AXIS2-6104 Sync message/errorMessage setters, restore Axis2/C anchor
- Axis2JsonErrorResponse: setMessage() and setErrorMessage() now update
both fields to maintain the invariant that legacy and new fields always
carry the same value
- toc.xml: restore Axis2/C Equivalent anchor using the correct encoded
form (#Axis2.2FC_Equivalent) instead of dropping to page root
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
.../org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java | 10 ++++++++--
src/site/xdoc/docs/toc.xml | 2 +-
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java b/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
index a6f20ac15e..0762101dfc 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
@@ -175,13 +175,19 @@ public class Axis2JsonErrorResponse {
/** Legacy error message field. Same value as {@link #getMessage()}. */
public String getErrorMessage() { return errorMessage; }
- public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
+ public void setErrorMessage(String errorMessage) {
+ this.errorMessage = errorMessage;
+ this.message = errorMessage; // keep new field in sync
+ }
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public String getMessage() { return message; }
- public void setMessage(String message) { this.message = message; }
+ public void setMessage(String message) {
+ this.message = message;
+ this.errorMessage = message; // keep legacy field in sync
+ }
public String getErrorRef() { return errorRef; }
public void setErrorRef(String errorRef) { this.errorRef = errorRef; }
diff --git a/src/site/xdoc/docs/toc.xml b/src/site/xdoc/docs/toc.xml
index a0a10ee434..336714f0dc 100644
--- a/src/site/xdoc/docs/toc.xml
+++ b/src/site/xdoc/docs/toc.xml
@@ -193,7 +193,7 @@ Support</a></li>
<li><a href="json-streaming-formatter.html#Problem_-_Large_HTTP_Responses_and_Reverse_Proxies">Problem: Large Responses and Reverse Proxies</a></li>
<li><a href="json-streaming-formatter.html#Configuration">Configuration (axis2.xml / services.xml)</a></li>
<li><a href="json-streaming-formatter.html#Services_That_Benefit">Services That Benefit (BigDataH2Service, FinancialBenchmarkService)</a></li>
- <li><a href="json-streaming-formatter.html">Axis2/C Equivalent (ap_rflush)</a></li>
+ <li><a href="json-streaming-formatter.html#Axis2.2FC_Equivalent">Axis2/C Equivalent (ap_rflush)</a></li>
</ul>
</li>
<li><strong>23.7 <a href="mcp-examples.html">MCP Examples — Financial Services Benchmarks</a></strong>
(axis-axis2-java-core) 07/07: AXIS2-6104 Null-check getOpMethod, sanitize test data
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit db273fc655d2a0d69dd7228931912e5c703b2e70
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Tue May 5 03:16:22 2026 -1000
AXIS2-6104 Null-check getOpMethod, sanitize test data
- All four message receivers (gson + moshi, InOut + InOnly) now check
for null return from getOpMethod() and throw a clear AxisFault with
the operation name and service class, instead of NPE
- Replace proprietary department names in pagination test with generic
"Department A" through "Department O"
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
.../apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java | 4 ++++
.../org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java | 4 ++++
.../apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java | 4 ++++
.../org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java | 4 ++++
.../test/org/apache/axis2/json/rpc/PaginatedResponseTest.java | 8 ++++----
5 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java
index 24f33b0323..a8ec434180 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java
@@ -73,6 +73,10 @@ public class JsonInOnlyRPCMessageReceiver extends RPCInOnlyMessageReceiver {
Class implClass = serviceObj.getClass();
Method[] allMethods = implClass.getDeclaredMethods();
Method method = JsonUtils.getOpMethod(operation_name, allMethods);
+ if (method == null) {
+ throw new AxisFault("Operation '" + operation_name
+ + "' not found on service class " + implClass.getName());
+ }
Class[] paramClasses = method.getParameterTypes();
try {
int paramCount = paramClasses.length;
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
index 189692b41a..f070124b82 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
@@ -75,6 +75,10 @@ public class JsonRpcMessageReceiver extends RPCMessageReceiver {
Class implClass = serviceObj.getClass();
Method[] allMethods = implClass.getDeclaredMethods();
Method method = JsonUtils.getOpMethod(operation_name, allMethods);
+ if (method == null) {
+ throw new AxisFault("Operation '" + operation_name
+ + "' not found on service class " + implClass.getName());
+ }
Class[] paramClasses = method.getParameterTypes();
try {
int paramCount = paramClasses.length;
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java
index a46430f11e..40102babdd 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonInOnlyRPCMessageReceiver.java
@@ -73,6 +73,10 @@ public class JsonInOnlyRPCMessageReceiver extends RPCInOnlyMessageReceiver {
Class implClass = serviceObj.getClass();
Method[] allMethods = implClass.getDeclaredMethods();
Method method = JsonUtils.getOpMethod(operation_name, allMethods);
+ if (method == null) {
+ throw new AxisFault("Operation '" + operation_name
+ + "' not found on service class " + implClass.getName());
+ }
Class[] paramClasses = method.getParameterTypes();
try {
int paramCount = paramClasses.length;
diff --git a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
index 3726ca0147..e6de2188f5 100644
--- a/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/moshi/rpc/JsonRpcMessageReceiver.java
@@ -75,6 +75,10 @@ public class JsonRpcMessageReceiver extends RPCMessageReceiver {
Class implClass = serviceObj.getClass();
Method[] allMethods = implClass.getDeclaredMethods();
Method method = JsonUtils.getOpMethod(operation_name, allMethods);
+ if (method == null) {
+ throw new AxisFault("Operation '" + operation_name
+ + "' not found on service class " + implClass.getName());
+ }
Class[] paramClasses = method.getParameterTypes();
try {
int paramCount = paramClasses.length;
diff --git a/modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java b/modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
index 6ec4b75dc5..6dc08ef38e 100644
--- a/modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
+++ b/modules/json/test/org/apache/axis2/json/rpc/PaginatedResponseTest.java
@@ -337,10 +337,10 @@ public class PaginatedResponseTest {
// Scenario: a lookup/reference table (e.g. list of 15 departments).
// No pagination needed — return the full set with hasMore=false.
List<String> departments = Arrays.asList(
- "Equity Long/Short", "Fixed Income", "Macro", "Quant",
- "Event Driven", "Multi-Strategy", "Credit", "Real Assets",
- "Private Equity", "Venture", "Commodities", "FX",
- "Emerging Markets", "Infrastructure", "Insurance-Linked");
+ "Department A", "Department B", "Department C", "Department D",
+ "Department E", "Department F", "Department G", "Department H",
+ "Department I", "Department J", "Department K", "Department L",
+ "Department M", "Department N", "Department O");
PaginatedResponse<String> resp = PaginatedResponse.unpaginated(departments);
(axis-axis2-java-core) 01/07: AXIS2-6104 Error contracts, JPA/Hibernate schema generation, and pagination
Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit 4537881e399b7a8df3c9a822632209514dbba352
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Mon May 4 14:34:21 2026 -1000
AXIS2-6104 Error contracts, JPA/Hibernate schema generation, and pagination
Phase A — Structured error contracts for JSON-RPC services:
- Axis2JsonErrorResponse: canonical error envelope with error code, message,
errorRef (UUID), timestamp, and optional retryAfter
- JsonRpcFaultException: checked exception carrying HTTP status code (422/429/503)
that services throw instead of returning HTTP 200 with in-band error status
- JsonRpcMessageReceiver: unwraps InvocationTargetException, detects
JsonRpcFaultException, sets HTTP_RESPONSE_STATE + serializes structured error
through the normal JSON formatter path (not SOAP fault path)
- JsonUtils.createSecureFault: new overload sets structured error on outgoing
MessageContext for parse errors (400) and internal errors (500)
- JsonInOnlyRPCMessageReceiver: same ITE unwrap fix for consistency
- FinancialBenchmarkService: all validation paths converted from
.failed() returns to throw JsonRpcFaultException.validationError()
- OpenApiSpecGenerator: ErrorResponse schema in components/schemas,
422/429/503 error responses on every operation, _meta.errorContract in
MCP catalog
Phase B — OpenAPI schema introspection:
- addComponents() generates request/response schemas from Java type
introspection via reflection on service method parameter/return POJOs
- resolveServiceClass() uses service.getClassLoader() for WAR visibility,
hoisted outside operation loop to avoid redundant reflection
- generateSchemaFromServiceClass() refactored with pre-resolved Class<?>
overload for addComponents() callers
Phase JPA — JPA/Hibernate schema generation (new module: axis2-jpa-schema):
- AnnotationIntrospector: reads @Entity, @Column, @Id, @GeneratedValue,
@ManyToOne, @OneToMany, @Version, @Transient, @Enumerated plus
configurable custom write-exclude annotations
- HbmXmlIntrospector: parses Hibernate 3.0 DTD .hbm.xml files with
air-gapped DTD resolution, handles <property>, <id>, <version>,
<many-to-one>, <set>/<bag>/<list>, <component>, nested <column>,
sql-type overrides
- EntitySchemaModel: unified mapping-style-agnostic representation
- JpaSchemaGenerator: produces read schema (all fields) and write schema
(excludes @GeneratedValue IDs, @Version, audit fields) with $ref for
relationships
- 29 tests including production-grade 70+ field HBM mapping
Phase PG — Offset/limit pagination:
- PaginatedResponse<T>: generic wrapper with data list + pagination
metadata (offset, limit, totalCount, hasMore)
- PaginationRequest: safe defaults (limit=50), maxLimit clamping (2000)
- 22 tests covering enterprise patterns: large entity lists, virtual
scrolling, soft-delete filtering, page calculation for React UIs
Documentation:
- AXIS2_MODERNIZATION_PLAN.md refreshed with completion status for all phases
- toc.xml: added JPA/Hibernate Schema Generation (7.7) and Pagination (23.8)
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
AXIS2_MODERNIZATION_PLAN.md | 876 ++++++++++-----------
modules/jpa-schema/pom.xml | 90 +++
.../axis2/jpa/schema/AnnotationIntrospector.java | 288 +++++++
.../apache/axis2/jpa/schema/EntitySchemaModel.java | 131 +++
.../axis2/jpa/schema/HbmXmlIntrospector.java | 376 +++++++++
.../axis2/jpa/schema/JpaSchemaGenerator.java | 182 +++++
.../axis2/jpa/schema/JpaSchemaGeneratorTest.java | 606 ++++++++++++++
.../src/test/resources/CompanyBO.hbm.xml | 70 ++
.../src/test/resources/DepartmentBO.hbm.xml | 213 +++++
modules/json/pom.xml | 5 +
.../json/gson/rpc/Axis2JsonErrorResponse.java | 183 +++++
.../gson/rpc/JsonInOnlyRPCMessageReceiver.java | 14 +-
.../axis2/json/gson/rpc/JsonRpcFaultException.java | 107 +++
.../json/gson/rpc/JsonRpcMessageReceiver.java | 51 +-
.../org/apache/axis2/json/gson/rpc/JsonUtils.java | 45 +-
.../axis2/json/gson/rpc/PaginatedResponse.java | 196 +++++
.../axis2/json/gson/rpc/PaginationRequest.java | 121 +++
.../axis2/json/gson/rpc/PaginatedResponseTest.java | 389 +++++++++
.../apache/axis2/openapi/OpenApiSpecGenerator.java | 429 ++++++++--
.../webservices/FinancialBenchmarkService.java | 56 +-
.../webservices/ScenarioAnalysisRequest.java | 2 +-
.../webservices/FinancialBenchmarkServiceTest.java | 174 ++--
src/site/xdoc/docs/toc.xml | 17 +
23 files changed, 4003 insertions(+), 618 deletions(-)
diff --git a/AXIS2_MODERNIZATION_PLAN.md b/AXIS2_MODERNIZATION_PLAN.md
index 58ebdc293b..1bf96ff04d 100644
--- a/AXIS2_MODERNIZATION_PLAN.md
+++ b/AXIS2_MODERNIZATION_PLAN.md
@@ -1,6 +1,7 @@
---
type: architecture
created: 2026-04-06
+updated: 2026-05-04
status: Active
---
@@ -16,586 +17,495 @@ entirely. No other Java framework can do all three from the same service deploym
- `springbootdemo-tomcat11` — working reference implementation (Spring Boot 3.x + Axis2 +
Tomcat 11 + Java 25)
- `axis2-openapi` module — OpenAPI spec served at `/openapi.json`, `/openapi.yaml`,
- `/swagger-ui`
+ `/swagger-ui`, `/openapi-mcp.json`
- `modules/transport-h2` — HTTP/2 transport module (proof of concept, tested)
+- `modules/spring-boot-starter` — functional Spring Boot autoconfiguration
- ThreadPool instance-field fix committed (`27860ddf9f`)
- Java 25 + Tomcat 11.0.20 full end-to-end test passing
---
-## Immediate Track — MCP inputSchema + Axis2/C + Apache httpd Demo
+## Completion Status
+
+| Phase | Status | Notes |
+|-------|--------|-------|
+| Immediate Track (B1-B3, C3, D1-D3) | **DONE** | MCP catalog, inputSchema, authScope, streaming, resources, error hardening |
+| Phase 1 — Spring Boot Starter | **DONE** | Full autoconfiguration: AxisServlet, AAR/MAR scanning, OpenAPI+MCP endpoints, SOAP/JSON mode. JWT security intentionally deferred (deployment-specific). |
+| Phase A — Error Contracts | **DONE** | Axis2JsonErrorResponse, JsonRpcFaultException, 422/429/503 HTTP status codes, OpenAPI ErrorResponse schema, MCP _meta.errorContract |
+| Phase B — MCP Schema Completion | **DONE** | Java type introspection for request/response schemas in both OpenAPI and MCP catalog. mcpAuthScope, mcpStreaming parameters. |
+| Phase 2 — OpenAPI Annotation Bridge | PARTIAL | POJO introspection done. springdoc @Operation/@Parameter annotation processing not yet started. |
+| Phase 3 — REST Transport | NOT STARTED | Dual-protocol JSON-RPC + REST from same service |
+| Phase 4 — MCP Bridge | NOT STARTED | OpenAPI-driven MCP wrapper |
+| Phase 5 — HTTP/2 Publication | NOT STARTED | transport-h2 graduation to supported module |
+| Phase 6 — Native MCP Transport | DEFERRED | Bridge approach sufficient; prove MCP catalog adoption first |
+| Phase 7 — Community | NOT STARTED | Blog post, migration guide |
+| **NEW** Phase JPA — JPA/Hibernate Schema Generation | **DONE** | See below |
+| **NEW** Phase PG — Offset/Limit Pagination | **DONE** | See below |
+| **NEW** Phase TX — Transaction Demarcation Module | PLANNED | See below |
-**Goal**: Complete the MCP catalog to production quality, port the catalog handler to
-Axis2/C, and run a live demo on Apache httpd with mod_axis2. This track runs ahead of
-Phases 1–6 because it validates the MCP story end-to-end on real hardware.
+---
-### Step B1 — `mcpInputSchema` static parameter support (Java + C)
+## NEW: Phase JPA — JPA/Hibernate Schema Generation (`axis2-jpa-schema`)
-**Problem**: Every tool in `/openapi-mcp.json` emits `"inputSchema": {}`. Claude has to
-guess parameters. This kills usability for financial benchmark tools with 6+ fields.
-
-**Approach (dual strategy)**:
-
-1. **Option 1 — Static declaration in services.xml** (ships first, zero risk):
- Each `<operation>` carries a `mcpInputSchema` parameter whose value is a literal
- JSON Schema string. `OpenApiSpecGenerator.generateMcpCatalogJson()` reads it with
- `getMcpStringParam()` and embeds it verbatim, parsing with Jackson to validate.
- Falls back to `{}` on parse failure with a WARN log.
-
- ```xml
- <operation name="portfolioVariance">
- <parameter name="mcpInputSchema">{
- "type": "object",
- "required": ["n_assets", "weights", "covariance_matrix"],
- "properties": {
- "n_assets": {"type": "integer", "minimum": 2, "maximum": 2000},
- "weights": {"type": "array", "items": {"type": "number"}},
- "covariance_matrix": {"type": "array", "items": {"type": "number"}},
- "request_id": {"type": "string"}
- }
- }</parameter>
- </operation>
- ```
-
-2. **Option 3 — Build-time code generation from C headers** (ships second):
- A Python script (`tools/gen_mcp_schema.py`) reads Axis2/C service header files,
- maps C struct fields to JSON Schema types, and writes `mcpInputSchema` parameters
- directly back into `services.xml`. The C type mapping table:
-
- | C type | JSON Schema type |
- |--------|-----------------|
- | `int`, `long`, `axis2_int32_t` | `"integer"` |
- | `double`, `float` | `"number"` |
- | `axis2_char_t *`, `char *` | `"string"` |
- | `axis2_bool_t` | `"boolean"` |
- | pointer-to-struct | `"object"` |
- | array pointer + count field | `"array"` |
-
- The script detects `_request_t` structs, infers which fields are required vs
- optional (required = no default value set in initialiser), and outputs a
- standards-compliant JSON Schema. Services.xml is updated in-place.
-
- Run: `python3 tools/gen_mcp_schema.py --header financial_benchmark_service.h \
- --services services.xml`
-
-**Java implementation**: `OpenApiSpecGenerator.generateMcpCatalogJson()` — check
-`mcpInputSchema` param before falling back to empty schema. Single method change.
-
-**Tests**: `McpCatalogGeneratorTest` — add tests for schema embedding, invalid JSON
-graceful fallback, and missing param fallback.
+**Goal**: Auto-generate OpenAPI `components/schemas` from JPA-annotated entity classes
+AND Hibernate XML mapping files (`.hbm.xml`). This makes Axis2 the first framework that
+can serve OpenAPI schemas for any Hibernate project regardless of mapping style.
-### Step B2 — `mcpAuthScope` per-operation parameter
+**Bang: 8/10 | Effort: days**
-Operation-level auth scope string embedded in catalog for MCP clients that support
-scope-based auth (e.g. `"mcpAuthScope": "read:portfolio"`). Reads via
-`getMcpStringParam()`. Omitted from tool node when absent.
+### Why both annotation AND hbm.xml support
-### Step B3 — `mcpStreaming` hint
+The original pickup doc rejected "OpenAPI from hbm.xml" as too narrow. That was correct
+for hbm.xml alone — but the real opportunity is broader:
-Boolean `mcpStreaming` parameter marks operations that can stream chunked responses
-(e.g. large Monte Carlo results). Adds `"x-streaming": true` to the tool node.
-Reads via `getMcpBoolParam()`.
+- **JPA annotations** (`@Entity`, `@Column`, `@ManyToOne`) — the standard for new
+ projects and the pattern used by typical financial applications (dozens of entities,
+ Spring Data JPA repositories, Jakarta Persistence)
+- **hbm.xml** — still in production at many organizations (legacy mapping
+ files, Hibernate 3.0 DTD, backtick-quoted SQL Server columns). These projects won't
+ rewrite to annotations just for OpenAPI support.
-### Step C3 — MCP Resources endpoint
+Supporting both from one module covers ~95% of Hibernate deployments.
-New servlet path `GET /mcp-resources` returns a JSON array of `resource://` URIs:
+### Design
-```json
-{
- "resources": [
- {"uri": "resource://axis2/openapi", "name": "OpenAPI Spec", "mimeType": "application/json"},
- {"uri": "resource://axis2/field-catalog", "name": "Field Catalog", "mimeType": "application/json"}
- ]
-}
+Two metadata extraction strategies, one unified schema output:
+
+```
+┌──────────────────────┐ ┌──────────────────────┐
+│ JPA Annotation │ │ HBM.XML Parser │
+│ Introspector │ │ (DOM/SAX) │
+│ │ │ │
+│ @Entity │ │ <class name="..."> │
+│ @Column │ │ <property> │
+│ @ManyToOne │ │ <many-to-one> │
+│ @Id/@GeneratedValue │ │ <id>/<generator> │
+│ @Transient │ │ <version> │
+└──────────┬───────────┘ └──────────┬────────────┘
+ │ │
+ ▼ ▼
+ ┌───────────────────────────────────────┐
+ │ EntitySchemaModel (unified) │
+ │ - entityName, tableName │
+ │ - fields: name, type, nullable, │
+ │ readOnly, relationship │
+ │ - idFields, versionField │
+ │ - ignoredFields (audit/system) │
+ └──────────────────┬────────────────────┘
+ │
+ ▼
+ ┌───────────────────────────────────────┐
+ │ OpenAPI Schema Generator │
+ │ - Read schema (all fields) │
+ │ - Write schema (excludes @Id with │
+ │ @GeneratedValue, @IgnoreChanges, │
+ │ version fields) │
+ │ - $ref for relationships │
+ └───────────────────────────────────────┘
```
-Individual resource content served at `GET /mcp-resource?uri=resource://axis2/openapi`.
-Wired in `OpenApiServlet` as a new path case.
+### JPA annotation mapping
+
+| JPA Annotation | Schema Effect |
+|----------------|---------------|
+| `@Entity` | Top-level schema object |
+| `@Table(name=...)` | Schema title / x-table-name |
+| `@Column(nullable=false)` | Added to `required` array |
+| `@Column(length=255)` | `maxLength: 255` |
+| `@Id` | Marked in schema description |
+| `@Id` + `@GeneratedValue` | `readOnly: true` in write schema |
+| `@ManyToOne` / `@OneToOne` | `$ref` to related entity schema |
+| `@OneToMany` / `@ManyToMany` | `type: array`, `items: {$ref: ...}` |
+| `@Transient` | Excluded from schema |
+| `@Version` | Excluded from write schema |
+| `@Enumerated` | `type: string`, `enum: [...]` |
+| `BigDecimal` (ID context) | `type: integer` |
+| `Double` / `Float` | `type: number` |
+| `Byte` (boolean context) | `type: boolean` |
+| `Timestamp` / `Date` | `type: string, format: date-time` |
+
+### HBM.XML mapping
+
+| HBM Element/Attribute | Schema Effect |
+|----------------------|---------------|
+| `<class name="..." table="...">` | Top-level schema object |
+| `<property not-null="true">` | Added to `required` array |
+| `<property type="java.lang.String">` | `type: string` |
+| `<property type="java.lang.Long">` | `type: integer` |
+| `<property type="java.lang.Boolean">` | `type: boolean` |
+| `<property type="timestamp">` | `type: string, format: date-time` |
+| `<id>` + `<generator>` | `readOnly: true` in write schema |
+| `<version>` | Excluded from write schema |
+| `<many-to-one class="...">` | `$ref` to related entity schema |
+| `<set>` / `<bag>` with `<one-to-many>` | `type: array`, `items: {$ref: ...}` |
+| `<component>` | Inline object properties (flattened) |
+| Column with `sql-type="nvarchar(max)"` | `type: string` (no maxLength) |
+
+### Custom annotation support
+
+Projects may use custom annotations that carry business semantics:
+
+| Custom Annotation | Schema Effect |
+|-------------------|---------------|
+| `@IgnoreChanges` | Excluded from write schema (audit fields) |
+| `@IncludeInUpdate` | Included in write schema (explicitly) |
+
+The module accepts a configurable list of "exclude from write" annotation class names
+so any project can declare their own audit-field markers.
+
+### Integration with OpenApiSpecGenerator
+
+Plugs into the existing `addComponents()` method. When `axis2-jpa-schema` is on the
+classpath and a service's parameter or return type is an `@Entity` class (or has a
+companion `.hbm.xml`), the JPA introspector generates the schema instead of the
+generic POJO introspector.
+
+### Key files
+
+- `modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/JpaSchemaGenerator.java`
+- `modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/EntitySchemaModel.java`
+- `modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/AnnotationIntrospector.java`
+- `modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/HbmXmlIntrospector.java`
+
+### Dependency
+
+None — optional module. Works standalone or with the Spring Boot starter.
---
-### Step D1 — Axis2/C MCP catalog handler
+## NEW: Phase TX — Transaction Demarcation Module (`axis2-tx`)
-New file: `modules/mcp/mcp_catalog_handler.c`
+**Goal**: A `.mar` module that wraps service invocations in JTA transactions. Deploy the
+module, set `<parameter name="transactional">true</parameter>` in services.xml, and
+every operation on that service gets automatic begin/commit/rollback.
-Walks `axis2_conf_t` service map at request time — same traversal as Java's
-`axisConfig.getServices()`. Emits the identical JSON catalog format. Key functions:
+**Bang: 7/10 | Effort: days**
-```c
-// Entry point registered on GET /_mcp/openapi-mcp.json
-axis2_status_t mcp_catalog_handler_invoke(
- axis2_handler_t *handler,
- const axutil_env_t *env,
- struct axis2_msg_ctx *msg_ctx);
+### How it works
-// Reads axis2_op_t parameter, falls back to axis2_svc_t parameter
-static const axis2_char_t *get_mcp_param(
- axis2_op_t *op, axis2_svc_t *svc,
- const axutil_env_t *env,
- const axis2_char_t *param_name,
- const axis2_char_t *default_val);
+Axis2 already has the plumbing:
+- `Axis2UserTransaction` in `modules/kernel` wraps JTA `UserTransaction`
+- `TransactionConfiguration` provides JNDI-based transaction manager lookup
+- `AbstractMessageReceiver.receive()` sets `SET_ROLLBACK_ONLY` on fault
+- `AbstractTemplatedHandler` provides `shouldInvoke()` / `doInvoke()` separation
+- Handler `flowComplete()` is called in reverse order after processing (for cleanup)
+
+The module injects a `TransactionHandler` into the `OperationInPhase`:
+
+```
+InFlow phases:
+ Transport → Addressing → Security → PreDispatch → Dispatch
+ → OperationInPhase:
+ [TransactionHandler.invoke()] ← BEGIN transaction
+ [other handlers]
+ [MessageReceiver.receive()] ← service method runs
+ → OperationOutPhase
+
+OutFlow / flowComplete:
+ [TransactionHandler.flowComplete()] ← COMMIT or ROLLBACK
```
-Parameter reading uses `axis2_op_get_param()` / `axis2_svc_get_param()` — the same
-two-level lookup as Java. `mcpDescription`, `mcpReadOnly`, `mcpDestructive`,
-`mcpIdempotent`, `mcpInputSchema` all supported.
+### module.xml
+
+```xml
+<module name="axis2-tx" class="org.apache.axis2.tx.TransactionModule">
+ <InFlow>
+ <handler name="TransactionHandler"
+ class="org.apache.axis2.tx.TransactionHandler">
+ <order phase="OperationInPhase" phaseFirst="true"/>
+ </handler>
+ </InFlow>
+</module>
+```
-JSON output built with `json_object_new_object()` (json-c) — no string concatenation.
+### TransactionHandler behavior
-### Step D2 — Axis2/C correlation ID error hardening
+- `shouldInvoke()`: checks service-level `transactional` parameter
+- `doInvoke()`: begins JTA transaction, stores ref in MessageContext
+- `flowComplete()`: if `SET_ROLLBACK_ONLY` or AxisFault occurred → rollback; else → commit
-New helper: `axis2_json_secure_fault.c`
+### Configuration in services.xml
-```c
-axis2_char_t *axis2_json_make_secure_fault_message(
- const axutil_env_t *env,
- int is_parse_error);
-// Returns "Bad Request [errorRef=<uuid>]" or "Internal Server Error [errorRef=<uuid>]"
-// UUID generated from /dev/urandom (16 bytes → hex with hyphens)
-// Full context logged to axutil_log before sanitized message returned
+```xml
+<service name="PortfolioAssetService">
+ <parameter name="transactional">true</parameter>
+ <parameter name="transactionTimeout">30</parameter>
+ <!-- operations... -->
+</service>
```
-Applied to `financial_benchmark_service_handler.c` JSON parse error paths and any
-`axis2_json_rpc_msg_recv` equivalent in Axis2/C.
+### Key files
-### Step D3 — Populate `mcpInputSchema` in all 5 financial benchmark operations
+- `modules/tx/src/main/java/org/apache/axis2/tx/TransactionModule.java`
+- `modules/tx/src/main/java/org/apache/axis2/tx/TransactionHandler.java`
+- `modules/tx/src/META-INF/module.xml`
-Using Option 1 (hand-authored) immediately; Option 3 code-gen script validates against
-it. The 5 operations:
-
-| Operation | Required fields |
-|-----------|----------------|
-| `portfolioVariance` | `n_assets`, `weights`, `covariance_matrix` |
-| `monteCarlo` | `n_simulations`, `n_periods`, `initial_value`, `expected_return`, `volatility` |
-| `scenarioAnalysis` | `n_assets`, `assets` |
-| `generateTestData` | `n_assets` |
-| `metadata` | *(none — GET operation)* |
-
-### Step E — Apache httpd deployment
-
-1. Build `mod_axis2.so` from `axis-axis2-c-core` targeting the host's Apache httpd
-2. `httpd.conf` fragment:
- ```apache
- LoadModule axis2_module modules/mod_axis2.so
- Axis2RepoPath /opt/axis2c/repository
- <Location /axis2>
- SetHandler axis2_module
- </Location>
- ```
-3. Deploy `FinancialBenchmarkService` to repository
-4. Verify:
- ```bash
- curl https://localhost/axis2/_mcp/openapi-mcp.json
- curl -X POST https://localhost/axis2/services/FinancialBenchmarkService/monteCarlo \
- -H 'Content-Type: application/json' \
- -d '{"monteCarlo":[{"arg0":{"n_simulations":10000,"n_periods":252,...}}]}'
- ```
-5. Demo: MCP-aware client resolves tools from catalog, calls financial operations
-
-### Immediate Sprint Sequence
+### Dependency
-```
-B1 (Java) → B1 tests → B2/B3 (Java, config-only) → C3 (Java, new servlet path)
- ↓
-D1 (Axis2/C catalog handler) → D2 (error hardening) → D3 (services.xml schemas)
- ↓
-Option 3 code-gen script (tools/gen_mcp_schema.py)
- ↓
-E (Penguin deployment + demo)
-```
+Requires JTA API on classpath (provided by WildFly, Tomcat with Atomikos/Narayana, etc.).
---
-## Phase 1 — Spring Boot Starter
+## DONE: Phase PG — Offset/Limit Pagination (`PaginatedResponse`)
-**Goal**: Reduce Axis2 + Spring Boot integration from a multi-day configuration project
-to a single Maven dependency.
+**Goal**: Provide a standard pagination envelope that works with existing DAO layers
+using `setFirstResult(offset)` / `setMaxResults(limit)` (the standard JPA/Hibernate
+query pattern), giving frontend grids and API consumers the metadata they need to
+render paging controls or implement infinite scroll.
-**Problem today**: The `springbootdemo-tomcat11` project is a hand-rolled integration
-requiring `maven-antrun-plugin` WAR pre-staging, manual `.mar` module file deployment,
-explicit `Axis2WebAppInitializer`, and custom security filter chain wiring. Every new
-Axis2+Spring Boot project repeats this work and gets it slightly wrong.
+**Bang: 7/10 | Effort: hours**
-### Tasks
+### Why offset/limit (not cursor)
-1. **Create `axis2-spring-boot-starter` module** in `modules/`
- - Spring Boot autoconfiguration class (`Axis2AutoConfiguration`)
- - Auto-registers `AxisServlet` at configurable path (default `/services/*`)
- - Classpath scanning for `.aar` and `.mar` modules — no manual staging
- - Externalized configuration via `application.properties`:
- ```properties
- axis2.services-path=/services
- axis2.repository-path=classpath:axis2-repository
- axis2.rest.enabled=true
- axis2.openapi.enabled=true
- ```
-
-2. **Spring Security autoconfiguration**
- - Default `SecurityFilterChain` bean wired to Axis2 service paths
- - `RequestAndResponseValidatorFilter` registered automatically
- - Overridable — consuming apps provide their own `SecurityFilterChain` bean to replace
-
-3. **Logging bridge autoconfiguration**
- - Log4j2 → SLF4J bridge wired without manual configuration
- - `log4j2-spring.xml` loaded automatically from classpath
-
-4. **WAR and embedded container support**
- - Works both as embedded (Spring Boot `main()`) and as WAR deployed to Tomcat/WildFly
- - `SpringBootServletInitializer` extension handled by the starter
-
-5. **Starter test suite**
- - Integration test: Spring Boot app with starter dependency, single `@WebService`,
- confirms service reachable at `/services/{name}`
- - Test matrix: Java 21, Java 25 × embedded Tomcat, external Tomcat 11, WildFly 32
-
-### Deliverable
-`axis2-spring-boot-starter-2.x.x.jar` — add to `pom.xml`, Axis2 works. Zero XML
-configuration required for the common case.
+Most enterprise applications paginate with integer offsets because:
-### Dependency
-None — builds directly on `springbootdemo-tomcat11` as the reference implementation.
+1. **DAO compatibility** — existing service layers pass `firstResult`/`maxResult` to
+ Hibernate's Query API. Cursor pagination requires a stable sort key and stateful
+ server-side tokens — added complexity with no benefit when the query is already
+ offset-based.
----
+2. **Frontend grid compatibility** — SmartClient, AG Grid, React Table, and MUI
+ DataGrid all natively speak offset/limit via `startRow`/`endRow` or
+ `page`/`pageSize`. Cursor tokens require client-side adaptation.
-## Phase 2 — OpenAPI Generation (springdoc-openapi Bridge)
+3. **Total count is cheap** — offset-based APIs can include `totalCount` (from a
+ parallel `SELECT COUNT(*)`) to enable "Showing 1–50 of 1,247" UI patterns.
+ Cursor APIs typically omit totals because they are expensive for the cursor model.
-**Goal**: Every Axis2 service automatically produces an OpenAPI 3.1 spec from Java
-annotations. MCP tool definitions are generated from that spec at no additional cost.
+4. **Virtual scrolling** — grids that load the next chunk as the user scrolls use
+ `hasMore` to decide whether to fetch. This maps directly to
+ `offset + limit < totalCount`.
-**Problem today**: `axis2-openapi` generates a spec from Axis2service descriptors
-(`services.xml`), not from Java type annotations. The spec is structural but not
-semantically rich — no operation descriptions, no parameter constraints, no response
-schemas beyond what Axis2 infers. springdoc-openapi generates far richer specs from
-`@Operation`, `@Parameter`, `@ApiResponse` annotations on the Java class itself.
+Cursor pagination remains valuable for append-only feeds (activity logs, message
+streams) where offset instability is a real problem. The framework does not preclude
+adding cursor support later — it simply does not ship cursor tokens by default because
+the common case does not need them.
-### Tasks
+### What NOT to build
+
+- No cursor tokens (UUID-based cursors add complexity most deployments don't need)
+- No GraphQL relay-style `edges` / `pageInfo` / `Connection` types
+- No Spring Data `Pageable` / `Page<T>` integration (many projects don't use Spring Data)
+
+### Wire format
+
+```json
+{
+ "response": {
+ "data": [ ... ],
+ "pagination": {
+ "offset": 100,
+ "limit": 50,
+ "totalCount": 1247,
+ "hasMore": true
+ }
+ }
+}
+```
+
+### Classes
+
+**`PaginatedResponse<T>`** — generic response wrapper with `data` (list) and
+`pagination` (metadata). Factory methods:
+- `PaginatedResponse.of(items, offset, limit, totalCount)` — standard paged response
+- `PaginatedResponse.unpaginated(items)` — wrap a full result set with no pagination
+
+**`PaginationRequest`** — request-side helper with safe defaults:
+- `offset` defaults to 0, negative values clamped
+- `limit` defaults to 50, capped at configurable `maxLimit` (default 2000)
+- Services can embed these fields in their request POJO or accept separately
+
+### Frontend integration pattern
+
+```typescript
+// React / TypeScript — infinite scroll
+const { data, pagination } = await fetchPage(offset, limit);
+setItems(prev => [...prev, ...data]);
+if (pagination.hasMore) {
+ setNextOffset(pagination.offset + pagination.limit);
+}
+
+// React / TypeScript — page controls
+const totalPages = Math.ceil(pagination.totalCount / pagination.limit);
+const currentPage = Math.floor(pagination.offset / pagination.limit) + 1;
+```
+
+### Serialization compatibility
+
+`PaginatedResponse` is a plain POJO — serializes identically across all four Axis2
+JSON formatters (Gson, Moshi, EnhancedGson/H2, EnhancedMoshi/H2) with no custom
+adapters. The `data` list elements serialize as their runtime type regardless of
+generic type erasure.
+
+### Key files
-1. **Annotation support on Axis2 `@WebService` classes**
- - Axis2 services annotated with springdoc/Swagger annotations:
- ```java
- @WebService
- public class AssetCalculationsService {
-
- @Operation(summary = "Get portfolio calculations",
- description = "Returns PWR, OPS, Kelly weight for all assets in a fund")
- @ApiResponse(responseCode = "200", content = @Content(schema =
- @Schema(implementation = AssetCalculationsResponse.class)))
- public AssetCalculationsResponse doGetAssetCalculations(
- @Parameter(description = "Fund ID") AssetCalculationsRequest request) { ... }
- }
- ```
- - Annotations processed by `axis2-openapi` during spec generation
- - Falls back to structural inference when annotations absent (backward compatible)
-
-2. **Java type → JSON Schema generation**
- - Request/response POJOs introspected to produce `components/schemas` in the spec
- - Uses Jackson's `JsonSchemaGenerator` or springdoc's `ModelConverter` pipeline
- - Handles: nested objects, arrays, enums, `BigDecimal` as `string` with `format: decimal`
-
-3. **OpenAPI 3.1 output**
- - Upgrade `axis2-openapi` output from OpenAPI 3.0 to OpenAPI 3.1
- - 3.1 required for full JSON Schema compatibility (needed for MCP tool `inputSchema`)
-
-4. **MCP tool definition export endpoint**
- - `GET /openapi-mcp.json` — returns MCP-formatted tool definitions derived from the
- OpenAPI spec:
- ```json
- {
- "tools": [
- {
- "name": "doGetAssetCalculations",
- "description": "Get portfolio calculations — PWR, OPS, Kelly weight",
- "inputSchema": {
- "type": "object",
- "properties": {
- "fundID": { "type": "integer" },
- "departmentID": { "type": "integer" }
- },
- "required": ["fundID", "departmentID"]
- }
- }
- ]
- }
- ```
- - Served by `OpenApiServlet` alongside existing `/openapi.json` and `/swagger-ui`
-
-5. **Starter integration**
- - `axis2.openapi.enabled=true` in starter autoconfiguration activates both
- `/openapi.json` and `/openapi-mcp.json` endpoints automatically
-
-### Deliverable
-Any Axis2 service annotated with standard OpenAPI annotations produces a spec and MCP
-tool definitions served at known endpoints. An MCP wrapper (Phase 4) can point at
-`/openapi-mcp.json` and expose every Axis2 service as an AI tool with zero additional
-code.
+- `modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java`
+- `modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java`
+- `modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java` (22 tests)
### Dependency
-Phase 1 (starter) — OpenAPI endpoints auto-registered via starter autoconfiguration.
+
+None — zero-dependency POJO pattern. Works with any JSON formatter.
---
-## Phase 3 — REST Transport (Dual-Protocol Services)
+## Immediate Track — MCP inputSchema + Axis2/C + Apache httpd Demo
-**Goal**: The same Axis2 `@WebService` class is reachable via both its existing
-JSON-RPC path and a new REST path, with proper HTTP method semantics and
-resource-oriented URLs.
+**Status: DONE** — all steps completed.
-**Problem today**: Existing Axis2 service URLs (`POST /services/calculationService/
-doCalculationJob`) are JSON-RPC over HTTP. New consumers (REST APIs, React
-frontends, MCP agents) expect `GET /api/v1/resources/{id}/calculations`. Axis2 has REST
-dispatch capability in `axis2.xml` but it has never been activated or documented for
-modern Spring Boot deployments.
+### Step B1 — `mcpInputSchema` static parameter support (Java + C)
-### Tasks
+**DONE.** Both Option 1 (static declaration in services.xml) and Option 2 (auto-generated
+from Java type introspection) are implemented and tested. `OpenApiSpecGenerator.generateMcpCatalogJson()`
+reads `mcpInputSchema` param with Jackson validation, falls back to auto-introspection
+via `generateSchemaFromServiceClass()`, then falls back to empty schema.
+
+### Step B2 — `mcpAuthScope` per-operation parameter
-1. **REST dispatcher activation and configuration**
- - Enable Axis2 REST dispatcher in autoconfiguration:
- ```properties
- axis2.rest.enabled=true
- axis2.rest.base-path=/api/v1
- ```
- - REST dispatcher maps `GET /api/v1/funds/{id}/calculations` →
- `AssetCalculationsService.doGetAssetCalculations(fundId)`
-
-2. **URL template annotation**
- - New `@RestMapping` annotation (or reuse JAX-RS `@GET`/`@Path` if feasible):
- ```java
- @RestMapping(method = "GET", path = "/funds/{fundId}/calculations")
- public AssetCalculationsResponse doGetAssetCalculations(
- @PathParam("fundId") long fundId,
- @QueryParam("fields") String fields) { ... }
- ```
- - Axis2 REST dispatcher resolves path variables and query parameters from the
- URL before invoking the service operation
-
-3. **HTTP method routing**
- - GET → read operations (no side effects)
- - POST → create operations
- - PUT/PATCH → update operations
- - DELETE → delete operations
- - Method constraint enforced by REST dispatcher (405 Method Not Allowed if violated)
-
-4. **Parallel transports — same service, no duplication**
- - JSON-RPC path unchanged: `POST /services/AssetCalculationsService/
- doGetAssetCalculations`
- - REST path added: `GET /api/v1/funds/{id}/calculations`
- - Both routes to the same Java method — no code duplication
- - Handler chain (security, logging, validation) applies to both
-
-5. **OpenAPI spec reflects REST paths**
- - Phase 2 spec generator emits REST paths (not JSON-RPC paths) when `@RestMapping`
- present
- - Both paths optionally included with `x-axis2-jsonrpc-path` extension field for
- tooling that needs the RPC form
-
-6. **Integration tests**
- - Confirm same service reachable at both paths
- - Confirm handler chain (authentication, validation) applies identically to both
- - Test matrix includes Java 21, 25 and Tomcat 11 + embedded Tomcat
-
-### Deliverable
-Existing Axis2 services add `@RestMapping` annotations and are immediately available
-as REST endpoints alongside their JSON-RPC paths. Existing JSON-RPC services can be
-exposed to REST and MCP consumers without rewriting or duplicating service logic.
+**DONE.** Reads via `getMcpStringParam()`, emits `x-authScope` in tool node. Tests cover
+operation-level, service-level, and absent cases.
-### Dependency
-Phase 1 (starter registers both dispatchers), Phase 2 (REST paths appear in OpenAPI
-spec and MCP tool definitions).
+### Step B3 — `mcpStreaming` hint
+
+**DONE.** Boolean `mcpStreaming` parameter adds `x-streaming: true`. Absent/false
+suppresses the field entirely (compact catalog).
+
+### Step C3 — MCP Resources endpoint
+
+**DONE.** `generateMcpResourcesJson()` returns `resources/list` response with URI,
+name, description, mimeType, and metadata (wsdlUrl, operations, requiresAuth) for
+each deployed service.
+
+### Step D1 — Axis2/C MCP catalog handler
+
+Axis2/C implementation — tracked separately in axis-axis2-c-core repo.
+
+### Step D2 — Axis2/C correlation ID error hardening
+
+Axis2/C implementation — tracked separately.
+
+### Step D3 — Populate `mcpInputSchema` in all 5 financial benchmark operations
+
+**DONE.** All three Java financial benchmark operations have explicit `mcpInputSchema`
+in services.xml with full JSON Schema definitions.
---
-## Phase 4 — MCP Path 1: OpenAPI-Driven MCP Wrapper
+## Phase 1 — Spring Boot Starter
-**Goal**: Package a lightweight MCP server that reads an Axis2 service's OpenAPI spec
-and exposes every operation as an MCP tool. AI agents (Claude, etc.) can call Axis2
-services via MCP with no MCP-specific code in the service itself.
+**Status: DONE.**
-**This is the practical MCP path available immediately after Phase 2.**
+Delivered as `modules/spring-boot-starter` with:
+- `Axis2AutoConfiguration` — master switch, respects `axis2.enabled`
+- `Axis2RepositoryAutoConfiguration` — stages axis2.xml (SOAP or JSON mode templates)
+- `Axis2ServletAutoConfiguration` — registers AxisServlet with configurable path
+- `Axis2OpenApiAutoConfiguration` — registers OpenAPI/MCP servlet endpoints
+- `Axis2Properties` — externalized configuration via `application.properties`
+- Built-in `axis2-soap.xml` and `axis2-json.xml` templates
+- WildFly VFS compatibility
+- Full test suite
-### Tasks
+**Not included (intentional):** JWT/Spring Security autoconfiguration — deployment-specific,
+consuming apps provide their own `SecurityFilterChain`.
+
+---
+
+## Phase 2 — OpenAPI Generation (springdoc-openapi Bridge)
+
+**Status: PARTIAL.**
-1. **`axis2-mcp-bridge` module**
- - Thin Spring Boot app (or embeddable library) that:
- - Reads `/openapi-mcp.json` from a configured Axis2 deployment
- - Implements MCP `initialize` handshake, reporting tool capabilities
- - Forwards MCP `tools/call` requests to the corresponding Axis2 REST endpoint
- (Phase 3) or JSON-RPC endpoint (fallback if Phase 3 not deployed)
- - Returns MCP-formatted responses
-
-2. **Transport: HTTP + SSE**
- - MCP HTTP transport: POST to `/mcp` for client→server messages
- - SSE endpoint at `/mcp/events` for server→client streaming
- - Spring's `SseEmitter` for SSE — standard Spring MVC, no Axis2 involvement
-
-3. **Configuration**
- - `axis2.mcp.target-url=http://localhost:8080/axis2-json-api` points bridge at the
- Axis2 deployment
- - Bridge refreshes tool definitions on startup and on `/openapi-mcp.json` change
-
-4. **Starter integration**
- - `axis2.mcp.bridge.enabled=true` in starter spins up the bridge in the same JVM
- as the Axis2 deployment — no separate process needed for simple deployments
-
-5. **Reference implementation**
- - Extend `springbootdemo-tomcat11` with MCP bridge enabled
- - Document: Claude Desktop config pointing at the bridge, example tool call flow
-
-### Deliverable
-`axis2-mcp-bridge` — configure, deploy, and every Axis2 service is callable from Claude
-Desktop, Claude API tool use, or any MCP-compatible AI agent. No MCP code in service
-classes.
+**Done:**
+- POJO introspection for request/response types → `components/schemas`
+- Structured error response schema (`ErrorResponse`) with 422/429/503 responses
+- MCP tool definitions with `inputSchema` auto-generated from Java types
+- Request body and 200 response schemas linked via `$ref`
+
+**Remaining:**
+- springdoc/Swagger annotation processing (`@Operation`, `@Parameter`, `@ApiResponse`)
+- OpenAPI 3.1 output (currently 3.0.1)
### Dependency
-Phase 2 (requires `/openapi-mcp.json` endpoint). Phase 3 (REST paths preferred as MCP
-call targets, but JSON-RPC fallback works without Phase 3).
+Phase 1 (starter) — **satisfied**.
---
-## Phase 5 — HTTP/2 Transport Publication
+## Phase 3 — REST Transport (Dual-Protocol Services)
-**Goal**: `modules/transport-h2` becomes a supported, tested, documented module with
-a published performance benchmark.
+**Status: NOT STARTED.**
-**Foundation**: The module exists and was tested end-to-end with Java 25 + Tomcat 11.0.20
-in `springbootdemo-tomcat11` (`BigDataH2Service` — confirmed working with `datasetId` +
-`datasetSize` request fields).
+**Goal**: Same Axis2 `@WebService` class reachable via both JSON-RPC and REST paths.
### Tasks
-1. **Formal integration test suite for `transport-h2`**
- - Tests against: embedded Tomcat (Spring Boot), external Tomcat 10, Tomcat 11,
- WildFly 32
- - Java versions: 21, 25
- - Large payload test: confirm HTTP/2 multiplexing benefit over HTTP/1.1 at 1MB+
- payloads
+1. REST dispatcher activation in autoconfiguration
+2. `@RestMapping` annotation for URL templates
+3. HTTP method routing (GET/POST/PUT/DELETE → 405 enforcement)
+4. Parallel transports — same service, no code duplication
+5. OpenAPI spec reflects REST paths when `@RestMapping` present
-2. **Performance benchmark**
- - Baseline: HTTP/1.1 transport, sequential requests, 1MB / 10MB / 50MB payloads
- - Compare: HTTP/2 transport, concurrent requests (multiplexing)
- - Document results in module README
+### Dependency
+Phase 1 (starter), Phase 2 (REST paths in OpenAPI spec).
+
+---
-3. **Starter integration**
- - `axis2.transport.h2.enabled=true` activates HTTP/2 transport in the starter
- - Auto-detects servlet container HTTP/2 support (Tomcat 10+, WildFly 32+)
+## Phase 4 — MCP Path 1: OpenAPI-Driven MCP Wrapper
-4. **Module graduation**
- - Move from `modules/transport-h2` proof-of-concept to a released artifact at the
- same version as the core modules
- - Javadoc, usage example, known limitations documented
+**Status: NOT STARTED.**
-### Deliverable
-`axis2-transport-h2-2.x.x.jar` as a supported module. HTTP/2 is a documented, tested
-deployment option for Axis2 services with large payloads.
+Thin Spring Boot app that reads `/openapi-mcp.json` and implements MCP protocol
+(initialize, tools/list, tools/call). Forwards calls to Axis2 endpoints.
### Dependency
-Phase 1 (starter exposes the transport config property). No dependency on Phases 2-4.
+Phase 2 (requires `/openapi-mcp.json` endpoint — **satisfied**).
---
-## Phase 6 — Native MCP Transport (`axis2-transport-mcp`)
+## Phase 5 — HTTP/2 Transport Publication
-**Goal**: Axis2 speaks MCP natively. An MCP client (Claude Desktop, Claude API, any
-MCP-compatible agent) connects directly to Axis2 with no intermediate wrapper. One
-service deployment, three protocols: JSON-RPC, REST, MCP.
+**Status: NOT STARTED.**
-**This is the novel Apache project contribution — no other Java framework has this.**
+Graduate `modules/transport-h2` to supported module with formal test suite,
+performance benchmarks, and starter integration.
-### Tasks
+### Dependency
+Phase 1 (starter) — **satisfied**.
+
+---
-1. **`axis2-transport-mcp` module**
- - Implements Axis2 `TransportListener` and `TransportSender` interfaces
- - Translates MCP JSON-RPC 2.0 messages ↔ Axis2 `MessageContext`
- - MCP `tools/call` → Axis2 service operation invocation
- - Axis2 response → MCP `tools/call` result
-
-2. **MCP initialize handshake**
- - Axis2 responds to MCP `initialize` with:
- - `serverInfo.name`: service deployment name from `axis2.xml`
- - `capabilities.tools`: populated from deployed services
- - Tool list derived from Phase 2 OpenAPI/MCP tool definitions
-
-3. **Transport: stdio (ships first)**
- - stdio transport is simpler than HTTP/SSE — no persistent connection management
- - Axis2 can be launched as a subprocess; Claude Desktop communicates via stdin/stdout
- - Validates the JSON-RPC 2.0 translation layer before adding HTTP complexity
-
-4. **Transport: HTTP + SSE (ships second)**
- - MCP HTTP transport layer on top of Axis2's existing HTTP infrastructure
- - SSE for server-initiated messages (progress notifications for long-running calcs)
- - Reuses Axis2's HTTP transport configuration (port, TLS, thread pool)
-
-5. **Tool schema generation**
- - Uses Phase 2 JSON Schema generation to populate `inputSchema` for each tool
- - Tool descriptions from `@Operation` annotations (Phase 2)
-
-6. **Starter integration**
- - `axis2.transport.mcp.enabled=true`
- - `axis2.transport.mcp.transport=stdio|http`
- - HTTP transport: `axis2.transport.mcp.path=/mcp`
-
-7. **Integration test**
- - MCP client (test harness, not full Claude) sends `initialize` + `tools/list` +
- `tools/call` sequence
- - Verifies correct JSON-RPC 2.0 framing, correct tool invocation, correct response
-
-### Deliverable
-`axis2-transport-mcp-2.x.x.jar` — configure, and Axis2 becomes a native MCP server.
-Phase 4 wrapper becomes optional (useful for external deployments; native transport
-preferred for co-located services).
+## Phase 6 — Native MCP Transport (`axis2-transport-mcp`)
-### Dependency
-Phase 2 (tool schema generation), Phase 1 (starter wires the transport). Phase 3 (REST)
-independent — MCP transport calls service operations directly, REST paths not required.
+**Status: DEFERRED.**
+
+Bridge approach (Phase 4) sufficient. Prove MCP catalog adoption before investing
+in native transport. Build when there's demand.
---
## Phase 7 — Community and Positioning
-**Goal**: The Apache community and downstream projects know these capabilities exist,
-understand the multi-protocol positioning, and have concrete migration guides.
-
-### Tasks
+**Status: NOT STARTED.**
-1. **Apache blog post: "Axis2 as a Multi-Protocol Service Platform"**
- - Covers: dual-protocol JSON-RPC + REST from one service, OpenAPI generation,
- MCP transport, Spring Boot starter
- - Uses a calculation-orchestration deployment as the case study:
- sub-200ms portfolio calculations, change-data-capture cache, Node.js bridge
- - Positions Axis2's handler chain as the differentiator for production orchestration
- workloads
-
-2. **ThreadPool fix release note**
- - Explicit note in 2.x changelog: `ThreadPool.shutDown` was a static field; changed
- to instance field. Affects any deployment running multiple Axis2 engine instances
- in the same JVM. Multi-pool deployments should upgrade.
-
-3. **`springbootdemo-tomcat11` as canonical reference**
- - The existing module becomes the official Spring Boot + Axis2 + Tomcat 11
- reference implementation in the distribution
- - README updated to reflect starter usage once Phase 1 ships
-
-4. **Migration guide: JSON-RPC → Dual-Protocol**
- - Step-by-step: add `@RestMapping` annotations, enable REST dispatcher, verify
- both paths, update OpenAPI spec, expose MCP tools
- - Targets teams running Axis2 JSON-RPC who want REST and MCP without rewriting
+Apache blog post, migration guide, reference implementation documentation.
---
-## Summary Timeline
+## Summary Timeline (Updated)
-| Phase | Deliverable | Key Dependency |
+| Phase | Deliverable | Status |
|---|---|---|
-| **1** | `axis2-spring-boot-starter` | springbootdemo-tomcat11 reference |
-| **2** | OpenAPI annotation bridge + MCP tool export | Phase 1 |
-| **3** | REST transport + dual-protocol services | Phase 1, Phase 2 |
-| **4** | `axis2-mcp-bridge` (OpenAPI-driven MCP wrapper) | Phase 2 |
-| **5** | `transport-h2` published module + benchmarks | Phase 1 |
-| **6** | `axis2-transport-mcp` native MCP transport | Phase 2, Phase 1 |
-| **7** | Community posts, migration guide, reference impl | All phases |
-
-Phases 1 and 2 are the critical path — everything else depends on them. Phases 3, 4,
-and 5 can proceed in parallel after Phase 2. Phase 6 requires Phase 2 but is otherwise
-independent of Phases 3, 4, and 5.
+| Immediate Track | MCP catalog, inputSchema, resources | **DONE** |
+| **1** | `axis2-spring-boot-starter` | **DONE** |
+| **A** | Error contracts (422/429/503) | **DONE** |
+| **B** | MCP schema completion | **DONE** |
+| **JPA** | JPA/Hibernate → OpenAPI schema generation | **DONE** |
+| **PG** | Offset/limit pagination envelope | **DONE** |
+| **TX** | Transaction demarcation .mar module | PLANNED |
+| **2** | OpenAPI annotation bridge (springdoc) | PARTIAL |
+| **3** | REST transport + dual-protocol | NOT STARTED |
+| **4** | MCP bridge wrapper | NOT STARTED |
+| **5** | HTTP/2 transport publication | NOT STARTED |
+| **6** | Native MCP transport | DEFERRED |
+| **7** | Community posts, migration guide | NOT STARTED |
---
@@ -604,12 +514,16 @@ independent of Phases 3, 4, and 5.
A single Axis2 service deployment, configured once, serves:
```
-Claude Desktop / AI agent → MCP (native transport, Phase 6)
+Claude Desktop / AI agent → MCP (bridge Phase 4, or native Phase 6)
↓
Data API / React frontend → REST (Phase 3) ──► Axis2 Service
↑ (one implementation)
Existing JSON-RPC callers → JSON-RPC (unchanged)
```
-With OpenAPI spec and MCP tool definitions auto-generated (Phase 2) and a Spring Boot
-starter (Phase 1) that makes the whole stack a single Maven dependency.
+With:
+- OpenAPI spec and MCP tool definitions auto-generated (Phase 2 + JPA)
+- JPA entity schemas for any Hibernate project (Phase JPA)
+- Automatic transaction management (Phase TX)
+- Spring Boot starter (Phase 1) — single Maven dependency
+- Structured error contracts with proper HTTP status codes (Phase A)
diff --git a/modules/jpa-schema/pom.xml b/modules/jpa-schema/pom.xml
new file mode 100644
index 0000000000..4ec7d24894
--- /dev/null
+++ b/modules/jpa-schema/pom.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ -->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.axis2</groupId>
+ <artifactId>axis2</artifactId>
+ <version>2.0.1-SNAPSHOT</version>
+ <relativePath>../../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>axis2-jpa-schema</artifactId>
+ <packaging>jar</packaging>
+
+ <name>Apache Axis2 - JPA/Hibernate Schema Generation Module</name>
+ <description>
+ Generates OpenAPI JSON Schema definitions from JPA-annotated entity classes
+ and Hibernate XML mapping files (.hbm.xml). Supports both mapping styles
+ from a single module, producing read and write schema variants.
+ </description>
+
+ <dependencies>
+ <!-- Jakarta Persistence API — provides @Entity, @Column, @ManyToOne, etc.
+ Marked "provided" because the consuming application supplies the
+ implementation (Hibernate, EclipseLink, etc.). -->
+ <dependency>
+ <groupId>jakarta.persistence</groupId>
+ <artifactId>jakarta.persistence-api</artifactId>
+ <version>3.1.0</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Jackson for JSON Schema node building. Used to produce ObjectNode
+ trees that OpenApiSpecGenerator can convert to swagger-core Schema
+ objects via treeToValue(). Arrives transitively from swagger-core
+ in most deployments, declared explicitly here for standalone use. -->
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ <version>2.18.3</version>
+ </dependency>
+
+ <!-- Logging -->
+ <dependency>
+ <groupId>commons-logging</groupId>
+ <artifactId>commons-logging</artifactId>
+ </dependency>
+
+ <!-- Testing -->
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <archive>
+ <manifestEntries>
+ <Automatic-Module-Name>org.apache.axis2.jpa.schema</Automatic-Module-Name>
+ </manifestEntries>
+ </archive>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/AnnotationIntrospector.java b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/AnnotationIntrospector.java
new file mode 100644
index 0000000000..807662c0c8
--- /dev/null
+++ b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/AnnotationIntrospector.java
@@ -0,0 +1,288 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.jpa.schema;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.persistence.ManyToMany;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.Transient;
+import jakarta.persistence.Version;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Extracts {@link EntitySchemaModel} from JPA-annotated entity classes.
+ *
+ * <p>Reads standard Jakarta Persistence annotations ({@code @Entity},
+ * {@code @Column}, {@code @Id}, {@code @ManyToOne}, etc.) and maps them to
+ * the unified schema model. Also supports custom "ignore for write"
+ * annotations (configurable via {@link #addWriteExcludeAnnotation}) for
+ * project-specific audit field markers.
+ *
+ * <p>Usage:
+ * <pre>{@code
+ * AnnotationIntrospector introspector = new AnnotationIntrospector();
+ * introspector.addWriteExcludeAnnotation("com.example.IgnoreChanges");
+ * EntitySchemaModel model = introspector.introspect(Product.class);
+ * }</pre>
+ */
+public class AnnotationIntrospector {
+
+ private static final Log log = LogFactory.getLog(AnnotationIntrospector.class);
+
+ /** Fully-qualified names of annotations that mark fields as excluded from write schemas. */
+ private final Set<String> writeExcludeAnnotations = new HashSet<>();
+
+ /**
+ * Register a custom annotation that marks fields as excluded from write schemas.
+ * For example, {@code "com.example.project.audit.IgnoreOnUpdate"}.
+ */
+ public void addWriteExcludeAnnotation(String annotationClassName) {
+ writeExcludeAnnotations.add(annotationClassName);
+ }
+
+ /**
+ * Introspect a JPA entity class and produce an {@link EntitySchemaModel}.
+ *
+ * @param entityClass the class annotated with {@code @Entity}
+ * @return the schema model, or null if the class is not an {@code @Entity}
+ */
+ public EntitySchemaModel introspect(Class<?> entityClass) {
+ if (!entityClass.isAnnotationPresent(Entity.class)) {
+ return null;
+ }
+
+ EntitySchemaModel model = new EntitySchemaModel();
+
+ // Entity name: @Entity(name=...) or simple class name
+ Entity entityAnn = entityClass.getAnnotation(Entity.class);
+ String entityName = (entityAnn.name() != null && !entityAnn.name().isEmpty())
+ ? entityAnn.name() : entityClass.getSimpleName();
+ model.setEntityName(entityName);
+
+ // Table name: @Table(name=...) or entity name
+ Table tableAnn = entityClass.getAnnotation(Table.class);
+ if (tableAnn != null && tableAnn.name() != null && !tableAnn.name().isEmpty()) {
+ model.setTableName(tableAnn.name());
+ } else {
+ model.setTableName(entityName);
+ }
+
+ // Walk all declared fields up the class hierarchy
+ List<Field> allFields = getAllFields(entityClass);
+ for (Field field : allFields) {
+ EntitySchemaModel.FieldModel fm = introspectField(field);
+ if (fm != null) {
+ model.addField(fm);
+ if (field.isAnnotationPresent(Id.class)) {
+ model.getIdFieldNames().add(fm.getName());
+ }
+ if (field.isAnnotationPresent(Version.class)) {
+ model.setVersionFieldName(fm.getName());
+ }
+ }
+ }
+
+ log.debug("Introspected JPA entity '" + entityName + "' with "
+ + model.getFields().size() + " fields ("
+ + model.getIdFieldNames().size() + " ID fields)");
+ return model;
+ }
+
+ /**
+ * Check if a class is a JPA entity (has @Entity annotation).
+ */
+ public static boolean isEntity(Class<?> clazz) {
+ return clazz.isAnnotationPresent(Entity.class);
+ }
+
+ // ── Private helpers ──────────────────────────────────────────────────────
+
+ private EntitySchemaModel.FieldModel introspectField(Field field) {
+ // @Transient fields are excluded from all schemas
+ if (field.isAnnotationPresent(Transient.class)) {
+ EntitySchemaModel.FieldModel fm = new EntitySchemaModel.FieldModel(field.getName());
+ fm.setTransientField(true);
+ return fm;
+ }
+
+ EntitySchemaModel.FieldModel fm = new EntitySchemaModel.FieldModel(field.getName());
+
+ // ── Type mapping ────────────────────────────────────────────────
+ Class<?> fieldType = field.getType();
+
+ // Check for relationships first
+ if (field.isAnnotationPresent(ManyToOne.class) || field.isAnnotationPresent(OneToOne.class)) {
+ fm.setJsonSchemaType("object");
+ fm.setRefEntityName(fieldType.getSimpleName());
+ } else if (field.isAnnotationPresent(OneToMany.class) || field.isAnnotationPresent(ManyToMany.class)) {
+ fm.setJsonSchemaType("array");
+ fm.setCollection(true);
+ // Extract generic type argument for the collection
+ Type genericType = field.getGenericType();
+ if (genericType instanceof ParameterizedType) {
+ Type[] typeArgs = ((ParameterizedType) genericType).getActualTypeArguments();
+ if (typeArgs.length > 0 && typeArgs[0] instanceof Class) {
+ fm.setRefEntityName(((Class<?>) typeArgs[0]).getSimpleName());
+ }
+ }
+ } else if (field.isAnnotationPresent(Enumerated.class)) {
+ fm.setJsonSchemaType("string");
+ if (fieldType.isEnum()) {
+ List<String> enumValues = new ArrayList<>();
+ for (Object constant : fieldType.getEnumConstants()) {
+ enumValues.add(constant.toString());
+ }
+ fm.setEnumValues(enumValues);
+ }
+ } else {
+ mapJavaType(fieldType, fm);
+ }
+
+ // ── Column metadata ─────────────────────────────────────────────
+ Column columnAnn = field.getAnnotation(Column.class);
+ if (columnAnn != null) {
+ fm.setNullable(columnAnn.nullable());
+ if (columnAnn.length() != 255 && "string".equals(fm.getJsonSchemaType())) {
+ // 255 is the JPA default — only set maxLength if explicitly specified
+ fm.setMaxLength(columnAnn.length());
+ }
+ }
+
+ // ── ID / GeneratedValue ─────────────────────────────────────────
+ if (field.isAnnotationPresent(Id.class)) {
+ fm.setNullable(false);
+ if (field.isAnnotationPresent(GeneratedValue.class)) {
+ fm.setGeneratedValue(true);
+ }
+ }
+
+ // ── Version ─────────────────────────────────────────────────────
+ if (field.isAnnotationPresent(Version.class)) {
+ fm.setVersionField(true);
+ }
+
+ // ── Custom "ignore for write" annotations ───────────────────────
+ // Check if any of the field's annotations match the configured
+ // write-exclude annotation names. Uses string comparison on the
+ // annotation class name so we don't need a compile-time dependency
+ // on project-specific annotation classes.
+ for (Annotation ann : field.getAnnotations()) {
+ if (writeExcludeAnnotations.contains(ann.annotationType().getName())) {
+ fm.setIgnoredForWrite(true);
+ break;
+ }
+ }
+
+ return fm;
+ }
+
+ /**
+ * Map a Java type to JSON Schema type + format.
+ *
+ * <p>Handles the common types found in JPA entities:
+ * <ul>
+ * <li>BigDecimal used as ID → integer (common in legacy schemas)</li>
+ * <li>BigDecimal used as value → number</li>
+ * <li>Byte used as boolean flag → boolean (common in SQL Server entities)</li>
+ * </ul>
+ */
+ private void mapJavaType(Class<?> type, EntitySchemaModel.FieldModel fm) {
+ if (type == String.class) {
+ fm.setJsonSchemaType("string");
+ } else if (type == int.class || type == Integer.class) {
+ fm.setJsonSchemaType("integer");
+ fm.setJsonSchemaFormat("int32");
+ } else if (type == long.class || type == Long.class) {
+ fm.setJsonSchemaType("integer");
+ fm.setJsonSchemaFormat("int64");
+ } else if (type == BigInteger.class) {
+ fm.setJsonSchemaType("integer");
+ } else if (type == BigDecimal.class) {
+ // BigDecimal is ambiguous: ID fields use it as integer, value fields as number.
+ // Default to number; the caller can override for ID fields.
+ fm.setJsonSchemaType("number");
+ } else if (type == double.class || type == Double.class
+ || type == float.class || type == Float.class) {
+ fm.setJsonSchemaType("number");
+ } else if (type == boolean.class || type == Boolean.class) {
+ fm.setJsonSchemaType("boolean");
+ } else if (type == byte.class || type == Byte.class) {
+ // Byte is commonly used as a boolean flag in SQL Server entities
+ // (0 = false, 1 = true). Map to boolean for JSON Schema.
+ fm.setJsonSchemaType("boolean");
+ } else if (type == short.class || type == Short.class) {
+ fm.setJsonSchemaType("integer");
+ } else if (type == Timestamp.class || type == Date.class
+ || type == Instant.class || type == LocalDateTime.class) {
+ fm.setJsonSchemaType("string");
+ fm.setJsonSchemaFormat("date-time");
+ } else if (type == LocalDate.class) {
+ fm.setJsonSchemaType("string");
+ fm.setJsonSchemaFormat("date");
+ } else if (type == byte[].class || type == Byte[].class) {
+ fm.setJsonSchemaType("string");
+ fm.setJsonSchemaFormat("byte"); // base64
+ } else if (Collection.class.isAssignableFrom(type) || type.isArray()) {
+ fm.setJsonSchemaType("array");
+ fm.setCollection(true);
+ } else {
+ // Unknown type — treat as object
+ fm.setJsonSchemaType("object");
+ }
+ }
+
+ /** Collect all declared fields from the class and its superclasses. */
+ private List<Field> getAllFields(Class<?> clazz) {
+ List<Field> fields = new ArrayList<>();
+ Class<?> current = clazz;
+ while (current != null && current != Object.class) {
+ fields.addAll(Arrays.asList(current.getDeclaredFields()));
+ current = current.getSuperclass();
+ }
+ return fields;
+ }
+}
diff --git a/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/EntitySchemaModel.java b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/EntitySchemaModel.java
new file mode 100644
index 0000000000..ce3d80aaa1
--- /dev/null
+++ b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/EntitySchemaModel.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.jpa.schema;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Unified representation of a JPA/Hibernate entity's metadata, produced by
+ * either {@link AnnotationIntrospector} (JPA annotations) or
+ * {@link HbmXmlIntrospector} (.hbm.xml files).
+ *
+ * <p>This model is mapping-style-agnostic: both introspectors populate the
+ * same fields, and {@link JpaSchemaGenerator} converts it to JSON Schema
+ * without knowing which source produced it.
+ *
+ * <p>Two schema variants are generated from each model:
+ * <ul>
+ * <li><b>Read schema</b> — all fields, including generated IDs and audit fields</li>
+ * <li><b>Write schema</b> — excludes auto-generated IDs, version fields, and
+ * fields marked with "ignore" annotations (e.g. audit timestamps)</li>
+ * </ul>
+ */
+public class EntitySchemaModel {
+
+ private String entityName;
+ private String tableName;
+ private final Map<String, FieldModel> fields = new LinkedHashMap<>();
+ private final List<String> idFieldNames = new ArrayList<>();
+ private String versionFieldName;
+
+ // ── Field model ──────────────────────────────────────────────────────────
+
+ public static class FieldModel {
+ private String name;
+ private String jsonSchemaType; // "string", "integer", "number", "boolean", "array", "object"
+ private String jsonSchemaFormat; // "date-time", "int64", etc. (nullable)
+ private boolean nullable = true;
+ private boolean generatedValue; // @GeneratedValue or <generator>
+ private boolean versionField; // @Version or <version>
+ private boolean ignoredForWrite; // @IgnoreChanges, @Transient, or custom
+ private boolean transientField; // @Transient — excluded from ALL schemas
+ private Integer maxLength; // @Column(length=) or hbm length attribute
+ private String refEntityName; // for relationships: target entity name
+ private boolean collection; // true for @OneToMany, <set>, <bag>
+ private List<String> enumValues; // for @Enumerated types
+
+ public FieldModel(String name) {
+ this.name = name;
+ }
+
+ // Getters / setters
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getJsonSchemaType() { return jsonSchemaType; }
+ public void setJsonSchemaType(String jsonSchemaType) { this.jsonSchemaType = jsonSchemaType; }
+
+ public String getJsonSchemaFormat() { return jsonSchemaFormat; }
+ public void setJsonSchemaFormat(String jsonSchemaFormat) { this.jsonSchemaFormat = jsonSchemaFormat; }
+
+ public boolean isNullable() { return nullable; }
+ public void setNullable(boolean nullable) { this.nullable = nullable; }
+
+ public boolean isGeneratedValue() { return generatedValue; }
+ public void setGeneratedValue(boolean generatedValue) { this.generatedValue = generatedValue; }
+
+ public boolean isVersionField() { return versionField; }
+ public void setVersionField(boolean versionField) { this.versionField = versionField; }
+
+ public boolean isIgnoredForWrite() { return ignoredForWrite; }
+ public void setIgnoredForWrite(boolean ignoredForWrite) { this.ignoredForWrite = ignoredForWrite; }
+
+ public boolean isTransientField() { return transientField; }
+ public void setTransientField(boolean transientField) { this.transientField = transientField; }
+
+ public Integer getMaxLength() { return maxLength; }
+ public void setMaxLength(Integer maxLength) { this.maxLength = maxLength; }
+
+ public String getRefEntityName() { return refEntityName; }
+ public void setRefEntityName(String refEntityName) { this.refEntityName = refEntityName; }
+
+ public boolean isCollection() { return collection; }
+ public void setCollection(boolean collection) { this.collection = collection; }
+
+ public List<String> getEnumValues() { return enumValues; }
+ public void setEnumValues(List<String> enumValues) { this.enumValues = enumValues; }
+
+ /** True if this field should be excluded from write schemas. */
+ public boolean isExcludedFromWrite() {
+ return transientField || ignoredForWrite || versionField
+ || generatedValue;
+ }
+ }
+
+ // ── EntitySchemaModel getters / setters ──────────────────────────────────
+
+ public String getEntityName() { return entityName; }
+ public void setEntityName(String entityName) { this.entityName = entityName; }
+
+ public String getTableName() { return tableName; }
+ public void setTableName(String tableName) { this.tableName = tableName; }
+
+ public Map<String, FieldModel> getFields() { return fields; }
+
+ public void addField(FieldModel field) { fields.put(field.getName(), field); }
+
+ public List<String> getIdFieldNames() { return idFieldNames; }
+
+ public String getVersionFieldName() { return versionFieldName; }
+ public void setVersionFieldName(String versionFieldName) { this.versionFieldName = versionFieldName; }
+}
diff --git a/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/HbmXmlIntrospector.java b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/HbmXmlIntrospector.java
new file mode 100644
index 0000000000..9eb4a41fe4
--- /dev/null
+++ b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/HbmXmlIntrospector.java
@@ -0,0 +1,376 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.jpa.schema;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+import org.xml.sax.EntityResolver;
+import org.xml.sax.SAXException;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Extracts {@link EntitySchemaModel} from Hibernate XML mapping files (.hbm.xml).
+ *
+ * <p>Parses the Hibernate 3.0 DTD mapping format:
+ * <pre>{@code
+ * <hibernate-mapping package="com.example.bo">
+ * <class name="CompanyBO" table="`COMPANY`">
+ * <id name="id" type="java.lang.Long" column="`companyID`">
+ * <generator class="native"/>
+ * </id>
+ * <version name="version" type="java.lang.Long" column="OBJ_VERSION"/>
+ * <property name="name" type="java.lang.String" not-null="true"/>
+ * <many-to-one name="createdUser" class="...UserBO" column="`createdUserID`"/>
+ * <set name="departments" cascade="all-delete-orphan">
+ * <key column="`companyID`"/>
+ * <one-to-many class="...DepartmentBO"/>
+ * </set>
+ * </class>
+ * </hibernate-mapping>
+ * }</pre>
+ *
+ * <p>The parser ignores the Hibernate DTD (resolves it to empty) to avoid
+ * network fetches and to work in air-gapped environments.
+ */
+public class HbmXmlIntrospector {
+
+ private static final Log log = LogFactory.getLog(HbmXmlIntrospector.class);
+
+ // Hibernate type → JSON Schema type mapping
+ private static final Map<String, String[]> TYPE_MAP = new HashMap<>();
+
+ static {
+ // [jsonType, jsonFormat]
+ TYPE_MAP.put("java.lang.String", new String[]{"string", null});
+ TYPE_MAP.put("string", new String[]{"string", null});
+ TYPE_MAP.put("java.lang.Integer", new String[]{"integer", "int32"});
+ TYPE_MAP.put("integer", new String[]{"integer", "int32"});
+ TYPE_MAP.put("int", new String[]{"integer", "int32"});
+ TYPE_MAP.put("java.lang.Long", new String[]{"integer", "int64"});
+ TYPE_MAP.put("long", new String[]{"integer", "int64"});
+ TYPE_MAP.put("java.lang.Short", new String[]{"integer", null});
+ TYPE_MAP.put("short", new String[]{"integer", null});
+ TYPE_MAP.put("java.lang.Double", new String[]{"number", null});
+ TYPE_MAP.put("double", new String[]{"number", null});
+ TYPE_MAP.put("java.lang.Float", new String[]{"number", null});
+ TYPE_MAP.put("float", new String[]{"number", null});
+ TYPE_MAP.put("java.math.BigDecimal", new String[]{"number", null});
+ TYPE_MAP.put("big_decimal", new String[]{"number", null});
+ TYPE_MAP.put("java.lang.Boolean", new String[]{"boolean", null});
+ TYPE_MAP.put("boolean", new String[]{"boolean", null});
+ TYPE_MAP.put("yes_no", new String[]{"boolean", null});
+ TYPE_MAP.put("true_false", new String[]{"boolean", null});
+ TYPE_MAP.put("java.lang.Byte", new String[]{"boolean", null});
+ TYPE_MAP.put("byte", new String[]{"boolean", null});
+ TYPE_MAP.put("timestamp", new String[]{"string", "date-time"});
+ TYPE_MAP.put("java.sql.Timestamp", new String[]{"string", "date-time"});
+ TYPE_MAP.put("java.util.Date", new String[]{"string", "date-time"});
+ TYPE_MAP.put("date", new String[]{"string", "date"});
+ TYPE_MAP.put("java.sql.Date", new String[]{"string", "date"});
+ TYPE_MAP.put("binary", new String[]{"string", "byte"});
+ TYPE_MAP.put("text", new String[]{"string", null});
+ TYPE_MAP.put("clob", new String[]{"string", null});
+ TYPE_MAP.put("blob", new String[]{"string", "byte"});
+ }
+
+ /**
+ * Parse a .hbm.xml file and produce an {@link EntitySchemaModel}.
+ *
+ * @param inputStream the .hbm.xml file content
+ * @param resourceName name for logging (e.g. "CompanyBO.hbm.xml")
+ * @return the schema model, or null on parse failure
+ */
+ public EntitySchemaModel introspect(InputStream inputStream, String resourceName) {
+ try {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ // Security: allow DOCTYPE (needed for legacy HBM files that declare
+ // the Hibernate DTD), but disable all external entity processing
+ // to prevent XXE attacks. The EntityResolver below further blocks
+ // network DTD fetches by returning empty content for all entities.
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ // Resolve all DTD references to empty — avoids network fetch for
+ // Hibernate DTDs and works in air-gapped environments.
+ builder.setEntityResolver(new EntityResolver() {
+ @Override
+ public InputSource resolveEntity(String publicId, String systemId)
+ throws SAXException, IOException {
+ return new InputSource(new StringReader(""));
+ }
+ });
+
+ Document doc = builder.parse(inputStream);
+ Element root = doc.getDocumentElement(); // <hibernate-mapping>
+
+ // Package prefix for class name resolution
+ String packageName = root.getAttribute("package");
+
+ // Find the <class> element (typically one per .hbm.xml)
+ NodeList classNodes = root.getElementsByTagName("class");
+ if (classNodes.getLength() == 0) {
+ log.debug("No <class> element found in " + resourceName);
+ return null;
+ }
+
+ Element classEl = (Element) classNodes.item(0);
+ return parseClassElement(classEl, packageName, resourceName);
+
+ } catch (Exception e) {
+ // Sanitize log inputs to prevent CR/LF log injection from
+ // malformed XML content or attacker-controlled file names.
+ String safeName = resourceName != null
+ ? resourceName.replaceAll("[\\r\\n]", "_") : "<null>";
+ String safeMsg = e.getMessage() != null
+ ? e.getMessage().replaceAll("[\\r\\n]", "_") : "<null>";
+ log.warn("Failed to parse HBM XML '" + safeName + "': " + safeMsg);
+ return null;
+ }
+ }
+
+ // ── Private parsing ──────────────────────────────────────────────────────
+
+ private EntitySchemaModel parseClassElement(Element classEl, String packageName,
+ String resourceName) {
+ EntitySchemaModel model = new EntitySchemaModel();
+
+ String className = classEl.getAttribute("name");
+ model.setEntityName(className);
+
+ // Table name: strip backticks (SQL Server quoting)
+ String tableName = classEl.getAttribute("table");
+ if (tableName != null) {
+ tableName = tableName.replace("`", "");
+ }
+ model.setTableName(tableName != null ? tableName : className);
+
+ // ── <id> element ────────────────────────────────────────────────
+ NodeList idNodes = getDirectChildren(classEl, "id");
+ for (int i = 0; i < idNodes.getLength(); i++) {
+ Element idEl = (Element) idNodes.item(i);
+ EntitySchemaModel.FieldModel fm = parsePropertyElement(idEl);
+ fm.setNullable(false);
+ // Check for <generator> child — if present, it's auto-generated
+ NodeList generators = idEl.getElementsByTagName("generator");
+ if (generators.getLength() > 0) {
+ fm.setGeneratedValue(true);
+ }
+ model.addField(fm);
+ model.getIdFieldNames().add(fm.getName());
+ }
+
+ // ── <composite-id> ──────────────────────────────────────────────
+ NodeList compositeIdNodes = getDirectChildren(classEl, "composite-id");
+ for (int i = 0; i < compositeIdNodes.getLength(); i++) {
+ Element compEl = (Element) compositeIdNodes.item(i);
+ NodeList keyProps = compEl.getElementsByTagName("key-property");
+ for (int j = 0; j < keyProps.getLength(); j++) {
+ EntitySchemaModel.FieldModel fm = parsePropertyElement((Element) keyProps.item(j));
+ fm.setNullable(false);
+ model.addField(fm);
+ model.getIdFieldNames().add(fm.getName());
+ }
+ }
+
+ // ── <version> element ───────────────────────────────────────────
+ NodeList versionNodes = getDirectChildren(classEl, "version");
+ for (int i = 0; i < versionNodes.getLength(); i++) {
+ Element versionEl = (Element) versionNodes.item(i);
+ EntitySchemaModel.FieldModel fm = parsePropertyElement(versionEl);
+ fm.setVersionField(true);
+ model.addField(fm);
+ model.setVersionFieldName(fm.getName());
+ }
+
+ // ── <property> elements ─────────────────────────────────────────
+ NodeList propNodes = getDirectChildren(classEl, "property");
+ for (int i = 0; i < propNodes.getLength(); i++) {
+ Element propEl = (Element) propNodes.item(i);
+ EntitySchemaModel.FieldModel fm = parsePropertyElement(propEl);
+ model.addField(fm);
+ }
+
+ // ── <many-to-one> elements ──────────────────────────────────────
+ NodeList manyToOneNodes = getDirectChildren(classEl, "many-to-one");
+ for (int i = 0; i < manyToOneNodes.getLength(); i++) {
+ Element mtoEl = (Element) manyToOneNodes.item(i);
+ EntitySchemaModel.FieldModel fm = new EntitySchemaModel.FieldModel(
+ mtoEl.getAttribute("name"));
+ fm.setJsonSchemaType("object");
+
+ // Resolve target entity name from class attribute
+ String targetClass = mtoEl.getAttribute("class");
+ if (targetClass != null && !targetClass.isEmpty()) {
+ fm.setRefEntityName(simpleClassName(targetClass));
+ }
+
+ String notNull = mtoEl.getAttribute("not-null");
+ fm.setNullable(!"true".equals(notNull));
+
+ model.addField(fm);
+ }
+
+ // ── <set>, <bag>, <list> with <one-to-many> ─────────────────────
+ for (String collTag : new String[]{"set", "bag", "list"}) {
+ NodeList collNodes = getDirectChildren(classEl, collTag);
+ for (int i = 0; i < collNodes.getLength(); i++) {
+ Element collEl = (Element) collNodes.item(i);
+ EntitySchemaModel.FieldModel fm = new EntitySchemaModel.FieldModel(
+ collEl.getAttribute("name"));
+ fm.setJsonSchemaType("array");
+ fm.setCollection(true);
+
+ // Find <one-to-many> child for target class
+ NodeList otmNodes = collEl.getElementsByTagName("one-to-many");
+ if (otmNodes.getLength() > 0) {
+ Element otmEl = (Element) otmNodes.item(0);
+ String targetClass = otmEl.getAttribute("class");
+ if (targetClass != null && !targetClass.isEmpty()) {
+ fm.setRefEntityName(simpleClassName(targetClass));
+ }
+ }
+
+ // Find <many-to-many> child for target class
+ NodeList mtmNodes = collEl.getElementsByTagName("many-to-many");
+ if (mtmNodes.getLength() > 0) {
+ Element mtmEl = (Element) mtmNodes.item(0);
+ String targetClass = mtmEl.getAttribute("class");
+ if (targetClass != null && !targetClass.isEmpty()) {
+ fm.setRefEntityName(simpleClassName(targetClass));
+ }
+ }
+
+ model.addField(fm);
+ }
+ }
+
+ // ── <component> elements (embedded objects) ─────────────────────
+ // Flatten component properties into the parent schema with a prefix.
+ NodeList componentNodes = getDirectChildren(classEl, "component");
+ for (int i = 0; i < componentNodes.getLength(); i++) {
+ Element compEl = (Element) componentNodes.item(i);
+ String prefix = compEl.getAttribute("name");
+ NodeList compProps = compEl.getElementsByTagName("property");
+ for (int j = 0; j < compProps.getLength(); j++) {
+ EntitySchemaModel.FieldModel fm = parsePropertyElement((Element) compProps.item(j));
+ // Prefix the field name for clarity: companyAddress.address1
+ fm.setName(prefix + "." + fm.getName());
+ model.addField(fm);
+ }
+ }
+
+ log.debug("Parsed HBM XML '" + resourceName + "' → entity '"
+ + model.getEntityName() + "' with " + model.getFields().size() + " fields");
+ return model;
+ }
+
+ private EntitySchemaModel.FieldModel parsePropertyElement(Element propEl) {
+ String name = propEl.getAttribute("name");
+ EntitySchemaModel.FieldModel fm = new EntitySchemaModel.FieldModel(name);
+
+ // Type mapping
+ String hbmType = propEl.getAttribute("type");
+ if (hbmType != null && !hbmType.isEmpty()) {
+ String[] mapping = TYPE_MAP.get(hbmType);
+ if (mapping != null) {
+ fm.setJsonSchemaType(mapping[0]);
+ fm.setJsonSchemaFormat(mapping[1]);
+ } else {
+ // Unknown type — default to string
+ fm.setJsonSchemaType("string");
+ log.debug("Unknown HBM type '" + hbmType + "' for property '"
+ + name + "', defaulting to string");
+ }
+ } else {
+ // No type attribute — check for nested <column> with sql-type
+ fm.setJsonSchemaType("string");
+ }
+
+ // Nullability: check attribute on <property>, then on nested <column>
+ String notNull = propEl.getAttribute("not-null");
+ if ("true".equals(notNull)) {
+ fm.setNullable(false);
+ } else {
+ // Check nested <column not-null="true">
+ NodeList columns = propEl.getElementsByTagName("column");
+ for (int i = 0; i < columns.getLength(); i++) {
+ Element colEl = (Element) columns.item(i);
+ if ("true".equals(colEl.getAttribute("not-null"))) {
+ fm.setNullable(false);
+ break;
+ }
+ }
+ }
+
+ // Length
+ String length = propEl.getAttribute("length");
+ if (length != null && !length.isEmpty() && "string".equals(fm.getJsonSchemaType())) {
+ try {
+ fm.setMaxLength(Integer.parseInt(length));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ return fm;
+ }
+
+ /** Get direct child elements with a specific tag name (not deep descendants). */
+ private NodeList getDirectChildren(Element parent, String tagName) {
+ // getElementsByTagName is recursive; we need direct children only.
+ // Using a simple wrapper that filters.
+ List<Element> result = new ArrayList<>();
+ org.w3c.dom.Node child = parent.getFirstChild();
+ while (child != null) {
+ if (child instanceof Element && tagName.equals(((Element) child).getTagName())) {
+ result.add((Element) child);
+ }
+ child = child.getNextSibling();
+ }
+ // Return as a simple NodeList wrapper
+ return new NodeList() {
+ @Override
+ public org.w3c.dom.Node item(int index) { return result.get(index); }
+ @Override
+ public int getLength() { return result.size(); }
+ };
+ }
+
+ /** Extract simple class name from a potentially fully-qualified name. */
+ private String simpleClassName(String fqcn) {
+ int dot = fqcn.lastIndexOf('.');
+ return dot >= 0 ? fqcn.substring(dot + 1) : fqcn;
+ }
+}
diff --git a/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/JpaSchemaGenerator.java b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/JpaSchemaGenerator.java
new file mode 100644
index 0000000000..8c09ed3f25
--- /dev/null
+++ b/modules/jpa-schema/src/main/java/org/apache/axis2/jpa/schema/JpaSchemaGenerator.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.jpa.schema;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import java.util.Map;
+
+/**
+ * Converts an {@link EntitySchemaModel} to JSON Schema (as Jackson ObjectNode trees).
+ *
+ * <p>Produces two schema variants per entity:
+ * <ul>
+ * <li><b>Read schema</b> ({@link #generateReadSchema}) — all fields including
+ * auto-generated IDs, version, and audit fields. Suitable for GET response bodies.</li>
+ * <li><b>Write schema</b> ({@link #generateWriteSchema}) — excludes fields that
+ * the server manages: {@code @GeneratedValue} IDs, {@code @Version} fields,
+ * and fields marked with custom "ignore for write" annotations.
+ * Suitable for POST/PUT request bodies.</li>
+ * </ul>
+ *
+ * <p>Relationships are emitted as {@code $ref} pointers using the convention
+ * {@code #/components/schemas/{EntityName}}. The caller is responsible for
+ * ensuring that referenced entity schemas are also generated and added to
+ * the OpenAPI components section.
+ *
+ * <p>Usage:
+ * <pre>{@code
+ * EntitySchemaModel model = annotationIntrospector.introspect(Fund.class);
+ * ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ * ObjectNode writeSchema = JpaSchemaGenerator.generateWriteSchema(model);
+ * }</pre>
+ */
+public class JpaSchemaGenerator {
+
+ private static final Log log = LogFactory.getLog(JpaSchemaGenerator.class);
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ /**
+ * Generate the read (response) schema — includes all fields.
+ */
+ public static ObjectNode generateReadSchema(EntitySchemaModel model) {
+ return generateSchema(model, false);
+ }
+
+ /**
+ * Generate the write (request) schema — excludes auto-generated and
+ * audit fields.
+ */
+ public static ObjectNode generateWriteSchema(EntitySchemaModel model) {
+ return generateSchema(model, true);
+ }
+
+ /**
+ * Generate a combined schema with both read and write variants as
+ * separate schemas: {@code {EntityName}} (read) and
+ * {@code {EntityName}Write} (write).
+ *
+ * @return a map of schema name → ObjectNode
+ */
+ public static Map<String, ObjectNode> generateBothSchemas(EntitySchemaModel model) {
+ Map<String, ObjectNode> schemas = new java.util.LinkedHashMap<>();
+ schemas.put(model.getEntityName(), generateReadSchema(model));
+ schemas.put(model.getEntityName() + "Write", generateWriteSchema(model));
+ return schemas;
+ }
+
+ // ── Private implementation ───────────────────────────────────────────────
+
+ private static ObjectNode generateSchema(EntitySchemaModel model, boolean writeMode) {
+ ObjectNode schema = mapper.createObjectNode();
+ schema.put("type", "object");
+ schema.put("description", (writeMode ? "Write schema for " : "")
+ + model.getEntityName()
+ + (model.getTableName() != null
+ ? " (table: " + model.getTableName() + ")" : ""));
+
+ ObjectNode properties = schema.putObject("properties");
+ ArrayNode required = schema.putArray("required");
+
+ for (EntitySchemaModel.FieldModel field : model.getFields().values()) {
+ // Skip @Transient fields from all schemas
+ if (field.isTransientField()) {
+ continue;
+ }
+
+ // In write mode, skip server-managed fields
+ if (writeMode && field.isExcludedFromWrite()) {
+ continue;
+ }
+
+ ObjectNode prop = generateFieldSchema(field, writeMode);
+ properties.set(field.getName(), prop);
+
+ // Add to required array if not nullable
+ if (!field.isNullable()) {
+ required.add(field.getName());
+ }
+ }
+
+ // Remove empty required array (cleaner output)
+ if (required.size() == 0) {
+ schema.remove("required");
+ }
+
+ return schema;
+ }
+
+ private static ObjectNode generateFieldSchema(EntitySchemaModel.FieldModel field,
+ boolean writeMode) {
+ ObjectNode prop = mapper.createObjectNode();
+
+ // ── Relationship: $ref to another entity schema ─────────────────
+ if (field.getRefEntityName() != null) {
+ if (field.isCollection()) {
+ // OneToMany / ManyToMany → array of $ref
+ prop.put("type", "array");
+ ObjectNode items = prop.putObject("items");
+ String refTarget = writeMode
+ ? field.getRefEntityName() + "Write"
+ : field.getRefEntityName();
+ items.put("$ref", "#/components/schemas/" + refTarget);
+ } else {
+ // ManyToOne / OneToOne → $ref
+ String refTarget = writeMode
+ ? field.getRefEntityName() + "Write"
+ : field.getRefEntityName();
+ prop.put("$ref", "#/components/schemas/" + refTarget);
+ }
+ return prop;
+ }
+
+ // ── Enum ────────────────────────────────────────────────────────
+ if (field.getEnumValues() != null && !field.getEnumValues().isEmpty()) {
+ prop.put("type", "string");
+ ArrayNode enumNode = prop.putArray("enum");
+ for (String val : field.getEnumValues()) {
+ enumNode.add(val);
+ }
+ return prop;
+ }
+
+ // ── Simple type ─────────────────────────────────────────────────
+ prop.put("type", field.getJsonSchemaType());
+ if (field.getJsonSchemaFormat() != null) {
+ prop.put("format", field.getJsonSchemaFormat());
+ }
+ if (field.getMaxLength() != null) {
+ prop.put("maxLength", field.getMaxLength());
+ }
+
+ // Mark auto-generated IDs as readOnly in read schemas
+ // (they don't appear in write schemas at all)
+ if (!writeMode && field.isGeneratedValue()) {
+ prop.put("readOnly", true);
+ }
+
+ return prop;
+ }
+}
diff --git a/modules/jpa-schema/src/test/java/org/apache/axis2/jpa/schema/JpaSchemaGeneratorTest.java b/modules/jpa-schema/src/test/java/org/apache/axis2/jpa/schema/JpaSchemaGeneratorTest.java
new file mode 100644
index 0000000000..aa8176fe6a
--- /dev/null
+++ b/modules/jpa-schema/src/test/java/org/apache/axis2/jpa/schema/JpaSchemaGeneratorTest.java
@@ -0,0 +1,606 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.jpa.schema;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import jakarta.persistence.Transient;
+import jakarta.persistence.Version;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests for the full JPA schema generation pipeline:
+ * annotation introspection, HBM XML introspection, and schema generation.
+ */
+public class JpaSchemaGeneratorTest {
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Test entities (annotation-based)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ /** Custom "ignore for write" annotation — mimics project-specific audit markers. */
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.FIELD)
+ public @interface IgnoreChanges {}
+
+ public enum ProductStatus { ACTIVE, INACTIVE, SUSPENDED }
+
+ @Entity
+ @Table(name = "PRODUCT")
+ public static class TestProduct {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private BigDecimal productID;
+
+ @Column(nullable = false, length = 200)
+ private String name;
+
+ private Double maxExposure;
+
+ @Enumerated(EnumType.STRING)
+ private ProductStatus status;
+
+ @Version
+ private Long objVersion;
+
+ @IgnoreChanges
+ private Timestamp created;
+
+ @IgnoreChanges
+ private BigDecimal createdUserID;
+
+ @Transient
+ private String computedLabel;
+
+ @ManyToOne
+ private TestDepartment department;
+
+ @OneToMany
+ private List<TestLineItem> lineItems;
+ }
+
+ @Entity
+ public static class TestDepartment {
+ @Id
+ @GeneratedValue
+ private Long departmentID;
+ private String name;
+ }
+
+ @Entity
+ public static class TestLineItem {
+ @Id
+ @GeneratedValue
+ private Long lineItemID;
+ private Double weight;
+ }
+
+ /** Non-entity class — should be rejected by introspector. */
+ public static class NotAnEntity {
+ private String name;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Annotation introspector tests
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testAnnotationIntrospector_entityDetection() {
+ assertTrue(AnnotationIntrospector.isEntity(TestProduct.class));
+ assertFalse(AnnotationIntrospector.isEntity(NotAnEntity.class));
+ }
+
+ @Test
+ public void testAnnotationIntrospector_nonEntityReturnsNull() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ assertNull(introspector.introspect(NotAnEntity.class));
+ }
+
+ @Test
+ public void testAnnotationIntrospector_entityNameAndTable() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+ assertNotNull(model);
+ assertEquals("TestProduct", model.getEntityName());
+ assertEquals("PRODUCT", model.getTableName());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_idField() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+ assertEquals(1, model.getIdFieldNames().size());
+ assertEquals("productID", model.getIdFieldNames().get(0));
+
+ EntitySchemaModel.FieldModel idField = model.getFields().get("productID");
+ assertNotNull(idField);
+ assertTrue(idField.isGeneratedValue());
+ assertFalse(idField.isNullable());
+ assertEquals("number", idField.getJsonSchemaType()); // BigDecimal → number
+ }
+
+ @Test
+ public void testAnnotationIntrospector_columnConstraints() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ EntitySchemaModel.FieldModel nameField = model.getFields().get("name");
+ assertNotNull(nameField);
+ assertEquals("string", nameField.getJsonSchemaType());
+ assertFalse(nameField.isNullable());
+ assertEquals(Integer.valueOf(200), nameField.getMaxLength());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_versionField() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+ assertEquals("objVersion", model.getVersionFieldName());
+
+ EntitySchemaModel.FieldModel vf = model.getFields().get("objVersion");
+ assertTrue(vf.isVersionField());
+ assertTrue(vf.isExcludedFromWrite());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_transientField() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ EntitySchemaModel.FieldModel tf = model.getFields().get("computedLabel");
+ assertTrue(tf.isTransientField());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_customWriteExcludeAnnotation() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ introspector.addWriteExcludeAnnotation(IgnoreChanges.class.getName());
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ EntitySchemaModel.FieldModel createdField = model.getFields().get("created");
+ assertTrue("@IgnoreChanges should exclude from write", createdField.isIgnoredForWrite());
+ assertTrue(createdField.isExcludedFromWrite());
+
+ EntitySchemaModel.FieldModel createdUserId = model.getFields().get("createdUserID");
+ assertTrue(createdUserId.isIgnoredForWrite());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_manyToOneRelationship() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ EntitySchemaModel.FieldModel deptField = model.getFields().get("department");
+ assertNotNull(deptField);
+ assertEquals("object", deptField.getJsonSchemaType());
+ assertEquals("TestDepartment", deptField.getRefEntityName());
+ assertFalse(deptField.isCollection());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_oneToManyRelationship() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ EntitySchemaModel.FieldModel itemsField = model.getFields().get("lineItems");
+ assertNotNull(itemsField);
+ assertEquals("array", itemsField.getJsonSchemaType());
+ assertEquals("TestLineItem", itemsField.getRefEntityName());
+ assertTrue(itemsField.isCollection());
+ }
+
+ @Test
+ public void testAnnotationIntrospector_enumField() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ EntitySchemaModel.FieldModel statusField = model.getFields().get("status");
+ assertNotNull(statusField);
+ assertEquals("string", statusField.getJsonSchemaType());
+ assertNotNull(statusField.getEnumValues());
+ assertEquals(3, statusField.getEnumValues().size());
+ assertTrue(statusField.getEnumValues().contains("ACTIVE"));
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Schema generator tests (from annotations)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testReadSchema_includesAllFields() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ introspector.addWriteExcludeAnnotation(IgnoreChanges.class.getName());
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ JsonNode props = readSchema.get("properties");
+
+ // Read schema should include everything except @Transient
+ assertTrue(props.has("productID"));
+ assertTrue(props.has("name"));
+ assertTrue(props.has("created")); // audit field — present in read
+ assertTrue(props.has("objVersion")); // version — present in read
+ assertFalse(props.has("computedLabel")); // @Transient — never present
+
+ // ID should be marked readOnly
+ assertTrue(props.get("productID").has("readOnly"));
+ assertTrue(props.get("productID").get("readOnly").asBoolean());
+ }
+
+ @Test
+ public void testWriteSchema_excludesServerManagedFields() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ introspector.addWriteExcludeAnnotation(IgnoreChanges.class.getName());
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ ObjectNode writeSchema = JpaSchemaGenerator.generateWriteSchema(model);
+ JsonNode props = writeSchema.get("properties");
+
+ // Write schema should exclude server-managed fields
+ assertFalse("@GeneratedValue ID excluded", props.has("productID"));
+ assertFalse("@Version excluded", props.has("objVersion"));
+ assertFalse("@IgnoreChanges excluded", props.has("created"));
+ assertFalse("@IgnoreChanges excluded", props.has("createdUserID"));
+ assertFalse("@Transient excluded", props.has("computedLabel"));
+
+ // Regular fields should be present
+ assertTrue(props.has("name"));
+ assertTrue(props.has("maxExposure"));
+ assertTrue(props.has("status"));
+ assertTrue(props.has("department"));
+ assertTrue(props.has("lineItems"));
+ }
+
+ @Test
+ public void testSchema_requiredArray() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ JsonNode required = readSchema.get("required");
+ assertNotNull(required);
+
+ boolean hasProductID = false;
+ boolean hasName = false;
+ for (JsonNode r : required) {
+ if ("productID".equals(r.asText())) hasProductID = true;
+ if ("name".equals(r.asText())) hasName = true;
+ }
+ assertTrue("productID should be required", hasProductID);
+ assertTrue("name should be required", hasName);
+ }
+
+ @Test
+ public void testSchema_relationshipRefs() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ JsonNode props = readSchema.get("properties");
+
+ // ManyToOne → $ref
+ JsonNode dept = props.get("department");
+ assertEquals("#/components/schemas/TestDepartment", dept.get("$ref").asText());
+
+ // OneToMany → array of $ref
+ JsonNode items = props.get("lineItems");
+ assertEquals("array", items.get("type").asText());
+ assertEquals("#/components/schemas/TestLineItem",
+ items.get("items").get("$ref").asText());
+ }
+
+ @Test
+ public void testSchema_enumValues() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ JsonNode statusProp = readSchema.get("properties").get("status");
+ assertTrue(statusProp.has("enum"));
+ assertEquals(3, statusProp.get("enum").size());
+ }
+
+ @Test
+ public void testBothSchemas_returnsReadAndWrite() {
+ AnnotationIntrospector introspector = new AnnotationIntrospector();
+ EntitySchemaModel model = introspector.introspect(TestProduct.class);
+
+ Map<String, ObjectNode> schemas = JpaSchemaGenerator.generateBothSchemas(model);
+ assertTrue(schemas.containsKey("TestProduct"));
+ assertTrue(schemas.containsKey("TestProductWrite"));
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // HBM XML introspector tests
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testHbmXml_parsesCompanyEntity() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/CompanyBO.hbm.xml")) {
+ assertNotNull("Test HBM XML file must be on classpath", is);
+
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "CompanyBO.hbm.xml");
+
+ assertNotNull(model);
+ assertEquals("CompanyBO", model.getEntityName());
+ assertFalse("Should have at least one ID field", model.getIdFieldNames().isEmpty());
+ assertNotNull("Should have a version field", model.getVersionFieldName());
+ assertTrue("Should have multiple fields", model.getFields().size() > 3);
+ }
+ }
+
+ @Test
+ public void testHbmXml_idFieldIsGeneratedAndRequired() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/CompanyBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "CompanyBO.hbm.xml");
+
+ String idName = model.getIdFieldNames().get(0);
+ EntitySchemaModel.FieldModel idField = model.getFields().get(idName);
+ assertTrue("ID field should be generated", idField.isGeneratedValue());
+ assertFalse("ID field should not be nullable", idField.isNullable());
+ }
+ }
+
+ @Test
+ public void testHbmXml_versionFieldExcludedFromWrite() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/CompanyBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "CompanyBO.hbm.xml");
+
+ EntitySchemaModel.FieldModel vf = model.getFields().get(model.getVersionFieldName());
+ assertNotNull(vf);
+ assertTrue(vf.isVersionField());
+ assertTrue(vf.isExcludedFromWrite());
+ }
+ }
+
+ @Test
+ public void testHbmXml_generatesBothSchemas() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/CompanyBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "CompanyBO.hbm.xml");
+
+ ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ ObjectNode writeSchema = JpaSchemaGenerator.generateWriteSchema(model);
+
+ JsonNode readProps = readSchema.get("properties");
+ JsonNode writeProps = writeSchema.get("properties");
+
+ // Read schema should contain generated ID and version; write should not
+ assertTrue("Read schema must contain the generated ID field 'id'",
+ readProps.has("id"));
+ assertFalse("Write schema must NOT contain the generated ID field 'id'",
+ writeProps.has("id"));
+ assertTrue("Read schema must contain the version field 'version'",
+ readProps.has("version"));
+ assertFalse("Write schema must NOT contain the version field 'version'",
+ writeProps.has("version"));
+ }
+ }
+
+ @Test
+ public void testHbmXml_propertyTypesMapCorrectly() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/CompanyBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "CompanyBO.hbm.xml");
+
+ EntitySchemaModel.FieldModel nameField = model.getFields().get("name");
+ if (nameField != null) {
+ assertEquals("string", nameField.getJsonSchemaType());
+ }
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // HBM XML — production-grade mapping (DepartmentBO: 70+ fields)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testHbmXml_department_parsesLargeEntity() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ assertNotNull("DepartmentBO.hbm.xml must be on classpath", is);
+
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ assertNotNull(model);
+ assertEquals("DepartmentBO", model.getEntityName());
+ assertEquals("DEPARTMENT", model.getTableName());
+
+ // 70+ fields: properties + relationships + component fields
+ assertTrue("Should have 40+ fields, got " + model.getFields().size(),
+ model.getFields().size() >= 40);
+ }
+ }
+
+ @Test
+ public void testHbmXml_department_allTypesMapped() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ // String property
+ assertEquals("string", model.getFields().get("name").getJsonSchemaType());
+ assertFalse("name is not-null", model.getFields().get("name").isNullable());
+
+ // Timestamp property
+ assertEquals("string", model.getFields().get("created").getJsonSchemaType());
+ assertEquals("date-time", model.getFields().get("created").getJsonSchemaFormat());
+
+ // Boolean property
+ assertEquals("boolean", model.getFields().get("active").getJsonSchemaType());
+
+ // Double property
+ assertEquals("number", model.getFields().get("usageScore").getJsonSchemaType());
+
+ // Long property
+ assertEquals("integer", model.getFields().get("loginCount1Week").getJsonSchemaType());
+ assertEquals("int64", model.getFields().get("loginCount1Week").getJsonSchemaFormat());
+
+ // text type (maps to string)
+ assertEquals("string", model.getFields().get("historicalDataOptions").getJsonSchemaType());
+ }
+ }
+
+ @Test
+ public void testHbmXml_department_manyToOneRelationships() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ // 4 many-to-one relationships
+ EntitySchemaModel.FieldModel company = model.getFields().get("company");
+ assertNotNull("company relationship must be parsed", company);
+ assertEquals("object", company.getJsonSchemaType());
+ assertEquals("CompanyBO", company.getRefEntityName());
+ assertFalse("company is not-null", company.isNullable());
+
+ EntitySchemaModel.FieldModel policy = model.getFields().get("passwordPolicy");
+ assertNotNull(policy);
+ assertEquals("PasswordPolicyBO", policy.getRefEntityName());
+ assertTrue("passwordPolicy is nullable", policy.isNullable());
+ }
+ }
+
+ @Test
+ public void testHbmXml_department_collectionSets() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ // 4 <set> collections
+ EntitySchemaModel.FieldModel users = model.getFields().get("users");
+ assertNotNull("users collection must be parsed", users);
+ assertEquals("array", users.getJsonSchemaType());
+ assertTrue(users.isCollection());
+ assertEquals("UserBO", users.getRefEntityName());
+
+ EntitySchemaModel.FieldModel portfolios = model.getFields().get("portfolios");
+ assertNotNull("portfolios collection must be parsed", portfolios);
+ assertEquals("PortfolioBO", portfolios.getRefEntityName());
+
+ EntitySchemaModel.FieldModel customFields = model.getFields().get("customFields");
+ assertNotNull(customFields);
+ assertEquals("CustomFieldBO", customFields.getRefEntityName());
+
+ EntitySchemaModel.FieldModel ipRanges = model.getFields().get("ipAddressRanges");
+ assertNotNull(ipRanges);
+ assertEquals("IPAddressRangeBO", ipRanges.getRefEntityName());
+ }
+ }
+
+ @Test
+ public void testHbmXml_department_componentFlattened() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ // Component fields are flattened with prefix: departmentAddress.city
+ EntitySchemaModel.FieldModel city = model.getFields().get("departmentAddress.city");
+ assertNotNull("component field departmentAddress.city must be present", city);
+ assertEquals("string", city.getJsonSchemaType());
+
+ EntitySchemaModel.FieldModel zip = model.getFields().get("departmentAddress.zip");
+ assertNotNull(zip);
+ assertEquals("integer", zip.getJsonSchemaType());
+ }
+ }
+
+ @Test
+ public void testHbmXml_department_nestedColumnNotNull() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ // "deleted" uses nested <column not-null="true"> instead of attribute
+ EntitySchemaModel.FieldModel deleted = model.getFields().get("deleted");
+ assertNotNull(deleted);
+ assertEquals("boolean", deleted.getJsonSchemaType());
+ assertFalse("deleted with nested column not-null=true should be required",
+ deleted.isNullable());
+ }
+ }
+
+ @Test
+ public void testHbmXml_department_schemaGeneration() throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/DepartmentBO.hbm.xml")) {
+ HbmXmlIntrospector introspector = new HbmXmlIntrospector();
+ EntitySchemaModel model = introspector.introspect(is, "DepartmentBO.hbm.xml");
+
+ ObjectNode readSchema = JpaSchemaGenerator.generateReadSchema(model);
+ ObjectNode writeSchema = JpaSchemaGenerator.generateWriteSchema(model);
+
+ JsonNode readProps = readSchema.get("properties");
+ JsonNode writeProps = writeSchema.get("properties");
+
+ // Read schema has everything
+ assertTrue(readProps.has("id"));
+ assertTrue(readProps.has("version"));
+ assertTrue(readProps.has("name"));
+ assertTrue(readProps.has("users"));
+ assertTrue(readProps.has("company"));
+ assertTrue(readProps.has("departmentAddress.city"));
+
+ // Write schema excludes generated ID and version
+ assertFalse(writeProps.has("id"));
+ assertFalse(writeProps.has("version"));
+
+ // Write schema still has regular fields, relationships, collections
+ assertTrue(writeProps.has("name"));
+ assertTrue(writeProps.has("users"));
+ assertTrue(writeProps.has("company"));
+ assertTrue(writeProps.has("departmentAddress.city"));
+
+ // Relationship refs in read schema
+ assertEquals("#/components/schemas/CompanyBO",
+ readProps.get("company").get("$ref").asText());
+ assertEquals("array", readProps.get("users").get("type").asText());
+ assertEquals("#/components/schemas/UserBO",
+ readProps.get("users").get("items").get("$ref").asText());
+ }
+ }
+}
diff --git a/modules/jpa-schema/src/test/resources/CompanyBO.hbm.xml b/modules/jpa-schema/src/test/resources/CompanyBO.hbm.xml
new file mode 100644
index 0000000000..e001f7578c
--- /dev/null
+++ b/modules/jpa-schema/src/test/resources/CompanyBO.hbm.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0"?>
+<!DOCTYPE hibernate-mapping PUBLIC
+ "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
+ "http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">
+
+<hibernate-mapping package="com.example.bo">
+
+ <class name="CompanyBO" table="`COMPANY`"
+ dynamic-insert="true"
+ optimistic-lock="version"
+ lazy="true"
+ batch-size="300">
+
+ <id name="id" type="java.lang.Long" column="`companyID`">
+ <generator class="native"/>
+ </id>
+
+ <version name="version" type="java.lang.Long" access="field" column="OBJ_VERSION"/>
+
+ <property name="name" column="`name`"
+ type="java.lang.String"
+ not-null="true"/>
+
+ <property name="shortName" column="`shortName`"
+ type="java.lang.String"
+ not-null="false"/>
+
+ <property name="created" column="`created`"
+ type="timestamp" not-null="true" length="6"/>
+
+ <property name="require2FactorAuth" column="`require2FactorAuth`"
+ type="java.lang.Boolean" not-null="false"/>
+
+ <property name="maxUsers" column="`maxUsers`"
+ type="java.lang.Integer" not-null="false"/>
+
+ <property name="annualFee" column="`annualFee`"
+ type="java.lang.Double" not-null="false"/>
+
+ <property name="deleted" type="java.lang.Boolean">
+ <column name="deleted" not-null="true" default="0"/>
+ </property>
+
+ <many-to-one
+ name="createdUser"
+ column="`createdUserID`"
+ class="com.example.bo.UserBO"
+ not-null="true"/>
+
+ <many-to-one
+ name="passwordPolicy"
+ class="com.example.bo.PasswordPolicyBO"
+ column="`passwordPolicyID`"
+ not-null="false"/>
+
+ <set name="departments" table="`Department`"
+ inverse="true" cascade="all-delete-orphan" lazy="true">
+ <key column="`companyID`"/>
+ <one-to-many class="com.example.bo.DepartmentBO"/>
+ </set>
+
+ <component name="companyAddress" class="com.example.bo.component.Address">
+ <property name="address1" type="java.lang.String" column="`company_address1`" not-null="false"/>
+ <property name="city" type="java.lang.String" column="`company_city`" not-null="false"/>
+ <property name="state" type="java.lang.String" column="`company_state`" not-null="false"/>
+ </component>
+
+ </class>
+
+</hibernate-mapping>
diff --git a/modules/jpa-schema/src/test/resources/DepartmentBO.hbm.xml b/modules/jpa-schema/src/test/resources/DepartmentBO.hbm.xml
new file mode 100644
index 0000000000..ae1f61ce0c
--- /dev/null
+++ b/modules/jpa-schema/src/test/resources/DepartmentBO.hbm.xml
@@ -0,0 +1,213 @@
+<?xml version="1.0"?>
+<!DOCTYPE hibernate-mapping PUBLIC
+ "-//Hibernate/Hibernate Mapping DTD//EN"
+ "http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd" >
+
+<!-- Sanitized production-grade HBM mapping: 70+ fields, multiple types,
+ collections, components, sql-type overrides, nested column defaults.
+ Tests that the parser handles real-world complexity. -->
+
+<hibernate-mapping package="com.example.bo">
+ <class name="DepartmentBO"
+ table="`DEPARTMENT`"
+ dynamic-update="false"
+ dynamic-insert="true"
+ select-before-update="false"
+ optimistic-lock="version"
+ lazy="true"
+ batch-size="300"
+ >
+
+ <id name="id" type="java.lang.Long" column="`departmentID`">
+ <generator class="native">
+ ${department_departmentid_seq}
+ </generator>
+ </id>
+ <version name="version" type="java.lang.Long" access="field" column="OBJ_VERSION"/>
+
+ <property name="name" column="`name`"
+ type="java.lang.String"
+ not-null="true" />
+
+ <property name="codeName" column="`codeName`"
+ type="java.lang.String"
+ not-null="false" />
+
+ <property name="created" column="`created`"
+ type="timestamp" not-null="true" length="6" />
+
+ <property name="modified" column="`modified`"
+ type="timestamp" not-null="true" length="6" />
+
+ <property name="trialBegin" column="`trialBegin`"
+ type="timestamp" not-null="false" length="6" />
+
+ <property name="trialEnd" column="`trialEnd`"
+ type="timestamp" not-null="false" length="6" />
+
+ <property name="active" column="`active`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="restrictDetails" column="`restrictDetails`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="dataDeletionRequested" column="`dataDeletionRequested`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="historicalDataApproved" column="`historicalDataApproved`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="historicalDataOptions" column="`historicalDataOptions`"
+ type="text" not-null="false" />
+
+ <property name="aggregateJobRunning" column="`aggregateJobRunning`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="aggregateJobQueued" column="`aggregateJobQueued`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="useBeta" column="`useBeta`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="isReportable" column="`isReportable`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="ssoEnabled" column="`ssoEnabled`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <!-- sql-type override: nvarchar(max) — no length constraint -->
+ <property name="ssoMetadataXML" type="java.lang.String">
+ <column name="`ssoMetadataXML`" sql-type="nvarchar(max)" not-null="false" />
+ </property>
+
+ <property name="ssoLoginURL" column="`ssoLoginURL`"
+ type="java.lang.String" not-null="false" length="8000" />
+
+ <property name="ssoLogoutURL" column="`ssoLogoutURL`"
+ type="java.lang.String" not-null="false" length="8000" />
+
+ <property name="usageScore" column="`usageScore`"
+ type="java.lang.Double" not-null="false" />
+
+ <property name="loginCount1Week" column="`loginCount1Week`"
+ type="java.lang.Long" not-null="false" />
+ <property name="loginCount4Week" column="`loginCount4Week`"
+ type="java.lang.Long" not-null="false" />
+ <property name="loginCount12Week" column="`loginCount12Week`"
+ type="java.lang.Long" not-null="false" />
+ <property name="loginCount52Week" column="`loginCount52Week`"
+ type="java.lang.Long" not-null="false" />
+
+ <property name="loginAvg1Week" column="`loginAvg1Week`"
+ type="java.lang.Double" not-null="false" />
+ <property name="loginAvg4Week" column="`loginAvg4Week`"
+ type="java.lang.Double" not-null="false" />
+ <property name="loginAvg12Week" column="`loginAvg12Week`"
+ type="java.lang.Double" not-null="false" />
+ <property name="loginAvg52Week" column="`loginAvg52Week`"
+ type="java.lang.Double" not-null="false" />
+
+ <property name="userCount1Week" column="`userCount1Week`"
+ type="java.lang.Long" not-null="false" />
+ <property name="userCount4Week" column="`userCount4Week`"
+ type="java.lang.Long" not-null="false" />
+
+ <property name="nonLocked" column="`nonLocked`"
+ type="java.lang.Boolean" not-null="true" />
+
+ <property name="dataModified" column="`dataModified`"
+ type="timestamp" not-null="false" length="6" />
+
+ <property name="modifiedAppInstance" column="`modifiedAppInstance`"
+ type="java.lang.String" not-null="false" />
+
+ <property name="createdDBUserName" column="`createdDBUserName`"
+ type="java.lang.String" not-null="false" />
+
+ <property name="modifiedDBUserName" column="`modifiedDBUserName`"
+ type="java.lang.String" not-null="false" />
+
+ <property name="require2FactorAuth" column="`require2FactorAuth`"
+ type="java.lang.Boolean" not-null="false" />
+
+ <property name="deleted" type="java.lang.Boolean">
+ <column name="deleted" not-null="true" default="0"/>
+ </property>
+
+ <property name="configurationOverrides" column="`configurationOverrides`"
+ type="text" not-null="false" />
+
+ <property name="layoutOverrides" column="`layoutOverrides`"
+ type="text" not-null="false" />
+
+ <property name="customMessage" column="`customMessage`"
+ type="text" not-null="false" />
+
+ <property name="customMessageExpiration" column="`customMessageExpiration`"
+ type="timestamp" not-null="false" length="6" />
+
+ <!-- Multiple many-to-one relationships -->
+ <many-to-one name="createdUser" column="`createdUserID`"
+ class="com.example.bo.UserBO" not-null="true"/>
+
+ <many-to-one name="modifiedUser" column="`modifiedUserID`"
+ class="com.example.bo.UserBO" not-null="true"/>
+
+ <many-to-one name="company" column="`companyID`"
+ class="com.example.bo.CompanyBO"
+ cascade="save-update" not-null="true"/>
+
+ <many-to-one name="passwordPolicy" column="`passwordPolicyID`"
+ class="com.example.bo.PasswordPolicyBO"
+ cascade="save-update" not-null="false"/>
+
+ <!-- Embedded component -->
+ <component name="departmentAddress" class="com.example.bo.component.Address">
+ <property name="address1" type="java.lang.String"
+ column="`department_address1`" not-null="false"/>
+ <property name="address2" type="java.lang.String"
+ column="`department_address2`" not-null="false"/>
+ <property name="city" type="java.lang.String"
+ column="`department_city`" not-null="false"/>
+ <property name="state" type="java.lang.String"
+ column="`department_state`" not-null="false"/>
+ <property name="zip" type="java.lang.Integer"
+ column="`department_zip`" not-null="false"/>
+ <property name="country" type="java.lang.String"
+ column="`department_country`" not-null="false"/>
+ <property name="phone" type="java.lang.String"
+ column="`department_phone`" not-null="false"/>
+ </component>
+
+ <!-- Collection mappings (one-to-many via <set>) -->
+ <set name="ipAddressRanges" fetch="subselect" table="`IPADDRESSRANGE`"
+ cascade="all-delete-orphan" inverse="true" lazy="true">
+ <key column="`departmentID`"/>
+ <one-to-many class="com.example.bo.IPAddressRangeBO"/>
+ <filter name="deletedFilter"/>
+ </set>
+
+ <set name="users" fetch="subselect" table="`APPUSER`"
+ cascade="all-delete-orphan" inverse="true" lazy="true">
+ <key column="`departmentID`"/>
+ <one-to-many class="com.example.bo.UserBO"/>
+ <filter name="deletedFilter"/>
+ </set>
+
+ <set name="customFields" fetch="subselect" table="`CUSTOMFIELD`"
+ cascade="all-delete-orphan" inverse="true" lazy="true">
+ <key column="`departmentID`"/>
+ <one-to-many class="com.example.bo.CustomFieldBO"/>
+ <filter name="deletedFilter"/>
+ </set>
+
+ <set name="portfolios" fetch="subselect" table="`PORTFOLIO`"
+ cascade="all-delete-orphan" inverse="true" lazy="true">
+ <key column="`departmentID`"/>
+ <one-to-many class="com.example.bo.PortfolioBO"/>
+ <filter name="deletedFilter"/>
+ </set>
+
+ <filter name="deletedFilter"/>
+ </class>
+</hibernate-mapping>
diff --git a/modules/json/pom.xml b/modules/json/pom.xml
index ad11d20781..62abfd72c8 100644
--- a/modules/json/pom.xml
+++ b/modules/json/pom.xml
@@ -123,6 +123,11 @@
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java b/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
new file mode 100644
index 0000000000..ff242a7eb5
--- /dev/null
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/Axis2JsonErrorResponse.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.json.gson.rpc;
+
+import java.time.Instant;
+import java.util.UUID;
+
+/**
+ * Structured JSON error response for Axis2 JSON-RPC services.
+ *
+ * <p>This is the canonical error envelope that every JSON-RPC error path
+ * serializes to the client. It replaces the previous pattern of returning
+ * HTTP 200 with {@code {"status":"FAILED","error_message":"..."}} inside
+ * domain-specific response objects (e.g. {@code PortfolioVarianceResponse.failed()}).
+ *
+ * <h3>Error taxonomy</h3>
+ * <ul>
+ * <li>400 BAD_REQUEST — malformed JSON or missing required envelope fields</li>
+ * <li>422 VALIDATION_ERROR — valid JSON, fails business-rule checks</li>
+ * <li>429 RATE_LIMITED — too many requests; {@code retryAfter} tells when to retry</li>
+ * <li>500 INTERNAL_ERROR — opaque; real details in server log keyed by errorRef</li>
+ * <li>503 SERVICE_UNAVAILABLE — downstream dependency or overload</li>
+ * </ul>
+ *
+ * <p>The {@code errorRef} UUID is logged server-side for correlation
+ * while keeping client-facing messages safe from information disclosure (CWE-209).
+ * For 500/400, only the errorRef is exposed; for 422, the full validation
+ * message is safe to return since it describes the caller's own input.
+ *
+ * <h3>Integration points</h3>
+ * <ul>
+ * <li>{@link JsonRpcMessageReceiver} — catches {@link JsonRpcFaultException},
+ * sets this as RETURN_OBJECT + HTTP status on the outgoing MessageContext</li>
+ * <li>{@link JsonUtils#createSecureFault} — builds this for parse/internal errors</li>
+ * <li>OpenAPI spec — {@code components/schemas/ErrorResponse} mirrors this shape</li>
+ * <li>MCP catalog — {@code _meta.errorContract} describes these fields</li>
+ * </ul>
+ *
+ * <p>Wire format:
+ * <pre>{@code
+ * {
+ * "error": "VALIDATION_ERROR",
+ * "message": "weights sum to 1.60000000, expected 1.0",
+ * "errorRef": "a1b2c3d4-...",
+ * "timestamp": "2026-05-04T12:00:00Z",
+ * "retryAfter": null
+ * }
+ * }</pre>
+ *
+ * @see JsonRpcFaultException
+ * @see JsonRpcMessageReceiver#invokeService
+ */
+public class Axis2JsonErrorResponse {
+
+ /** Error code — e.g. VALIDATION_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, INTERNAL_ERROR */
+ private String error;
+
+ /** Human-readable error message */
+ private String message;
+
+ /** Opaque correlation ID for server-side log lookup */
+ private String errorRef;
+
+ /** ISO 8601 timestamp of when the error occurred */
+ private String timestamp;
+
+ /** Optional: seconds until the client should retry (for 429/503) */
+ private Integer retryAfter;
+
+ public Axis2JsonErrorResponse() {
+ }
+
+ public Axis2JsonErrorResponse(String error, String message, String errorRef,
+ String timestamp, Integer retryAfter) {
+ this.error = error;
+ this.message = message;
+ this.errorRef = errorRef;
+ this.timestamp = timestamp;
+ this.retryAfter = retryAfter;
+ }
+
+ /**
+ * Build a validation error (HTTP 422).
+ */
+ public static Axis2JsonErrorResponse validationError(String message) {
+ return new Axis2JsonErrorResponse(
+ "VALIDATION_ERROR", message,
+ UUID.randomUUID().toString(),
+ Instant.now().toString(), null);
+ }
+
+ /**
+ * Build a rate-limit error (HTTP 429).
+ */
+ public static Axis2JsonErrorResponse rateLimited(String message, int retryAfterSeconds) {
+ return new Axis2JsonErrorResponse(
+ "RATE_LIMITED", message,
+ UUID.randomUUID().toString(),
+ Instant.now().toString(), retryAfterSeconds);
+ }
+
+ /**
+ * Build a service-unavailable error (HTTP 503).
+ */
+ public static Axis2JsonErrorResponse serviceUnavailable(String message, Integer retryAfterSeconds) {
+ return new Axis2JsonErrorResponse(
+ "SERVICE_UNAVAILABLE", message,
+ UUID.randomUUID().toString(),
+ Instant.now().toString(), retryAfterSeconds);
+ }
+
+ /**
+ * Build an internal error (HTTP 500). The message is kept opaque;
+ * full context is in server logs keyed by errorRef.
+ *
+ * <p>Unlike validationError(), the errorRef is passed IN rather than
+ * generated here — the caller (JsonUtils.createSecureFault) generates
+ * it first so the same UUID appears in both the log line and the response.
+ */
+ public static Axis2JsonErrorResponse internalError(String errorRef) {
+ return new Axis2JsonErrorResponse(
+ "INTERNAL_ERROR",
+ "Internal Server Error [errorRef=" + errorRef + "]",
+ errorRef,
+ Instant.now().toString(), null);
+ }
+
+ /**
+ * Build a bad-request / parse error (HTTP 400).
+ */
+ public static Axis2JsonErrorResponse badRequest(String errorRef) {
+ return new Axis2JsonErrorResponse(
+ "BAD_REQUEST",
+ "Bad Request [errorRef=" + errorRef + "]",
+ errorRef,
+ Instant.now().toString(), null);
+ }
+
+ // ── Getters / setters ────────────────────────────────────────────────────
+
+ public String getError() { return error; }
+ public void setError(String error) { this.error = error; }
+
+ public String getMessage() { return message; }
+ public void setMessage(String message) { this.message = message; }
+
+ public String getErrorRef() { return errorRef; }
+ public void setErrorRef(String errorRef) { this.errorRef = errorRef; }
+
+ public String getTimestamp() { return timestamp; }
+ public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
+
+ public Integer getRetryAfter() { return retryAfter; }
+ public void setRetryAfter(Integer retryAfter) { this.retryAfter = retryAfter; }
+
+ @Override
+ public String toString() {
+ return "Axis2JsonErrorResponse{" +
+ "error='" + error + '\'' +
+ ", message='" + message + '\'' +
+ ", errorRef='" + errorRef + '\'' +
+ ", timestamp='" + timestamp + '\'' +
+ ", retryAfter=" + retryAfter +
+ '}';
+ }
+}
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java
index 7e528ea551..24f33b0323 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonInOnlyRPCMessageReceiver.java
@@ -77,7 +77,19 @@ public class JsonInOnlyRPCMessageReceiver extends RPCInOnlyMessageReceiver {
try {
int paramCount = paramClasses.length;
JsonUtils.invokeServiceClass(jsonReader, serviceObj, method, paramClasses, paramCount, enableJSONOnly);
- } catch (IllegalAccessException | InvocationTargetException e) {
+ } catch (InvocationTargetException e) {
+ // Unwrap the ITE to log the real cause, not the reflection wrapper.
+ Throwable cause = e.getCause();
+ Exception rootCause;
+ if (cause instanceof Exception) {
+ rootCause = (Exception) cause;
+ } else if (cause != null) {
+ rootCause = new RuntimeException("Service threw non-Exception Throwable", cause);
+ } else {
+ rootCause = e;
+ }
+ throw JsonUtils.createSecureFault(rootCause, operation_name, false);
+ } catch (IllegalAccessException e) {
throw JsonUtils.createSecureFault(e, operation_name, false);
} catch (IOException e) {
throw JsonUtils.createSecureFault(e, operation_name, true);
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcFaultException.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcFaultException.java
new file mode 100644
index 0000000000..909ad1f3bc
--- /dev/null
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcFaultException.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.json.gson.rpc;
+
+/**
+ * Exception thrown by JSON-RPC service methods to signal a structured error
+ * with a specific HTTP status code.
+ *
+ * <h3>How it flows through the stack</h3>
+ * <ol>
+ * <li>Service method (e.g. {@code FinancialBenchmarkService.portfolioVariance()})
+ * throws this on input validation failure</li>
+ * <li>{@code Method.invoke()} wraps it in {@link java.lang.reflect.InvocationTargetException}</li>
+ * <li>{@link JsonRpcMessageReceiver#invokeService} unwraps the ITE, detects this type,
+ * and sets:
+ * <ul>
+ * <li>{@code Constants.HTTP_RESPONSE_STATE} → the HTTP status (e.g. 422)</li>
+ * <li>{@code JsonConstant.RETURN_OBJECT} → the {@link Axis2JsonErrorResponse}</li>
+ * </ul>
+ * The response then flows through the normal JSON formatter path (not the
+ * SOAP fault path), producing a clean JSON error envelope.</li>
+ * </ol>
+ *
+ * <h3>Why a checked exception instead of return-value error patterns</h3>
+ * Returning {@code XxxResponse.failed(msg)} with HTTP 200 made it impossible
+ * for intermediaries (proxies, MCP clients) to distinguish errors from success
+ * without parsing the body. Throwing produces proper HTTP semantics:
+ * 422 for validation, 429 for rate-limit, 503 for unavailability.
+ *
+ * <p>Usage in a service method:
+ * <pre>{@code
+ * if (request.getWeights() == null) {
+ * throw JsonRpcFaultException.validationError(
+ * "Missing required field: \"weights\" array");
+ * }
+ * }</pre>
+ *
+ * @see Axis2JsonErrorResponse
+ * @see JsonRpcMessageReceiver#invokeService
+ */
+public class JsonRpcFaultException extends Exception {
+
+ private final int httpStatusCode;
+ private final Axis2JsonErrorResponse errorResponse;
+
+ public JsonRpcFaultException(int httpStatusCode, Axis2JsonErrorResponse errorResponse) {
+ super(errorResponse.getMessage());
+ this.httpStatusCode = httpStatusCode;
+ this.errorResponse = errorResponse;
+ }
+
+ public JsonRpcFaultException(int httpStatusCode, Axis2JsonErrorResponse errorResponse,
+ Throwable cause) {
+ super(errorResponse.getMessage(), cause);
+ this.httpStatusCode = httpStatusCode;
+ this.errorResponse = errorResponse;
+ }
+
+ /**
+ * Convenience: throw a 422 validation error.
+ */
+ public static JsonRpcFaultException validationError(String message) {
+ return new JsonRpcFaultException(422, Axis2JsonErrorResponse.validationError(message));
+ }
+
+ /**
+ * Convenience: throw a 429 rate-limit error.
+ */
+ public static JsonRpcFaultException rateLimited(String message, int retryAfterSeconds) {
+ return new JsonRpcFaultException(429,
+ Axis2JsonErrorResponse.rateLimited(message, retryAfterSeconds));
+ }
+
+ /**
+ * Convenience: throw a 503 service-unavailable error.
+ */
+ public static JsonRpcFaultException serviceUnavailable(String message,
+ Integer retryAfterSeconds) {
+ return new JsonRpcFaultException(503,
+ Axis2JsonErrorResponse.serviceUnavailable(message, retryAfterSeconds));
+ }
+
+ public int getHttpStatusCode() {
+ return httpStatusCode;
+ }
+
+ public Axis2JsonErrorResponse getErrorResponse() {
+ return errorResponse;
+ }
+}
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
index fdf5233fcc..ac35a2a579 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonRpcMessageReceiver.java
@@ -20,6 +20,7 @@ package org.apache.axis2.json.gson.rpc;
import com.google.gson.stream.JsonReader;
import org.apache.axis2.AxisFault;
+import org.apache.axis2.Constants;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.description.AxisOperation;
import org.apache.axis2.json.gson.GsonXMLStreamReader;
@@ -35,7 +36,7 @@ import java.lang.reflect.Method;
public class JsonRpcMessageReceiver extends RPCMessageReceiver {
private static final Log log = LogFactory.getLog(RPCMessageReceiver.class);
-
+
@Override
public void invokeBusinessLogic(MessageContext inMessage, MessageContext outMessage) throws AxisFault {
Object tempObj = inMessage.getProperty(JsonConstant.IS_JSON_STREAM);
@@ -81,10 +82,52 @@ public class JsonRpcMessageReceiver extends RPCMessageReceiver {
outMes.setProperty(JsonConstant.RETURN_OBJECT, retObj);
outMes.setProperty(JsonConstant.RETURN_TYPE, method.getReturnType());
- } catch (IllegalAccessException | InvocationTargetException e) {
- throw JsonUtils.createSecureFault(e, operation_name, false);
+ } catch (InvocationTargetException e) {
+ // Method.invoke() wraps any exception thrown by the service method
+ // in InvocationTargetException. Unwrap to inspect the real cause.
+ Throwable cause = e.getCause();
+
+ if (cause instanceof JsonRpcFaultException) {
+ // ── Structured error path (new) ──────────────────────────────
+ // Service explicitly signaled a typed error (e.g. validation 422).
+ // Set the HTTP status code and put the error response as RETURN_OBJECT
+ // so the JSON formatter serializes it as a normal JSON body — NOT
+ // through the SOAP fault path (which would produce XML-in-JSON).
+ // The transport layer (AxisServlet / AbstractHTTPTransportSender)
+ // reads HTTP_RESPONSE_STATE from the MessageContext to set the
+ // actual HTTP response code.
+ JsonRpcFaultException fault = (JsonRpcFaultException) cause;
+ Axis2JsonErrorResponse errorResponse = fault.getErrorResponse();
+ log.warn("[errorRef=" + errorResponse.getErrorRef() + "] " +
+ errorResponse.getError() + " in operation '" + operation_name +
+ "': " + errorResponse.getMessage());
+ outMes.setProperty(Constants.HTTP_RESPONSE_STATE,
+ String.valueOf(fault.getHttpStatusCode()));
+ outMes.setProperty(JsonConstant.RETURN_OBJECT, errorResponse);
+ outMes.setProperty(JsonConstant.RETURN_TYPE, Axis2JsonErrorResponse.class);
+ } else {
+ // ── Opaque error path (existing behavior) ────────────────────
+ // Unexpected exception from the service — create a CWE-209-safe
+ // AxisFault with only an errorRef visible to the client.
+ // Log the root cause (unwrapped from ITE), not the wrapper.
+ Exception rootCause;
+ if (cause instanceof Exception) {
+ rootCause = (Exception) cause;
+ } else if (cause != null) {
+ // e.g. an Error — wrap so createSecureFault can log it
+ rootCause = new RuntimeException("Service threw non-Exception Throwable", cause);
+ } else {
+ // cause is null — fall back to the ITE itself to preserve stack trace
+ rootCause = e;
+ }
+ throw JsonUtils.createSecureFault(rootCause, operation_name, false, outMes);
+ }
+ } catch (IllegalAccessException e) {
+ // Reflection access denied — should not happen in normal operation
+ throw JsonUtils.createSecureFault(e, operation_name, false, outMes);
} catch (IOException e) {
- throw JsonUtils.createSecureFault(e, operation_name, true);
+ // Malformed JSON input — parsing failed before the service was invoked
+ throw JsonUtils.createSecureFault(e, operation_name, true, outMes);
}
}
}
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java
index 8720d4a3ad..d15f3d2c0b 100644
--- a/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/JsonUtils.java
@@ -26,6 +26,9 @@ import com.google.gson.Gson;
import com.google.gson.stream.JsonReader;
import org.apache.axis2.AxisFault;
+import org.apache.axis2.Constants;
+import org.apache.axis2.context.MessageContext;
+import org.apache.axis2.json.factory.JsonConstant;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
@@ -102,17 +105,55 @@ public class JsonUtils {
* @return an AxisFault safe to send to the client
*/
static AxisFault createSecureFault(Exception e, String operationName, boolean isParsingError) {
+ return createSecureFault(e, operationName, isParsingError, null);
+ }
+
+ /**
+ * Build a secure {@link AxisFault} and, when an outgoing {@link MessageContext}
+ * is available, also set the structured {@link Axis2JsonErrorResponse} as the
+ * return object with the appropriate HTTP status code. This allows the JSON
+ * formatter to serialize a clean error envelope instead of a SOAP fault XML fragment.
+ *
+ * @param e the caught exception
+ * @param operationName the Axis2 operation being dispatched (may be null)
+ * @param isParsingError {@code true} for malformed-input IOExceptions,
+ * {@code false} for internal reflection/invocation failures
+ * @param outMessage the outgoing message context (may be null for backward compat)
+ * @return an AxisFault safe to send to the client
+ */
+ static AxisFault createSecureFault(Exception e, String operationName, boolean isParsingError,
+ MessageContext outMessage) {
String errorRef = UUID.randomUUID().toString();
String opDisplay = operationName != null ? operationName : "<unknown>";
+ Axis2JsonErrorResponse errorResponse;
+ int httpStatus;
if (isParsingError) {
+ // Bad JSON from the client → 400. Full stack trace logged server-side;
+ // only the errorRef UUID reaches the client (CWE-209 safe).
log.error("[errorRef=" + errorRef + "] Bad Request parsing JSON-RPC body " +
"for operation '" + opDisplay + "': " + e.getMessage(), e);
- return new AxisFault("Bad Request [errorRef=" + errorRef + "]");
+ errorResponse = Axis2JsonErrorResponse.badRequest(errorRef);
+ httpStatus = 400;
} else {
+ // Unexpected internal failure (reflection, ClassCast, NPE, etc.) → 500.
log.error("[errorRef=" + errorRef + "] Internal error invoking operation '" +
opDisplay + "': " + e.getMessage(), e);
- return new AxisFault("Internal Server Error [errorRef=" + errorRef + "]");
+ errorResponse = Axis2JsonErrorResponse.internalError(errorRef);
+ httpStatus = 500;
+ }
+ if (outMessage != null) {
+ // When we have the outgoing MessageContext, set the structured error
+ // as RETURN_OBJECT so the JSON formatter serializes it as a clean
+ // JSON envelope. Also set HTTP_RESPONSE_STATE which AxisServlet /
+ // AbstractHTTPTransportSender reads to set the actual HTTP status code.
+ // Note: we still return an AxisFault below — the caller (invokeService)
+ // throws it, but the transport will prefer the already-set RETURN_OBJECT
+ // over the fault's XML representation for JSON content types.
+ outMessage.setProperty(Constants.HTTP_RESPONSE_STATE, String.valueOf(httpStatus));
+ outMessage.setProperty(JsonConstant.RETURN_OBJECT, errorResponse);
+ outMessage.setProperty(JsonConstant.RETURN_TYPE, Axis2JsonErrorResponse.class);
}
+ return new AxisFault(errorResponse.getMessage());
}
public static Method getOpMethod(String methodName, Method[] methodSet) {
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java b/modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java
new file mode 100644
index 0000000000..96b81ff5d6
--- /dev/null
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/PaginatedResponse.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.json.gson.rpc;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Generic paginated response wrapper for Axis2 JSON-RPC services.
+ *
+ * <p>Provides offset/limit pagination metadata alongside the result data.
+ * Designed for services backed by SQL databases where the DAO layer already
+ * supports {@code setFirstResult(offset)} and {@code setMaxResults(limit)}
+ * (standard JPA/Hibernate pattern).
+ *
+ * <h3>Wire format</h3>
+ * The JSON formatter serializes this as:
+ * <pre>{@code
+ * {
+ * "response": {
+ * "data": [ ... ],
+ * "pagination": {
+ * "offset": 0,
+ * "limit": 50,
+ * "totalCount": 1247,
+ * "hasMore": true
+ * }
+ * }
+ * }
+ * }</pre>
+ *
+ * <h3>Why offset/limit instead of cursor</h3>
+ * <ul>
+ * <li>Existing DAO layers use integer offsets ({@code query.setFirstResult()}).
+ * Cursor pagination requires a stable sort key and stateful server-side
+ * tokens — added complexity with no benefit when the underlying query is
+ * already offset-based.</li>
+ * <li>Frontend grids (SmartClient, AG Grid, React Table) natively speak
+ * offset/limit via {@code startRow}/{@code endRow} or
+ * {@code page}/{@code pageSize}. Cursor tokens require client-side
+ * adaptation.</li>
+ * <li>The {@code totalCount} field enables "Showing 1–50 of 1,247" UI patterns
+ * and page-count calculations. Cursor APIs typically omit total counts
+ * because they are expensive for the cursor model — but they are cheap
+ * when you already run a {@code SELECT COUNT(*)} in the DAO.</li>
+ * </ul>
+ *
+ * <h3>Usage in a service method</h3>
+ * <pre>{@code
+ * public PaginatedResponse<AssetBO> findAssets(AssetQuery query) {
+ * List<AssetBO> items = dao.findList(query.getOffset(), query.getLimit());
+ * long total = dao.count(query);
+ * return PaginatedResponse.of(items, query.getOffset(), query.getLimit(), total);
+ * }
+ * }</pre>
+ *
+ * <h3>Virtual scrolling / infinite scroll</h3>
+ * Frontends that use virtual scrolling (loading the next chunk as the user
+ * scrolls) can use {@code hasMore} to decide whether to fetch the next page:
+ * <pre>{@code
+ * // React / TypeScript
+ * const nextOffset = pagination.offset + pagination.limit;
+ * if (pagination.hasMore) {
+ * fetchPage(nextOffset, pagination.limit);
+ * }
+ * }</pre>
+ *
+ * @param <T> the element type in the data list
+ */
+public class PaginatedResponse<T> {
+
+ /** The items for this page. Never null after construction via factory methods. */
+ private List<T> data;
+
+ /** Pagination metadata: offset, limit, totalCount, hasMore. */
+ private Pagination pagination;
+
+ /** Default constructor for JSON deserialization (Gson, Moshi, Jackson). */
+ public PaginatedResponse() {
+ }
+
+ public PaginatedResponse(List<T> data, Pagination pagination) {
+ this.data = data;
+ this.pagination = pagination;
+ }
+
+ /**
+ * Build a paginated response from query results.
+ *
+ * @param data the result items for this page
+ * @param offset zero-based offset of the first item in this page
+ * @param limit maximum items requested (page size)
+ * @param totalCount total number of items matching the query (across all pages)
+ * @return a fully populated response ready for serialization
+ */
+ public static <T> PaginatedResponse<T> of(List<T> data, int offset, int limit,
+ long totalCount) {
+ return new PaginatedResponse<>(
+ data != null ? data : Collections.emptyList(),
+ new Pagination(offset, limit, totalCount));
+ }
+
+ /**
+ * Build an unpaginated response (all results in one page, no more pages).
+ * Useful when the full result set is small enough to return at once, or
+ * when migrating an existing service that returned a bare list — wrap it
+ * in PaginatedResponse.unpaginated() to add pagination metadata without
+ * changing the client contract (hasMore=false, totalCount=data.size()).
+ */
+ public static <T> PaginatedResponse<T> unpaginated(List<T> data) {
+ List<T> items = data != null ? data : Collections.emptyList();
+ return new PaginatedResponse<>(items,
+ new Pagination(0, items.size(), items.size()));
+ }
+
+ // ── Getters / setters ────────────────────────────────────────────────────
+
+ public List<T> getData() { return data; }
+ public void setData(List<T> data) { this.data = data; }
+
+ public Pagination getPagination() { return pagination; }
+ public void setPagination(Pagination pagination) { this.pagination = pagination; }
+
+ // ── Pagination metadata ──────────────────────────────────────────────────
+
+ /**
+ * Pagination metadata returned alongside the data.
+ *
+ * <p>All fields are echoed from the request (offset, limit) or computed
+ * from the query (totalCount, hasMore). This gives the client everything
+ * needed to render paging controls or implement infinite scroll without
+ * any client-side bookkeeping.
+ */
+ public static class Pagination {
+ /** Zero-based offset of the first item in this page. */
+ private int offset;
+
+ /** Maximum items requested (page size). */
+ private int limit;
+
+ /** Total items matching the query across all pages. */
+ private long totalCount;
+
+ /**
+ * True if there are more items beyond this page.
+ * Computed as {@code offset + data.size() < totalCount}.
+ * Frontends use this to enable/disable "Load More" or "Next Page".
+ */
+ private boolean hasMore;
+
+ public Pagination() {
+ }
+
+ public Pagination(int offset, int limit, long totalCount) {
+ this.offset = offset;
+ this.limit = limit;
+ this.totalCount = totalCount;
+ // hasMore is true when there are items beyond the current page.
+ // We use offset + limit (not offset + actual data size) because
+ // the caller may have received fewer items than limit on the last
+ // page, and we want hasMore=false in that case too. But the
+ // definitive check is offset + limit < totalCount — if the offset
+ // plus a full page is still less than total, there's more.
+ this.hasMore = (long) offset + limit < totalCount;
+ }
+
+ public int getOffset() { return offset; }
+ public void setOffset(int offset) { this.offset = offset; }
+
+ public int getLimit() { return limit; }
+ public void setLimit(int limit) { this.limit = limit; }
+
+ public long getTotalCount() { return totalCount; }
+ public void setTotalCount(long totalCount) { this.totalCount = totalCount; }
+
+ public boolean isHasMore() { return hasMore; }
+ public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
+ }
+}
diff --git a/modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java b/modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java
new file mode 100644
index 0000000000..3a6e549c9f
--- /dev/null
+++ b/modules/json/src/org/apache/axis2/json/gson/rpc/PaginationRequest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.json.gson.rpc;
+
+/**
+ * Pagination parameters that a client sends with a list query.
+ *
+ * <p>Services can embed these fields directly in their request POJO or
+ * accept a {@code PaginationRequest} as a separate parameter. The values
+ * map directly to Hibernate/JPA's {@code setFirstResult(offset)} and
+ * {@code setMaxResults(limit)}.
+ *
+ * <h3>Defaults</h3>
+ * <ul>
+ * <li>{@code offset = 0} — start from the beginning</li>
+ * <li>{@code limit = 50} — return at most 50 items</li>
+ * </ul>
+ *
+ * <h3>Safety</h3>
+ * The {@link #getLimit()} getter clamps the value to {@link #MAX_LIMIT}
+ * (default 2000) to prevent clients from requesting unbounded result sets
+ * that could exhaust server memory. Services can override this by calling
+ * {@link #setMaxLimit(int)} before reading the limit.
+ *
+ * <h3>Wire format</h3>
+ * When embedded in a request POJO:
+ * <pre>{@code
+ * {
+ * "searchTerm": "AAPL",
+ * "offset": 100,
+ * "limit": 50
+ * }
+ * }</pre>
+ */
+public class PaginationRequest {
+
+ /** Default page size when not specified by the client. */
+ public static final int DEFAULT_LIMIT = 50;
+
+ /**
+ * Maximum page size to prevent unbounded queries. Set to 2000 to match
+ * common enterprise grid configurations where large virtual-scroll pages
+ * are used to minimize round trips while staying within reasonable memory.
+ */
+ public static final int DEFAULT_MAX_LIMIT = 2000;
+
+ /** Zero-based offset — maps to JPA/Hibernate {@code query.setFirstResult(offset)}. */
+ private int offset;
+
+ /** Page size — maps to JPA/Hibernate {@code query.setMaxResults(limit)}. */
+ private int limit;
+
+ /**
+ * Server-enforced ceiling on limit. Prevents clients from requesting
+ * unbounded result sets (e.g. limit=999999) that could exhaust heap.
+ * Services can lower this per-operation via {@link #setMaxLimit(int)}.
+ */
+ private int maxLimit = DEFAULT_MAX_LIMIT;
+
+ public PaginationRequest() {
+ this.offset = 0;
+ this.limit = DEFAULT_LIMIT;
+ }
+
+ public PaginationRequest(int offset, int limit) {
+ this.offset = Math.max(0, offset);
+ this.limit = limit;
+ }
+
+ /** Zero-based offset. Negative values are clamped to 0. */
+ public int getOffset() {
+ return Math.max(0, offset);
+ }
+
+ public void setOffset(int offset) {
+ this.offset = offset;
+ }
+
+ /**
+ * Page size. Clamped to [1, maxLimit].
+ * If the client sends 0 or negative, the default (50) is used.
+ * If the client sends more than maxLimit, it is capped.
+ */
+ public int getLimit() {
+ if (limit <= 0) return DEFAULT_LIMIT;
+ return Math.min(limit, maxLimit);
+ }
+
+ public void setLimit(int limit) {
+ this.limit = limit;
+ }
+
+ /**
+ * Override the maximum allowed limit for this request.
+ * Call this before {@link #getLimit()} to change the cap.
+ */
+ public void setMaxLimit(int maxLimit) {
+ this.maxLimit = maxLimit;
+ }
+
+ public int getMaxLimit() {
+ return maxLimit;
+ }
+}
diff --git a/modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java b/modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java
new file mode 100644
index 0000000000..81af02d1b3
--- /dev/null
+++ b/modules/json/test/org/apache/axis2/json/gson/rpc/PaginatedResponseTest.java
@@ -0,0 +1,389 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.axis2.json.gson.rpc;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests for {@link PaginatedResponse} and {@link PaginationRequest}.
+ */
+public class PaginatedResponseTest {
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // PaginatedResponse
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testOf_firstPage_hasMore() {
+ List<String> items = Arrays.asList("a", "b", "c");
+ PaginatedResponse<String> resp = PaginatedResponse.of(items, 0, 3, 10);
+
+ assertEquals(3, resp.getData().size());
+ assertEquals(0, resp.getPagination().getOffset());
+ assertEquals(3, resp.getPagination().getLimit());
+ assertEquals(10, resp.getPagination().getTotalCount());
+ assertTrue("offset 0 + limit 3 < total 10 → hasMore", resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testOf_lastPage_noMore() {
+ List<String> items = Arrays.asList("h", "i", "j");
+ PaginatedResponse<String> resp = PaginatedResponse.of(items, 7, 3, 10);
+
+ assertEquals(3, resp.getData().size());
+ assertEquals(7, resp.getPagination().getOffset());
+ assertFalse("offset 7 + limit 3 = 10 = total → no more",
+ resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testOf_partialLastPage_noMore() {
+ // Only 2 items on last page (limit was 3 but only 2 remain)
+ List<String> items = Arrays.asList("i", "j");
+ PaginatedResponse<String> resp = PaginatedResponse.of(items, 8, 3, 10);
+
+ assertEquals(2, resp.getData().size());
+ // offset 8 + limit 3 = 11 > total 10 → no more
+ assertFalse(resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testOf_exactlyOnePage() {
+ List<String> items = Arrays.asList("a", "b", "c");
+ PaginatedResponse<String> resp = PaginatedResponse.of(items, 0, 3, 3);
+
+ assertEquals(3, resp.getPagination().getTotalCount());
+ assertFalse("single page → no more", resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testOf_emptyResult() {
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ Collections.emptyList(), 0, 50, 0);
+
+ assertTrue(resp.getData().isEmpty());
+ assertEquals(0, resp.getPagination().getTotalCount());
+ assertFalse(resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testOf_nullDataTreatedAsEmpty() {
+ PaginatedResponse<String> resp = PaginatedResponse.of(null, 0, 50, 0);
+
+ assertNotNull(resp.getData());
+ assertTrue(resp.getData().isEmpty());
+ }
+
+ @Test
+ public void testUnpaginated() {
+ List<String> items = Arrays.asList("x", "y", "z");
+ PaginatedResponse<String> resp = PaginatedResponse.unpaginated(items);
+
+ assertEquals(3, resp.getData().size());
+ assertEquals(0, resp.getPagination().getOffset());
+ assertEquals(3, resp.getPagination().getLimit());
+ assertEquals(3, resp.getPagination().getTotalCount());
+ assertFalse("unpaginated → no more", resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testUnpaginated_null() {
+ PaginatedResponse<String> resp = PaginatedResponse.unpaginated(null);
+ assertNotNull(resp.getData());
+ assertTrue(resp.getData().isEmpty());
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // PaginationRequest
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testRequest_defaults() {
+ PaginationRequest req = new PaginationRequest();
+ assertEquals(0, req.getOffset());
+ assertEquals(50, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_negativeOffsetClampedToZero() {
+ PaginationRequest req = new PaginationRequest();
+ req.setOffset(-5);
+ assertEquals(0, req.getOffset());
+ }
+
+ @Test
+ public void testRequest_zeroLimitUsesDefault() {
+ PaginationRequest req = new PaginationRequest();
+ req.setLimit(0);
+ assertEquals(PaginationRequest.DEFAULT_LIMIT, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_negativeLimitUsesDefault() {
+ PaginationRequest req = new PaginationRequest();
+ req.setLimit(-10);
+ assertEquals(PaginationRequest.DEFAULT_LIMIT, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_exceedsMaxLimitIsCapped() {
+ PaginationRequest req = new PaginationRequest();
+ req.setLimit(5000);
+ assertEquals(PaginationRequest.DEFAULT_MAX_LIMIT, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_customMaxLimit() {
+ PaginationRequest req = new PaginationRequest();
+ req.setMaxLimit(100);
+ req.setLimit(200);
+ assertEquals(100, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_validLimitPassesThrough() {
+ PaginationRequest req = new PaginationRequest();
+ req.setLimit(25);
+ assertEquals(25, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_constructorWithValues() {
+ PaginationRequest req = new PaginationRequest(100, 25);
+ assertEquals(100, req.getOffset());
+ assertEquals(25, req.getLimit());
+ }
+
+ @Test
+ public void testRequest_constructorNegativeOffset() {
+ PaginationRequest req = new PaginationRequest(-10, 25);
+ assertEquals(0, req.getOffset());
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Integration: request → response round-trip
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testRoundTrip_simulateServiceCall() {
+ // Simulate: client requests page 3 (offset=100, limit=50) of 247 total items
+ PaginationRequest req = new PaginationRequest(100, 50);
+
+ // Service returns 50 items from this page
+ List<String> pageData = Collections.nCopies(50, "item");
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ pageData, req.getOffset(), req.getLimit(), 247);
+
+ assertEquals(50, resp.getData().size());
+ assertEquals(100, resp.getPagination().getOffset());
+ assertEquals(50, resp.getPagination().getLimit());
+ assertEquals(247, resp.getPagination().getTotalCount());
+ assertTrue("100 + 50 = 150 < 247 → hasMore", resp.getPagination().isHasMore());
+
+ // Client calculates: next page starts at offset 150
+ int nextOffset = resp.getPagination().getOffset() + resp.getPagination().getLimit();
+ assertEquals(150, nextOffset);
+ }
+
+ @Test
+ public void testRoundTrip_lastPage() {
+ // Client requests the last partial page
+ PaginationRequest req = new PaginationRequest(200, 50);
+
+ // Service returns only 47 items (247 total - 200 offset = 47 remaining)
+ List<String> pageData = Collections.nCopies(47, "item");
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ pageData, req.getOffset(), req.getLimit(), 247);
+
+ assertEquals(47, resp.getData().size());
+ assertFalse("200 + 50 = 250 > 247 → no more", resp.getPagination().isHasMore());
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Enterprise DAO scenarios (sanitized production patterns)
+ //
+ // These tests mirror the common patterns found in Hibernate-backed
+ // enterprise apps: large entity lists, soft-delete filtering, virtual
+ // scrolling with large page sizes, and count-alongside-data queries.
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ public void testEnterprise_largeEntityList_firstPage() {
+ // Scenario: user opens a grid showing all portfolio holdings.
+ // DAO runs: query.setFirstResult(0), query.setMaxResults(2000)
+ // Count query returns 8,543 active (non-deleted) holdings.
+ PaginationRequest req = new PaginationRequest(0, 2000);
+
+ List<String> holdings = Collections.nCopies(2000, "holding");
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ holdings, req.getOffset(), req.getLimit(), 8543);
+
+ assertEquals(2000, resp.getData().size());
+ assertEquals(0, resp.getPagination().getOffset());
+ assertEquals(2000, resp.getPagination().getLimit());
+ assertEquals(8543, resp.getPagination().getTotalCount());
+ assertTrue("8543 holdings, showing first 2000 → hasMore", resp.getPagination().isHasMore());
+
+ // Frontend grid calculates total pages for page controls
+ long totalPages = (long) Math.ceil(
+ (double) resp.getPagination().getTotalCount() / resp.getPagination().getLimit());
+ assertEquals(5, totalPages); // ceil(8543/2000) = 5 pages
+ }
+
+ @Test
+ public void testEnterprise_largeEntityList_lastPartialPage() {
+ // Scenario: grid scrolls to the last page of 8,543 holdings.
+ // Pages 1-4 had 2000 each (offsets 0, 2000, 4000, 6000).
+ // Page 5: offset=8000, limit=2000, but only 543 remain.
+ PaginationRequest req = new PaginationRequest(8000, 2000);
+
+ List<String> lastPageHoldings = Collections.nCopies(543, "holding");
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ lastPageHoldings, req.getOffset(), req.getLimit(), 8543);
+
+ assertEquals(543, resp.getData().size());
+ assertFalse("last page → no more", resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testEnterprise_virtualScroll_startRowEndRow() {
+ // Scenario: SmartClient-style grid sends startRow=300, endRow=350.
+ // The service translates: offset = startRow, limit = endRow - startRow.
+ int startRow = 300;
+ int endRow = 350;
+ int offset = startRow;
+ int limit = endRow - startRow;
+
+ PaginationRequest req = new PaginationRequest(offset, limit);
+ assertEquals(300, req.getOffset());
+ assertEquals(50, req.getLimit());
+
+ // DAO returns 50 items from a total of 1,247
+ List<String> chunk = Collections.nCopies(50, "row");
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ chunk, req.getOffset(), req.getLimit(), 1247);
+
+ assertTrue("more rows available for virtual scroll", resp.getPagination().isHasMore());
+
+ // Grid calculates next fetch: startRow=350, endRow=400
+ int nextStartRow = resp.getPagination().getOffset() + resp.getPagination().getLimit();
+ assertEquals(350, nextStartRow);
+ }
+
+ @Test
+ public void testEnterprise_softDeleteFiltering() {
+ // Scenario: database has 10,000 records but 1,457 are soft-deleted.
+ // The DAO applies a "deleted = 0" filter before pagination.
+ // Count query: SELECT COUNT(*) WHERE deleted = 0 → 8,543
+ // Data query: SELECT ... WHERE deleted = 0 OFFSET 0 LIMIT 50
+ //
+ // totalCount reflects the FILTERED set (8,543), not the raw table (10,000).
+ // This is the correct behavior — clients see only active records.
+ PaginationRequest req = new PaginationRequest(0, 50);
+
+ List<String> activeRecords = Collections.nCopies(50, "active");
+ long filteredTotal = 8543; // excludes soft-deleted
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ activeRecords, req.getOffset(), req.getLimit(), filteredTotal);
+
+ assertEquals(8543, resp.getPagination().getTotalCount());
+ assertTrue(resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testEnterprise_maxLimitEnforced() {
+ // Scenario: a client sends limit=999999 trying to dump the entire table.
+ // PaginationRequest caps it at DEFAULT_MAX_LIMIT (2000).
+ PaginationRequest req = new PaginationRequest();
+ req.setLimit(999999);
+ assertEquals("server caps at 2000", 2000, req.getLimit());
+ }
+
+ @Test
+ public void testEnterprise_serviceCanLowerMaxLimit() {
+ // Scenario: a specific operation handles expensive entities (e.g. with
+ // large text fields). The service lowers the max to 100 to bound memory.
+ PaginationRequest req = new PaginationRequest();
+ req.setMaxLimit(100);
+ req.setLimit(500);
+ assertEquals("service-specific cap at 100", 100, req.getLimit());
+ }
+
+ @Test
+ public void testEnterprise_unpaginated_smallLookupTable() {
+ // Scenario: a lookup/reference table (e.g. list of 15 departments).
+ // No pagination needed — return the full set with hasMore=false.
+ List<String> departments = Arrays.asList(
+ "Equity Long/Short", "Fixed Income", "Macro", "Quant",
+ "Event Driven", "Multi-Strategy", "Credit", "Real Assets",
+ "Private Equity", "Venture", "Commodities", "FX",
+ "Emerging Markets", "Infrastructure", "Insurance-Linked");
+
+ PaginatedResponse<String> resp = PaginatedResponse.unpaginated(departments);
+
+ assertEquals(15, resp.getData().size());
+ assertEquals(15, resp.getPagination().getTotalCount());
+ assertEquals(0, resp.getPagination().getOffset());
+ assertFalse("small table, all returned → no more", resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testEnterprise_emptyFilterResult() {
+ // Scenario: user searches for "XYZNOTFOUND" — no matching records.
+ // The service returns an empty page with totalCount=0.
+ PaginationRequest req = new PaginationRequest(0, 50);
+
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ Collections.emptyList(), req.getOffset(), req.getLimit(), 0);
+
+ assertTrue(resp.getData().isEmpty());
+ assertEquals(0, resp.getPagination().getTotalCount());
+ assertFalse(resp.getPagination().isHasMore());
+ }
+
+ @Test
+ public void testEnterprise_pageCalculation_forReactUI() {
+ // Scenario: React frontend needs to render page controls.
+ // API returns pagination metadata; frontend calculates display values.
+ PaginatedResponse<String> resp = PaginatedResponse.of(
+ Collections.nCopies(50, "item"), 150, 50, 1247);
+
+ // Frontend calculations:
+ int offset = resp.getPagination().getOffset();
+ int limit = resp.getPagination().getLimit();
+ long total = resp.getPagination().getTotalCount();
+
+ int currentPage = (offset / limit) + 1; // 1-based page number
+ int totalPages = (int) Math.ceil((double) total / limit);
+ int showingFrom = offset + 1; // 1-based display
+ int showingTo = offset + resp.getData().size(); // actual items on this page
+
+ assertEquals("page 4 of 25", 4, currentPage);
+ assertEquals(25, totalPages);
+ assertEquals("Showing 151", 151, showingFrom);
+ assertEquals("to 200", 200, showingTo);
+ }
+}
diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java
index 49b50d41f0..b9d67ff7b8 100644
--- a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java
+++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java
@@ -133,8 +133,8 @@ public class OpenApiSpecGenerator {
// Add security schemes from configuration
addSecuritySchemes(openApi);
- // Add components/schemas section
- addComponents(openApi);
+ // Add components/schemas section (needs request for Spring bean resolution)
+ addComponents(openApi, request);
// Apply customizer if configured
applyCustomizer(openApi);
@@ -416,6 +416,37 @@ public class OpenApiSpecGenerator {
successResponse.setContent(content);
responses.addApiResponse("200", successResponse);
+
+ // ── Error responses ───────────────────────────────────────────────────
+ // Every operation gets 422/429/503 error responses referencing the
+ // shared ErrorResponse schema (defined in addComponents()). This
+ // matches the error taxonomy from Axis2JsonErrorResponse / JsonRpcFaultException:
+ // 422 = service threw JsonRpcFaultException.validationError()
+ // 429 = service threw JsonRpcFaultException.rateLimited()
+ // 503 = service threw JsonRpcFaultException.serviceUnavailable()
+ // All share the same JSON shape — only the "error" code field differs.
+ Content errorContent = new Content();
+ MediaType errorMediaType = new MediaType();
+ Schema errorRef = new Schema();
+ errorRef.set$ref("#/components/schemas/ErrorResponse");
+ errorMediaType.setSchema(errorRef);
+ errorContent.addMediaType("application/json", errorMediaType);
+
+ ApiResponse validationError = new ApiResponse();
+ validationError.setDescription("Validation error — request body failed input checks");
+ validationError.setContent(errorContent);
+ responses.addApiResponse("422", validationError);
+
+ ApiResponse rateLimitError = new ApiResponse();
+ rateLimitError.setDescription("Rate limited — too many requests");
+ rateLimitError.setContent(errorContent);
+ responses.addApiResponse("429", rateLimitError);
+
+ ApiResponse serviceUnavailable = new ApiResponse();
+ serviceUnavailable.setDescription("Service unavailable — downstream dependency or overload");
+ serviceUnavailable.setContent(errorContent);
+ responses.addApiResponse("503", serviceUnavailable);
+
operation.setResponses(responses);
return operation;
@@ -521,21 +552,315 @@ public class OpenApiSpecGenerator {
}
/**
- * Add components section to OpenAPI specification.
+ * Add components section to OpenAPI specification, including the shared
+ * ErrorResponse schema and auto-generated request/response schemas from
+ * Java type introspection.
*/
- private void addComponents(OpenAPI openApi) {
+ private void addComponents(OpenAPI openApi, HttpServletRequest request) {
Components components = openApi.getComponents();
if (components == null) {
components = new Components();
openApi.setComponents(components);
}
- // TODO: Add schema definitions for request/response models
- // This can be enhanced to automatically generate schemas from service metadata
+ java.util.Map<String, Schema> schemas = components.getSchemas();
+ if (schemas == null) {
+ schemas = new java.util.LinkedHashMap<>();
+ }
+
+ // ── Shared ErrorResponse schema — matches Axis2JsonErrorResponse wire format ──
+ Schema<Object> errorSchema = new Schema<>();
+ errorSchema.setType("object");
+ errorSchema.setDescription("Structured error response for Axis2 JSON-RPC services");
+
+ Schema<String> errorCode = new Schema<>();
+ errorCode.setType("string");
+ errorCode.setDescription("Error code: VALIDATION_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, BAD_REQUEST, INTERNAL_ERROR");
+
+ Schema<String> message = new Schema<>();
+ message.setType("string");
+ message.setDescription("Human-readable error message");
+
+ Schema<String> errorRefField = new Schema<>();
+ errorRefField.setType("string");
+ errorRefField.setDescription("Opaque correlation ID (UUID) for server-side log lookup");
+
+ Schema<String> timestamp = new Schema<>();
+ timestamp.setType("string");
+ timestamp.setFormat("date-time");
+ timestamp.setDescription("ISO 8601 timestamp of when the error occurred");
+
+ Schema<Integer> retryAfter = new Schema<>();
+ retryAfter.setType("integer");
+ retryAfter.setDescription("Seconds until the client should retry (present on 429/503)");
+
+ java.util.Map<String, Schema> errorProps = new java.util.LinkedHashMap<>();
+ errorProps.put("error", errorCode);
+ errorProps.put("message", message);
+ errorProps.put("errorRef", errorRefField);
+ errorProps.put("timestamp", timestamp);
+ errorProps.put("retryAfter", retryAfter);
+ errorSchema.setProperties(errorProps);
+
+ java.util.List<String> errorRequired = new java.util.ArrayList<>();
+ errorRequired.add("error");
+ errorRequired.add("message");
+ errorRequired.add("errorRef");
+ errorRequired.add("timestamp");
+ errorSchema.setRequired(errorRequired);
+ schemas.put("ErrorResponse", errorSchema);
+
+ // ── Auto-generate request/response schemas from service introspection ──
+ // This runs AFTER generatePaths() so all PathItem/Operation objects already
+ // exist. For each operation we:
+ // 1. Reflect on the service method's parameter POJO → request schema
+ // 2. Reflect on the return type POJO → response schema
+ // 3. Update the already-generated operation to $ref these schemas
+ // (replacing the generic {"type":"object"} placeholder)
+ //
+ // Introspection is best-effort: if the service class can't be loaded
+ // (e.g. Spring bean not yet initialized, or missing from classpath),
+ // the operation keeps its generic schema — no error, just less detail.
+ //
+ // Schema naming convention: {operationName}Request / {operationName}Response
+ // (e.g. portfolioVarianceRequest, portfolioVarianceResponse).
+ try {
+ AxisConfiguration axisConfig = configurationContext.getAxisConfiguration();
+ com.fasterxml.jackson.databind.ObjectMapper jackson = io.swagger.v3.core.util.Json.mapper();
+
+ for (AxisService service : axisConfig.getServices().values()) {
+ if (isSystemService(service) || !shouldIncludeService(service)) continue;
+
+ // Resolve service class ONCE per service (expensive reflection),
+ // then reuse for all operations within this service.
+ Class<?> serviceClass = resolveServiceClass(service, request);
+
+ Iterator<AxisOperation> ops = service.getOperations();
+ while (ops.hasNext()) {
+ AxisOperation op = ops.next();
+ if (!shouldIncludeOperation(service, op)) continue;
+
+ String opName = op.getName().getLocalPart();
+ String path = "/services/" + service.getName() + "/" + opName;
+ // ── Request schema: introspect the method's parameter POJO ──
+ // Uses the serviceClass resolved once per service above.
+ com.fasterxml.jackson.databind.node.ObjectNode requestSchemaNode =
+ generateSchemaFromServiceClass(serviceClass, service.getName(), opName);
+ if (requestSchemaNode != null) {
+ String requestSchemaName = opName + "Request";
+ try {
+ // Convert Jackson ObjectNode → swagger-core Schema via
+ // in-memory tree conversion (no intermediate String alloc).
+ Schema<?> reqSchema = jackson.treeToValue(
+ requestSchemaNode, Schema.class);
+ reqSchema.setDescription("Request body for " + service.getName()
+ + "/" + opName);
+ schemas.put(requestSchemaName, reqSchema);
+
+ // Update the operation's requestBody to reference this schema
+ PathItem pathItem = openApi.getPaths() != null
+ ? openApi.getPaths().get(path) : null;
+ if (pathItem != null && pathItem.getPost() != null) {
+ RequestBody reqBody = pathItem.getPost().getRequestBody();
+ if (reqBody != null && reqBody.getContent() != null) {
+ MediaType mt = reqBody.getContent().get("application/json");
+ if (mt != null) {
+ Schema refSchema = new Schema();
+ refSchema.set$ref("#/components/schemas/"
+ + requestSchemaName);
+ mt.setSchema(refSchema);
+ }
+ }
+ }
+ log.debug("Added components/schemas/" + requestSchemaName
+ + " from Java type introspection");
+ } catch (Exception e) {
+ log.debug("Could not convert request schema for " + opName
+ + ": " + e.getMessage());
+ }
+ }
+
+ // ── Response schema: introspect the method's return type POJO ──
+ // Uses the serviceClass resolved once per service above.
+ // Skips primitives, void, and java.* types (only POJOs get schemas).
+ try {
+ if (serviceClass != null) {
+ java.lang.reflect.Method targetMethod = null;
+ int candidateCount = 0;
+ for (java.lang.reflect.Method m : serviceClass.getMethods()) {
+ if (m.getName().equals(opName) && m.getParameterCount() == 1) {
+ if (targetMethod == null) {
+ targetMethod = m;
+ }
+ candidateCount++;
+ }
+ }
+ if (candidateCount > 1) {
+ log.warn("Ambiguous method '" + opName + "' in "
+ + serviceClass.getName() + " has " + candidateCount
+ + " one-argument overloads — generating response "
+ + "schema from first candidate");
+ }
+ if (targetMethod != null) {
+ Class<?> returnType = targetMethod.getReturnType();
+ if (!returnType.isPrimitive() && returnType != void.class
+ && returnType != Void.class
+ && !returnType.getName().startsWith("java.")) {
+ com.fasterxml.jackson.databind.node.ObjectNode respNode =
+ buildSchemaFromPojo(returnType);
+ if (respNode != null) {
+ String responseSchemaName = opName + "Response";
+ Schema<?> respSchema = jackson.treeToValue(
+ respNode, Schema.class);
+ respSchema.setDescription("Response body for "
+ + service.getName() + "/" + opName);
+ schemas.put(responseSchemaName, respSchema);
+
+ // Update the operation's 200 response to reference it
+ PathItem pathItem = openApi.getPaths() != null
+ ? openApi.getPaths().get(path) : null;
+ if (pathItem != null && pathItem.getPost() != null) {
+ ApiResponse ok = pathItem.getPost().getResponses()
+ .get("200");
+ if (ok != null && ok.getContent() != null) {
+ MediaType mt = ok.getContent()
+ .get("application/json");
+ if (mt != null) {
+ Schema refSchema = new Schema();
+ refSchema.set$ref(
+ "#/components/schemas/"
+ + responseSchemaName);
+ mt.setSchema(refSchema);
+ }
+ }
+ }
+ log.debug("Added components/schemas/"
+ + responseSchemaName
+ + " from Java type introspection");
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.debug("Could not generate response schema for "
+ + service.getName() + "/" + opName + ": " + e.getMessage());
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Error during auto-schema generation for components", e);
+ }
+
+ components.setSchemas(schemas);
openApi.setComponents(components);
}
+ /**
+ * Resolve the service implementation class for type introspection.
+ *
+ * <p>Resolution order:
+ * <ol>
+ * <li>{@code ServiceClass} parameter in services.xml → direct classload</li>
+ * <li>{@code SpringBeanName} parameter → resolve via Spring's
+ * {@code WebApplicationContextUtils} (invoked reflectively to avoid
+ * a compile-time Spring dependency in the openapi module)</li>
+ * </ol>
+ *
+ * <p>Returns null if neither path succeeds. Callers should treat null
+ * as "schema generation not possible" and fall back to generic schemas.
+ *
+ * <p>This is factored out of {@link #generateSchemaFromServiceClass} so that
+ * both request and response schema generation can share the same lookup
+ * without loading the class twice per operation.
+ */
+ private Class<?> resolveServiceClass(AxisService service, HttpServletRequest request) {
+ try {
+ // Try 1: explicit ServiceClass parameter (e.g. POJO deployment).
+ // Use the service's own classloader — it has visibility to
+ // WEB-INF/classes and WEB-INF/lib in WAR deployments, whereas
+ // Thread.currentThread().getContextClassLoader() may not.
+ String className = getServiceClassName(service);
+ if (className != null) {
+ ClassLoader cl = service.getClassLoader();
+ if (cl == null) {
+ cl = Thread.currentThread().getContextClassLoader();
+ }
+ return cl.loadClass(className);
+ }
+ // Try 2: Spring bean — uses reflection to call
+ // WebApplicationContextUtils.getWebApplicationContext(servletContext)
+ // so the openapi module compiles without spring-web on the classpath.
+ if (request != null) {
+ String beanName = null;
+ org.apache.axis2.description.Parameter springBeanParam =
+ service.getParameter("SpringBeanName");
+ if (springBeanParam != null && springBeanParam.getValue() != null) {
+ beanName = (String) springBeanParam.getValue();
+ }
+ if (beanName != null) {
+ jakarta.servlet.ServletContext sc = request.getServletContext();
+ Class<?> wacUtils = Class.forName(
+ "org.springframework.web.context.support.WebApplicationContextUtils");
+ java.lang.reflect.Method getWac = wacUtils.getMethod(
+ "getWebApplicationContext", jakarta.servlet.ServletContext.class);
+ Object ctx = getWac.invoke(null, sc);
+ if (ctx != null) {
+ java.lang.reflect.Method getBean = ctx.getClass().getMethod(
+ "getBean", String.class);
+ Object bean = getBean.invoke(ctx, beanName);
+ if (bean != null) return bean.getClass();
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.debug("Could not resolve service class for " + service.getName()
+ + ": " + e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * Build a JSON Schema ObjectNode from a POJO class using getter introspection.
+ * Reuses the same introspection pattern as {@link #generateSchemaFromServiceClass}
+ * but works on an arbitrary class (used for response types).
+ */
+ private com.fasterxml.jackson.databind.node.ObjectNode buildSchemaFromPojo(Class<?> pojoClass) {
+ try {
+ com.fasterxml.jackson.databind.ObjectMapper mapper =
+ io.swagger.v3.core.util.Json.mapper();
+ com.fasterxml.jackson.databind.node.ObjectNode schema = mapper.createObjectNode();
+ schema.put("type", "object");
+ com.fasterxml.jackson.databind.node.ObjectNode properties =
+ schema.putObject("properties");
+
+ for (java.lang.reflect.Method getter : pojoClass.getMethods()) {
+ String name = getter.getName();
+ if (name.equals("getClass") || getter.getParameterCount() != 0) continue;
+
+ String fieldName = null;
+ if (name.startsWith("get") && name.length() > 3) {
+ fieldName = Character.toLowerCase(name.charAt(3)) + name.substring(4);
+ } else if (name.startsWith("is") && name.length() > 2
+ && (getter.getReturnType() == boolean.class
+ || getter.getReturnType() == Boolean.class)) {
+ fieldName = Character.toLowerCase(name.charAt(2)) + name.substring(3);
+ }
+ if (fieldName == null) continue;
+
+ com.fasterxml.jackson.databind.node.ObjectNode prop =
+ properties.putObject(fieldName);
+ mapJavaTypeToJsonSchema(getter.getReturnType(),
+ getter.getGenericReturnType(), prop);
+ }
+ return schema;
+ } catch (Exception e) {
+ log.debug("Could not build schema from POJO " + pojoClass.getName()
+ + ": " + e.getMessage());
+ return null;
+ }
+ }
+
/**
* Apply customizer to OpenAPI specification if configured.
@@ -609,53 +934,23 @@ public class OpenApiSpecGenerator {
private com.fasterxml.jackson.databind.node.ObjectNode generateSchemaFromServiceClass(
AxisService service, String operationName, HttpServletRequest request) {
- try {
- Class<?> serviceClass = null;
-
- // Try 1: explicit ServiceClass parameter
- String className = getServiceClassName(service);
- if (className != null) {
- serviceClass = Thread.currentThread().getContextClassLoader().loadClass(className);
- }
-
- // Try 2: resolve Spring bean class from SpringBeanName via WebApplicationContext.
- // Uses reflection to avoid a compile-time dependency on Spring Framework —
- // the openapi module must work without Spring on the classpath.
- // Mirrors the lookup in SpringServletContextObjectSupplier which uses
- // WebApplicationContextUtils.getWebApplicationContext(servletContext).
- if (serviceClass == null && request != null) {
- try {
- String beanName = null;
- org.apache.axis2.description.Parameter springBeanParam =
- service.getParameter("SpringBeanName");
- if (springBeanParam != null && springBeanParam.getValue() != null) {
- beanName = (String) springBeanParam.getValue();
- }
- if (beanName != null) {
- jakarta.servlet.ServletContext sc = request.getServletContext();
- // Call WebApplicationContextUtils.getWebApplicationContext(sc) via reflection
- Class<?> wacUtils = Class.forName(
- "org.springframework.web.context.support.WebApplicationContextUtils");
- java.lang.reflect.Method getWac = wacUtils.getMethod(
- "getWebApplicationContext", jakarta.servlet.ServletContext.class);
- Object ctx = getWac.invoke(null, sc);
- if (ctx != null) {
- java.lang.reflect.Method getBean = ctx.getClass().getMethod(
- "getBean", String.class);
- Object bean = getBean.invoke(ctx, beanName);
- if (bean != null) {
- serviceClass = bean.getClass();
- log.debug("[MCP] Resolved Spring bean '" + beanName
- + "' -> " + serviceClass.getName());
- }
- }
- }
- } catch (Exception springEx) {
- log.debug("[MCP] Could not resolve Spring bean for "
- + service.getName() + ": " + springEx.getMessage());
- }
- }
+ Class<?> serviceClass = resolveServiceClass(service, request);
+ return generateSchemaFromServiceClass(serviceClass, service.getName(), operationName);
+ }
+ /**
+ * Generate a JSON Schema from a pre-resolved service class and operation name.
+ * This overload avoids redundant class resolution when the caller has already
+ * resolved the class (e.g. once per service in addComponents).
+ *
+ * @param serviceClass the resolved service implementation class (may be null)
+ * @param serviceName the service name (for logging)
+ * @param operationName the operation (method) name
+ * @return an ObjectNode containing the JSON Schema, or null
+ */
+ private com.fasterxml.jackson.databind.node.ObjectNode generateSchemaFromServiceClass(
+ Class<?> serviceClass, String serviceName, String operationName) {
+ try {
if (serviceClass == null) return null;
java.lang.reflect.Method targetMethod = null;
for (java.lang.reflect.Method m : serviceClass.getMethods()) {
@@ -706,14 +1001,14 @@ public class OpenApiSpecGenerator {
}
return schema;
- } catch (ClassNotFoundException | SecurityException e) {
- log.debug("[MCP] Could not auto-generate schema for " + service.getName()
+ } catch (SecurityException e) {
+ log.debug("[MCP] Could not auto-generate schema for " + serviceName
+ "/" + operationName + ": " + e.getClass().getSimpleName()
+ " - " + e.getMessage());
return null;
} catch (Exception e) {
log.warn("[MCP] Unexpected error during auto-schema generation for "
- + service.getName() + "/" + operationName, e);
+ + serviceName + "/" + operationName, e);
return null;
}
}
@@ -882,6 +1177,30 @@ public class OpenApiSpecGenerator {
meta.put("authHeader", "Authorization: Bearer <token>");
meta.put("tokenEndpoint", "POST /services/loginService/doLogin");
+ // ── Error contract in MCP _meta ──────────────────────────────────
+ // Describes the structured error envelope (Axis2JsonErrorResponse)
+ // that all operations may return on 4xx/5xx responses. Mirrors the
+ // OpenAPI components/schemas/ErrorResponse definition.
+ //
+ // MCP clients can use this to:
+ // 1. Auto-parse error responses without per-operation handling
+ // 2. Display the errorRef to users for support correlation
+ // 3. Implement retry logic based on retryAfter (429/503)
+ com.fasterxml.jackson.databind.node.ObjectNode errorContract = meta.putObject("errorContract");
+ errorContract.put("schemaRef", "#/components/schemas/ErrorResponse");
+ com.fasterxml.jackson.databind.node.ObjectNode errorFields = errorContract.putObject("fields");
+ errorFields.put("error", "Error code: VALIDATION_ERROR | RATE_LIMITED | SERVICE_UNAVAILABLE | BAD_REQUEST | INTERNAL_ERROR");
+ errorFields.put("message", "Human-readable error message");
+ errorFields.put("errorRef", "UUID correlation ID — quote in support requests");
+ errorFields.put("timestamp", "ISO 8601 when the error occurred");
+ errorFields.put("retryAfter", "Seconds to wait before retrying (429/503 only, null otherwise)");
+ com.fasterxml.jackson.databind.node.ObjectNode statusMap = errorContract.putObject("httpStatusMapping");
+ statusMap.put("400", "BAD_REQUEST — malformed JSON or missing required fields");
+ statusMap.put("422", "VALIDATION_ERROR — valid JSON but fails business validation");
+ statusMap.put("429", "RATE_LIMITED — too many requests, check retryAfter");
+ statusMap.put("500", "INTERNAL_ERROR — server fault, errorRef logged server-side");
+ statusMap.put("503", "SERVICE_UNAVAILABLE — downstream dependency or overload");
+
// Optional: natural key (ticker → assetId) resolution endpoint.
// Set axis2.xml global parameter "mcpTickerResolveService" to the
// "ServiceName/operationName" of the ticker lookup operation, e.g.:
diff --git a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java
index 28903c85bd..3d2f269cb4 100644
--- a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java
+++ b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/FinancialBenchmarkService.java
@@ -18,6 +18,7 @@
*/
package userguide.springboot.webservices;
+import org.apache.axis2.json.gson.rpc.JsonRpcFaultException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Component;
@@ -132,13 +133,21 @@ public class FinancialBenchmarkService {
* already annualized, pass {@code nPeriodsPerYear=1} and read
* {@code portfolioVolatility} (which equals the annualized vol in
* that case) instead of {@code annualizedVolatility}.
+ *
+ * @throws JsonRpcFaultException (HTTP 422) if input validation fails —
+ * e.g. missing weights, dimension mismatch, weights not summing to 1.0.
+ * The {@link JsonRpcMessageReceiver} catches the exception (wrapped in
+ * {@link java.lang.reflect.InvocationTargetException} from Method.invoke())
+ * and serializes the attached {@link Axis2JsonErrorResponse} as the JSON
+ * response body with the appropriate HTTP status code.
*/
- public PortfolioVarianceResponse portfolioVariance(PortfolioVarianceRequest request) {
+ public PortfolioVarianceResponse portfolioVariance(PortfolioVarianceRequest request)
+ throws JsonRpcFaultException {
String uuid = UUID.randomUUID().toString();
String logPrefix = "FinancialBenchmarkService.portfolioVariance uuid=" + uuid + " ";
if (request == null || request.getWeights() == null) {
- return PortfolioVarianceResponse.failed(
+ throw JsonRpcFaultException.validationError(
"Missing required field: \"weights\" array (nAssets elements summing to 1.0).");
}
@@ -146,7 +155,7 @@ public class FinancialBenchmarkService {
if (n <= 0 || n > MAX_ASSETS) {
String err = "n_assets=" + n + " is out of range [1, " + MAX_ASSETS + "].";
logger.warn("{} validation failed: {}", logPrefix, err);
- return PortfolioVarianceResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
// ── Resolve covariance matrix ─────────────────────────────────────────
@@ -155,19 +164,19 @@ public class FinancialBenchmarkService {
String err = "Missing or malformed \"covarianceMatrix\": provide a " + n + "×" + n +
" 2D array or a flat array of " + (long) n * n + " elements in row-major order.";
logger.warn("{} validation failed: {}", logPrefix, err);
- return PortfolioVarianceResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
if (cov.length != n) {
String err = "covarianceMatrix row count " + cov.length + " != nAssets " + n + ".";
logger.warn("{} validation failed: {}", logPrefix, err);
- return PortfolioVarianceResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
for (int i = 0; i < n; i++) {
if (cov[i] == null || cov[i].length != n) {
String err = "covarianceMatrix row " + i + " has " +
(cov[i] == null ? 0 : cov[i].length) + " columns, expected " + n + ".";
logger.warn("{} validation failed: {}", logPrefix, err);
- return PortfolioVarianceResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
}
@@ -201,7 +210,7 @@ public class FinancialBenchmarkService {
String err = "normalizeWeights=true but weights sum to " + weightSum +
". Cannot normalize a zero-weight portfolio.";
logger.warn("{} validation failed: {}", logPrefix, err);
- return PortfolioVarianceResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
if (Math.abs(weightSum - 1.0) > 1e-10) {
for (int i = 0; i < n; i++) weights[i] /= weightSum;
@@ -214,7 +223,7 @@ public class FinancialBenchmarkService {
", expected 1.0 (tolerance 1e-4). " +
"Pass normalizeWeights=true to rescale automatically.";
logger.warn("{} validation failed: {}", logPrefix, err);
- return PortfolioVarianceResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
}
@@ -322,13 +331,17 @@ public class FinancialBenchmarkService {
* input and produce a confidently wrong VaR. Callers running
* extreme scenarios must validate inputs upstream (e.g., reject
* {@code σ·√T > ~20}).
+ *
+ * @throws JsonRpcFaultException (HTTP 422) if input validation fails —
+ * e.g. null request, non-positive initialValue, negative volatility.
*/
- public MonteCarloResponse monteCarlo(MonteCarloRequest request) {
+ public MonteCarloResponse monteCarlo(MonteCarloRequest request)
+ throws JsonRpcFaultException {
String uuid = UUID.randomUUID().toString();
String logPrefix = "FinancialBenchmarkService.monteCarlo uuid=" + uuid + " ";
if (request == null) {
- return MonteCarloResponse.failed("Request must not be null.");
+ throw JsonRpcFaultException.validationError("Request must not be null.");
}
int nSims = Math.min(request.getNSimulations(), MAX_SIMULATIONS);
@@ -345,11 +358,11 @@ public class FinancialBenchmarkService {
// zero or negative value reaches here via direct field access paths
// and must be rejected at the service boundary.
if (initialValue <= 0.0) {
- return MonteCarloResponse.failed(
+ throw JsonRpcFaultException.validationError(
"initialValue must be > 0 (GBM is undefined for non-positive starting values).");
}
if (sigma < 0.0) {
- return MonteCarloResponse.failed("volatility must be >= 0.");
+ throw JsonRpcFaultException.validationError("volatility must be >= 0.");
}
// ── Pre-computed GBM constants ────────────────────────────────────────
@@ -520,13 +533,18 @@ public class FinancialBenchmarkService {
* study (O(1) vs O(n) lookup cost) and is independent of the financial
* math. It exists to give callers real numbers for the data-structure
* choice when building their own scenario analysis pipelines.
+ *
+ * @throws JsonRpcFaultException (HTTP 422) if input validation fails —
+ * e.g. missing assets, non-positive currentPrice, probability sums
+ * deviating from 1.0 beyond the configured tolerance.
*/
- public ScenarioAnalysisResponse scenarioAnalysis(ScenarioAnalysisRequest request) {
+ public ScenarioAnalysisResponse scenarioAnalysis(ScenarioAnalysisRequest request)
+ throws JsonRpcFaultException {
String uuid = UUID.randomUUID().toString();
String logPrefix = "FinancialBenchmarkService.scenarioAnalysis uuid=" + uuid + " ";
if (request == null || request.getAssets() == null || request.getAssets().isEmpty()) {
- return ScenarioAnalysisResponse.failed(
+ throw JsonRpcFaultException.validationError(
"Missing required field: \"assets\" array. " +
"Each entry must have assetId, currentPrice, positionSize, and scenarios " +
"[{price, probability}].");
@@ -537,7 +555,7 @@ public class FinancialBenchmarkService {
if (nAssets > MAX_ASSETS) {
String err = "assets count " + nAssets + " exceeds maximum " + MAX_ASSETS + ".";
logger.warn("{} validation failed: {}", logPrefix, err);
- return ScenarioAnalysisResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
double probTolerance = request.getProbTolerance();
@@ -572,7 +590,7 @@ public class FinancialBenchmarkService {
"which is undefined for non-positive currentPrice.",
i, asset.getAssetId(), asset.getCurrentPrice());
logger.warn("{} validation failed: {}", logPrefix, err);
- return ScenarioAnalysisResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
if (asset.getScenarios() == null || asset.getScenarios().isEmpty()) {
@@ -581,7 +599,7 @@ public class FinancialBenchmarkService {
"At least one scenario {price, probability} is required.",
i, asset.getAssetId());
logger.warn("{} validation failed: {}", logPrefix, err);
- return ScenarioAnalysisResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
if (asset.getScenarios().size() > MAX_SCENARIOS) {
@@ -591,7 +609,7 @@ public class FinancialBenchmarkService {
i, asset.getAssetId(),
asset.getScenarios().size(), MAX_SCENARIOS);
logger.warn("{} validation failed: {}", logPrefix, err);
- return ScenarioAnalysisResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
double probSum = 0.0;
@@ -607,7 +625,7 @@ public class FinancialBenchmarkService {
i, asset.getAssetId(), probSum, probTolerance,
asset.getScenarios().size());
logger.warn("{} validation failed: {}", logPrefix, err);
- return ScenarioAnalysisResponse.failed(err);
+ throw JsonRpcFaultException.validationError(err);
}
}
diff --git a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/ScenarioAnalysisRequest.java b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/ScenarioAnalysisRequest.java
index a42da3b338..e7301bb0f5 100644
--- a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/ScenarioAnalysisRequest.java
+++ b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/webservices/ScenarioAnalysisRequest.java
@@ -80,7 +80,7 @@ public class ScenarioAnalysisRequest {
*/
public static class AssetScenario {
- /** Unique asset identifier (e.g., security ID or fund-asset ID). */
+ /** Unique asset identifier (e.g., a security ID). */
private long assetId;
/** Current market price in currency units. Must be > 0. */
diff --git a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
index f399739c15..ddcac640d2 100644
--- a/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
+++ b/modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/test/java/userguide/springboot/webservices/FinancialBenchmarkServiceTest.java
@@ -18,6 +18,7 @@
*/
package userguide.springboot.webservices;
+import org.apache.axis2.json.gson.rpc.JsonRpcFaultException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.*;
* <li>Input validation: null, out-of-range, malformed arrays, probability sums</li>
* <li>Edge cases: single asset, zero volatility, weight normalization, seeded reproducibility</li>
* <li>New API fields: nPeriodsPerYear, percentiles, normalizeWeights, probTolerance</li>
+ * <li>Error contracts: validation errors throw {@link JsonRpcFaultException} with HTTP 422</li>
* </ul>
*/
class FinancialBenchmarkServiceTest {
@@ -51,7 +53,7 @@ class FinancialBenchmarkServiceTest {
// ═══════════════════════════════════════════════════════════════════════
@Test
- void testPortfolioVariance_twoAssets_knownResult() {
+ void testPortfolioVariance_twoAssets_knownResult() throws JsonRpcFaultException {
// 50/50 portfolio, cov = [[0.04, 0.006],[0.006, 0.09]]
// σ²_p = 0.5²×0.04 + 2×0.5×0.5×0.006 + 0.5²×0.09 = 0.01 + 0.003 + 0.0225 = 0.0355
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
@@ -71,7 +73,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testPortfolioVariance_singleAsset() {
+ void testPortfolioVariance_singleAsset() throws JsonRpcFaultException {
// Diagonal 1×1: σ²_p = w² × σ² = 1² × 0.04 = 0.04
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
req.setWeights(new double[]{1.0});
@@ -85,7 +87,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testPortfolioVariance_flatMatrixFormat() {
+ void testPortfolioVariance_flatMatrixFormat() throws JsonRpcFaultException {
// Same 2-asset test, but using flat array
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
req.setWeights(new double[]{0.5, 0.5});
@@ -98,7 +100,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testPortfolioVariance_nPeriodsPerYear() {
+ void testPortfolioVariance_nPeriodsPerYear() throws JsonRpcFaultException {
// Monthly data: nPeriodsPerYear=12
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
req.setWeights(new double[]{1.0});
@@ -113,7 +115,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testPortfolioVariance_normalizeWeights_rescalesAndReports() {
+ void testPortfolioVariance_normalizeWeights_rescalesAndReports() throws JsonRpcFaultException {
// Weights sum to 2.0 — should be normalized to {0.5, 0.5}
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
req.setWeights(new double[]{1.0, 1.0});
@@ -130,7 +132,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testPortfolioVariance_normalizeWeights_alreadyUnit_noRescale() {
+ void testPortfolioVariance_normalizeWeights_alreadyUnit_noRescale() throws JsonRpcFaultException {
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
req.setWeights(new double[]{0.5, 0.5});
req.setCovarianceMatrix(new double[][]{{0.04, 0.006}, {0.006, 0.09}});
@@ -143,14 +145,15 @@ class FinancialBenchmarkServiceTest {
}
// ═══════════════════════════════════════════════════════════════════════
- // portfolioVariance — validation
+ // portfolioVariance — validation (now throws JsonRpcFaultException / 422)
// ═══════════════════════════════════════════════════════════════════════
@Test
void testPortfolioVariance_nullRequest() {
- PortfolioVarianceResponse resp = service.portfolioVariance(null);
- assertEquals("FAILED", resp.getStatus());
- assertNotNull(resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(null));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertNotNull(ex.getErrorResponse().getErrorRef());
}
@Test
@@ -158,10 +161,11 @@ class FinancialBenchmarkServiceTest {
PortfolioVarianceRequest req = new PortfolioVarianceRequest();
req.setWeights(new double[FinancialBenchmarkService.MAX_ASSETS + 1]);
// No matrix set — should fail on nAssets check first
- PortfolioVarianceResponse resp = service.portfolioVariance(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("out of range"),
- "error must mention out-of-range: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("out of range"),
+ "error must mention out-of-range: " + ex.getMessage());
}
@Test
@@ -170,10 +174,11 @@ class FinancialBenchmarkServiceTest {
req.setWeights(new double[]{0.5, 0.5});
// No matrix set
- PortfolioVarianceResponse resp = service.portfolioVariance(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("covarianceMatrix"),
- "error must mention covarianceMatrix: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("covarianceMatrix"),
+ "error must mention covarianceMatrix: " + ex.getMessage());
}
@Test
@@ -182,10 +187,11 @@ class FinancialBenchmarkServiceTest {
req.setWeights(new double[]{0.3, 0.3}); // sum=0.6
req.setCovarianceMatrix(new double[][]{{0.04, 0.006}, {0.006, 0.09}});
- PortfolioVarianceResponse resp = service.portfolioVariance(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("normalizeWeights"),
- "error should suggest normalizeWeights: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("normalizeWeights"),
+ "error should suggest normalizeWeights: " + ex.getMessage());
}
@Test
@@ -195,10 +201,11 @@ class FinancialBenchmarkServiceTest {
req.setCovarianceMatrix(new double[][]{{0.04, 0.006}, {0.006, 0.09}});
req.setNormalizeWeights(true);
- PortfolioVarianceResponse resp = service.portfolioVariance(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("zero-weight"),
- "error must mention zero-weight: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("zero-weight"),
+ "error must mention zero-weight: " + ex.getMessage());
}
@Test
@@ -207,8 +214,24 @@ class FinancialBenchmarkServiceTest {
req.setWeights(new double[]{0.5, 0.5});
req.setCovarianceMatrixFlat(new double[]{0.04, 0.006, 0.006}); // length 3, expected 4
- PortfolioVarianceResponse resp = service.portfolioVariance(req);
- assertEquals("FAILED", resp.getStatus());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // portfolioVariance — error response structure
+ // ═══════════════════════════════════════════════════════════════════════
+
+ @Test
+ void testPortfolioVariance_errorResponse_hasStructuredFields() {
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.portfolioVariance(null));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertEquals("VALIDATION_ERROR", ex.getErrorResponse().getError());
+ assertNotNull(ex.getErrorResponse().getErrorRef(), "errorRef UUID must be present");
+ assertNotNull(ex.getErrorResponse().getTimestamp(), "timestamp must be present");
+ assertNull(ex.getErrorResponse().getRetryAfter(), "retryAfter should be null for 422");
}
// ═══════════════════════════════════════════════════════════════════════
@@ -216,7 +239,7 @@ class FinancialBenchmarkServiceTest {
// ═══════════════════════════════════════════════════════════════════════
@Test
- void testMonteCarlo_seededRunIsReproducible() {
+ void testMonteCarlo_seededRunIsReproducible() throws JsonRpcFaultException {
MonteCarloRequest req1 = new MonteCarloRequest();
req1.setNSimulations(1000);
req1.setRandomSeed(42L);
@@ -234,7 +257,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testMonteCarlo_defaults_succeed() {
+ void testMonteCarlo_defaults_succeed() throws JsonRpcFaultException {
MonteCarloResponse resp = service.monteCarlo(new MonteCarloRequest());
assertEquals("SUCCESS", resp.getStatus());
assertTrue(resp.getMeanFinalValue() > 0, "mean final value should be positive");
@@ -242,7 +265,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testMonteCarlo_zeroVolatility_allPathsEqual() {
+ void testMonteCarlo_zeroVolatility_allPathsEqual() throws JsonRpcFaultException {
MonteCarloRequest req = new MonteCarloRequest();
req.setNSimulations(100);
req.setVolatility(0.0);
@@ -258,7 +281,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testMonteCarlo_customPercentiles() {
+ void testMonteCarlo_customPercentiles() throws JsonRpcFaultException {
MonteCarloRequest req = new MonteCarloRequest();
req.setNSimulations(1000);
req.setRandomSeed(7L);
@@ -278,7 +301,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testMonteCarlo_nPeriodsPerYear_affectsDt() {
+ void testMonteCarlo_nPeriodsPerYear_affectsDt() throws JsonRpcFaultException {
// Same parameters, different nPeriodsPerYear: monthly (12) vs daily (252)
// Monthly has much larger per-step vol (σ×√(1/12) vs σ×√(1/252))
// so final value distribution should be wider
@@ -305,7 +328,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testMonteCarlo_capsAtMaxSimulations() {
+ void testMonteCarlo_capsAtMaxSimulations() throws JsonRpcFaultException {
MonteCarloRequest req = new MonteCarloRequest();
req.setNSimulations(Integer.MAX_VALUE); // way over limit
@@ -323,10 +346,42 @@ class FinancialBenchmarkServiceTest {
MonteCarloRequest req = new MonteCarloRequest();
req.setVolatility(-0.1);
- MonteCarloResponse resp = service.monteCarlo(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("volatility"),
- "error must mention volatility: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.monteCarlo(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("volatility"),
+ "error must mention volatility: " + ex.getMessage());
+ }
+
+ @Test
+ void testMonteCarlo_nullRequest_fails() {
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.monteCarlo(null));
+ assertEquals(422, ex.getHttpStatusCode());
+ }
+
+ @Test
+ void testMonteCarlo_negativeInitialValue_fails() {
+ MonteCarloRequest req = new MonteCarloRequest();
+ req.setInitialValue(-100.0);
+
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.monteCarlo(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("initialValue"),
+ "error must mention initialValue: " + ex.getMessage());
+ }
+
+ @Test
+ void testMonteCarlo_zeroInitialValue_fails() {
+ MonteCarloRequest req = new MonteCarloRequest();
+ req.setInitialValue(0.0);
+
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.monteCarlo(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("initialValue"),
+ "error must mention initialValue for zero: " + ex.getMessage());
}
// ═══════════════════════════════════════════════════════════════════════
@@ -334,7 +389,7 @@ class FinancialBenchmarkServiceTest {
// ═══════════════════════════════════════════════════════════════════════
@Test
- void testScenarioAnalysis_singleAsset_knownResult() {
+ void testScenarioAnalysis_singleAsset_knownResult() throws JsonRpcFaultException {
// Asset: price=100, position=10 shares
// Scenario 1: price=120, prob=0.6 → gain = 20×10 = 200
// Scenario 2: price=80, prob=0.4 → loss = 20×10 = 200
@@ -368,7 +423,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testScenarioAnalysis_hashLookup_fasterThanLinear_largePortfolio() {
+ void testScenarioAnalysis_hashLookup_fasterThanLinear_largePortfolio() throws JsonRpcFaultException {
// Build 200-asset portfolio to make timing difference detectable
List<ScenarioAnalysisRequest.AssetScenario> assets = new java.util.ArrayList<>();
for (int i = 0; i < 200; i++) {
@@ -398,7 +453,7 @@ class FinancialBenchmarkServiceTest {
}
@Test
- void testScenarioAnalysis_useHashLookupFalse_skipsHashBenchmark() {
+ void testScenarioAnalysis_useHashLookupFalse_skipsHashBenchmark() throws JsonRpcFaultException {
ScenarioAnalysisRequest.AssetScenario asset = buildSimpleAsset(1L, 100.0, 1.0, 0.5, 0.5);
ScenarioAnalysisRequest req = new ScenarioAnalysisRequest();
req.setAssets(List.of(asset));
@@ -416,8 +471,9 @@ class FinancialBenchmarkServiceTest {
@Test
void testScenarioAnalysis_nullRequest_fails() {
- ScenarioAnalysisResponse resp = service.scenarioAnalysis(null);
- assertEquals("FAILED", resp.getStatus());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.scenarioAnalysis(null));
+ assertEquals(422, ex.getHttpStatusCode());
}
@Test
@@ -425,10 +481,11 @@ class FinancialBenchmarkServiceTest {
ScenarioAnalysisRequest req = new ScenarioAnalysisRequest();
req.setAssets(List.of());
- ScenarioAnalysisResponse resp = service.scenarioAnalysis(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("assets"),
- "error must mention assets: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.scenarioAnalysis(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("assets"),
+ "error must mention assets: " + ex.getMessage());
}
@Test
@@ -447,14 +504,15 @@ class FinancialBenchmarkServiceTest {
ScenarioAnalysisRequest req = new ScenarioAnalysisRequest();
req.setAssets(List.of(asset));
- ScenarioAnalysisResponse resp = service.scenarioAnalysis(req);
- assertEquals("FAILED", resp.getStatus());
- assertTrue(resp.getErrorMessage().contains("probabilities sum"),
- "error must mention probability sum: " + resp.getErrorMessage());
+ JsonRpcFaultException ex = assertThrows(JsonRpcFaultException.class,
+ () -> service.scenarioAnalysis(req));
+ assertEquals(422, ex.getHttpStatusCode());
+ assertTrue(ex.getMessage().contains("probabilities sum"),
+ "error must mention probability sum: " + ex.getMessage());
}
@Test
- void testScenarioAnalysis_customProbTolerance_acceptsSlightlyOff() {
+ void testScenarioAnalysis_customProbTolerance_acceptsSlightlyOff() throws JsonRpcFaultException {
// Probabilities sum to 0.999 — rejected with default 1e-4, accepted with 0.002
ScenarioAnalysisRequest.Scenario s1 = new ScenarioAnalysisRequest.Scenario();
s1.setPrice(110.0); s1.setProbability(0.5);
@@ -470,7 +528,8 @@ class FinancialBenchmarkServiceTest {
// Default tolerance (1e-4) — should fail
ScenarioAnalysisRequest reqTight = new ScenarioAnalysisRequest();
reqTight.setAssets(List.of(asset));
- assertEquals("FAILED", service.scenarioAnalysis(reqTight).getStatus(),
+ assertThrows(JsonRpcFaultException.class,
+ () -> service.scenarioAnalysis(reqTight),
"tight tolerance should reject sum=0.999");
// Loose tolerance (0.002) — should succeed
@@ -490,15 +549,20 @@ class FinancialBenchmarkServiceTest {
MonteCarloRequest req = new MonteCarloRequest();
req.setNSimulations(0); // invalid → default 10,000
req.setNPeriods(-1); // invalid → default 252
- req.setInitialValue(0.0); // invalid → default 1,000,000
req.setNPeriodsPerYear(0); // invalid → default 252
req.setPercentiles(null); // null → default {0.01, 0.05}
assertEquals(10_000, req.getNSimulations());
assertEquals(252, req.getNPeriods());
- assertEquals(1_000_000.0, req.getInitialValue(), 1e-9);
assertEquals(252, req.getNPeriodsPerYear());
assertArrayEquals(new double[]{0.01, 0.05}, req.getPercentiles(), 1e-9);
+
+ // initialValue getter returns the raw value (no clamping); the service
+ // itself validates non-positive values and throws JsonRpcFaultException.
+ // The field initializer supplies the default for the "never set" case.
+ MonteCarloRequest fresh = new MonteCarloRequest();
+ assertEquals(1_000_000.0, fresh.getInitialValue(), 1e-9,
+ "fresh request should have default initialValue=1,000,000");
}
// ═══════════════════════════════════════════════════════════════════════
diff --git a/src/site/xdoc/docs/toc.xml b/src/site/xdoc/docs/toc.xml
index e08fe9bbfb..fc273a288a 100644
--- a/src/site/xdoc/docs/toc.xml
+++ b/src/site/xdoc/docs/toc.xml
@@ -62,6 +62,14 @@ Guide</a></li>
<li>7.4 <a href="openapi-rest-userguide.html#Swagger_UI">Interactive Swagger UI</a></li>
<li>7.5 <a href="openapi-rest-advanced-userguide.html">Advanced Enterprise OpenAPI Features</a></li>
<li>7.6 <a href="openapi-rest-advanced-http2-userguide.html">OpenAPI + HTTP/2 Performance Integration</a></li>
+<li>7.7 <a href="openapi-jpa-schema.html">JPA/Hibernate Schema Generation</a>
+<ul>
+<li>7.7.1 <a href="openapi-jpa-schema.html#annotation_mode">JPA Annotation Mode (@Entity, @Column, @ManyToOne)</a></li>
+<li>7.7.2 <a href="openapi-jpa-schema.html#hbm_xml_mode">Hibernate XML Mapping Mode (.hbm.xml)</a></li>
+<li>7.7.3 <a href="openapi-jpa-schema.html#read_write_schemas">Read vs Write Schema Generation</a></li>
+<li>7.7.4 <a href="openapi-jpa-schema.html#custom_annotations">Custom Audit Annotation Support</a></li>
+</ul>
+</li>
</ul>
</li>
<li><a href="axis2config.html">Configuration
@@ -196,6 +204,15 @@ Support</a></li>
<li><a href="mcp-examples.html#full-performance-summary">Full Performance Summary (C vs Java)</a></li>
</ul>
</li>
+ <li><strong>23.8 <a href="json-pagination.html">Offset/Limit Pagination for JSON-RPC Services</a></strong>
+ <ul>
+ <li>23.8.1 <a href="json-pagination.html#overview">Overview: PaginatedResponse and PaginationRequest</a></li>
+ <li>23.8.2 <a href="json-pagination.html#wire_format">Wire Format (data + pagination metadata)</a></li>
+ <li>23.8.3 <a href="json-pagination.html#service_integration">Service Integration (DAO offset/limit pattern)</a></li>
+ <li>23.8.4 <a href="json-pagination.html#frontend_patterns">Frontend Patterns (virtual scroll, page controls, infinite scroll)</a></li>
+ <li>23.8.5 <a href="json-pagination.html#safety">Safety: maxLimit clamping and input validation</a></li>
+ </ul>
+ </li>
</ul>
</li>
<li><a href="corba-deployer.html">Exposing CORBA Services as Web Services</a></li>