From 00434794227fe46fb64b12219f0a61b9b984291d Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Wed, 5 Aug 2026 13:24:12 +0800 Subject: [PATCH 1/9] docs: document servlet annotations routing and interceptors --- README.md | 20 ++++++++ docs/servlet_annotations.md | 97 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 README.md create mode 100644 docs/servlet_annotations.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b839f48 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# JavaCommons-servlet + +`JavaCommons-servlet` is the web and routing layer of the `JavaCommons` library stack. It manages the server's menu, routing incoming web requests dynamically to controller classes via custom annotations, providing structured response helpers, and generating client-side JavaScript APIs. + +--- + +## Core Features + +* **Annotation-based Routing**: Route incoming HTTP requests directly to controller methods using annotations instead of mapping files. +* **Hierarchical Nested Routing**: Fields can be annotated to mount recursive sub-page routers dynamically to keep controller code modular and highly structured. +* **Dynamic Axios client-side generation**: Scans your backend endpoints to compile frontend-consumable JavaScript API client wrappers automatically. +* **Type-Safe Input/Output Mapping**: Supports automatic binding of common parameters (e.g. `PrintWriter`, `ServletRequestMap`, `ApiResponseMap`) and serializes returned Map results directly to JSON. + +--- + +## Documentation + +To help developers get up to speed with the routing system and understand the behaviors of standard servlet annotations, see the dedicated guides: + +* [Annotation Routing Guide (ApiPath vs RequestPath)](./docs/servlet_annotations.md) — A comprehensive guide explaining the difference between `@ApiPath` and `@RequestPath` annotations, their targets, execution pipelines, and error handling. diff --git a/docs/servlet_annotations.md b/docs/servlet_annotations.md new file mode 100644 index 0000000..e365d9f --- /dev/null +++ b/docs/servlet_annotations.md @@ -0,0 +1,97 @@ +# JavaCommons-servlet Annotation Routing & Interceptor Guide + +This guide explains the routing and interceptor mechanisms in `JavaCommons-servlet`. It clarifies the behaviors and technical differences between the library's core routing annotations (`@ApiPath`, `@RequestPath`), method constraints (`@RequestType`), and lifecycle hooks (`@RequestBefore`, `@RequestAfter`). + +--- + +## At a Glance: Master Annotation Cheat-Sheet + +| Annotation | Primary Use-Case | Target Scope | HTTP Method Verification | Multi-Match Execution | Exception Handling | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **`@ApiPath`** | Programmatic JSON APIs consumed by client web apps. Generates client JS. | Methods only | **Ignored** (accepts all HTTP methods) | **No** (first exact match wins) | **Automated JSON wrap** (returns structured JSON maps) | +| **`@RequestPath`** | Standard page-level & hierarchical sub-page routing. | Methods & Fields | **Enforced** (filtered via `@RequestType`) | **No** (first exact match wins) | **Raw propagation** (escapes to servlet container) | +| **`@RequestType`** | HTTP method constraint (GET, POST, etc.) for a route. | Methods only | **N/A** (defines the allowed verbs) | **N/A** (declares allowed verbs list) | **N/A** | +| **`@RequestBefore`** | Pre-request hook (runs before target endpoint). | Methods only | **Ignored** (accepts all HTTP methods) | **Yes** (runs **all** matches in specificity order) | Inherits behavior of matching parent route | +| **`@RequestAfter`** | Post-request hook (runs after target endpoint). | Methods only | **Ignored** (accepts all HTTP methods) | **Yes** (runs **all** matches in specificity order) | Inherits behavior of matching parent route | + +--- + +## 1. Routing Annotations + +### `@ApiPath` (Client-Facing API Layer) +Designed for backend programmatic JSON endpoints. +* **Frontend Integration**: Automatically scanned by `AxiosApiBuilder` to generate client-side JS wrapper functions (`axiosApi.js`), allowing frontend devs to call APIs natively (e.g. `api.user.getProfile()`). +* **HTTP Method Blind**: Ignores HTTP verbs (e.g. `@RequestType` constraints are ignored during `@ApiPath` routing). +* **Automated Exception Handling**: Any exception thrown is caught internally, wrapped into an `ApiException`, and returned as a standard JSON error: + ```json + {"ERROR": {"message": "Error details..."}} + ``` + +### `@RequestPath` (Traditional Page & Router Layer) +Acts as a traditional HTTP route handler, similar to standard servlet mapping. +* **Sub-Routers (Field Injection)**: Can target fields to build modular, hierarchical path structures: + ```java + @RequestPath("admin/*") + private AdminPage adminRouter; // Mounts AdminPage routes under /admin/... + ``` +* **Enforced Verb Filtering**: Honors `@RequestType` filtering. +* **Raw Exceptions**: Standard runtime exceptions propagate directly to the servlet container (typically displaying 500 error pages or raw stack traces). + +--- + +## 2. HTTP Method Filtering: `@RequestType` + +The `@RequestType` annotation restricts an endpoint to specific HTTP methods (e.g. `GET`, `POST`, `PUT`, `DELETE`). + +```java +@RequestPath("profile") +@RequestType("POST") +public void saveProfile(PrintWriter writer) { ... } +``` + +### Critical Behaviors & Gotchas: +1. **Requires `@RequestPath`**: `@RequestType` is **only enforced** when paired with `@RequestPath`. +2. **Ignored on `@ApiPath`**: The `@ApiPath` lookup pipeline executes without HTTP method context, meaning any `@RequestType` constraint on an `@ApiPath` endpoint is **ignored** on the server side (though it is used by the frontend Axios generator). +3. **Ignored on Interceptors**: `@RequestBefore` and `@RequestAfter` do not respect `@RequestType`. They execute on any matching request path regardless of the HTTP method used. +4. **Default Behavior (Omitting `@RequestType`)**: If `@RequestType` is omitted from a `@RequestPath` method, it is **open to all HTTP methods** (GET, POST, PUT, DELETE, etc.). + * *Unit Test Verification*: This default behavior and its strict enforcement when `@RequestType` is provided are explicitly validated in the servlet library test suite under `BasePage_requestType_test.java`. + +--- + +## 3. Lifecycle Interceptors: `@RequestBefore` and `@RequestAfter` + +Interceptors provide a powerful hook system to execute cross-cutting concerns (e.g., authentication, request logging, session management, or adding custom response headers). + +```java +@RequestBefore("admin/*") +public void checkAdminAuth(ApiResponseMap response) { + if (!isAdmin()) { + halt(); // Halts further execution instantly + } +} +``` + +### Key Execution Rules: + +#### A. Execution Pipeline +For any incoming request, the execution sequence is: +$$\text{Matched } @RequestBefore \text{ hooks} \longrightarrow \text{Target Route (API/Request)} \longrightarrow \text{Matched } @RequestAfter \text{ hooks}$$ + +#### B. Multiple Interceptor Matching & Wildcards +Unlike route handlers (where only the first match is chosen), the servlet engine finds **all** matching `@RequestBefore` / `@RequestAfter` hooks and executes **every single one**. +* Wildcards (e.g. `admin/*`) and path templates (e.g. `user/:id/*`) are fully supported. + +#### C. Deterministic Execution Order (Specificity Sorting) +When multiple interceptors match a request, they are sorted using the library's `sortEndpointList` algorithm: +1. **Exact match segment (Weight 0)** is prioritized over a **path variable segment (e.g. `:id`, Weight 1)**, which is prioritized over a **wildcard segment (e.g. `*`, Weight 2)**. +2. **Longer matched paths** (more segments) are prioritized over shorter ones. + +> [!IMPORTANT] +> **Interceptors execute in order of specificity (most specific first, least specific last).** +> For a request to `/admin/user/profile`, the hook matching `/admin/user/profile` runs **before** `/admin/*`. + +#### D. Parameter Injection +Interceptors support the same automatic parameter injection as routing methods, meaning you can request parameters such as `PrintWriter`, `ServletRequestMap`, `ApiResponseMap`, or `HttpServletRequest` directly in the method signature. + +#### E. Halting Execution +Calling `halt()` on the page instance inside a `@RequestBefore` hook throws a `HaltException`. This immediately interrupts the execution pipeline, preventing the target route handler and any subsequent hooks from running. From f43a7ae72f4beb57ed9a7a552514862cebea1df9 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Wed, 5 Aug 2026 16:25:27 +0800 Subject: [PATCH 2/9] feat: enforce server-side HTTP method validation on @ApiPath endpoints and expand unit tests --- docs/servlet_annotations.md | 12 ++-- .../servlet/internal/BasePageClassMap.java | 4 +- .../servlet/BasePage_requestType_test.java | 69 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/docs/servlet_annotations.md b/docs/servlet_annotations.md index e365d9f..2a1e86a 100644 --- a/docs/servlet_annotations.md +++ b/docs/servlet_annotations.md @@ -8,7 +8,7 @@ This guide explains the routing and interceptor mechanisms in `JavaCommons-servl | Annotation | Primary Use-Case | Target Scope | HTTP Method Verification | Multi-Match Execution | Exception Handling | | :--- | :--- | :--- | :--- | :--- | :--- | -| **`@ApiPath`** | Programmatic JSON APIs consumed by client web apps. Generates client JS. | Methods only | **Ignored** (accepts all HTTP methods) | **No** (first exact match wins) | **Automated JSON wrap** (returns structured JSON maps) | +| **`@ApiPath`** | Programmatic JSON APIs consumed by client web apps. Generates client JS. | Methods only | **Enforced** (filtered via `@RequestType`) | **No** (first exact match wins) | **Automated JSON wrap** (returns structured JSON maps) | | **`@RequestPath`** | Standard page-level & hierarchical sub-page routing. | Methods & Fields | **Enforced** (filtered via `@RequestType`) | **No** (first exact match wins) | **Raw propagation** (escapes to servlet container) | | **`@RequestType`** | HTTP method constraint (GET, POST, etc.) for a route. | Methods only | **N/A** (defines the allowed verbs) | **N/A** (declares allowed verbs list) | **N/A** | | **`@RequestBefore`** | Pre-request hook (runs before target endpoint). | Methods only | **Ignored** (accepts all HTTP methods) | **Yes** (runs **all** matches in specificity order) | Inherits behavior of matching parent route | @@ -21,7 +21,7 @@ This guide explains the routing and interceptor mechanisms in `JavaCommons-servl ### `@ApiPath` (Client-Facing API Layer) Designed for backend programmatic JSON endpoints. * **Frontend Integration**: Automatically scanned by `AxiosApiBuilder` to generate client-side JS wrapper functions (`axiosApi.js`), allowing frontend devs to call APIs natively (e.g. `api.user.getProfile()`). -* **HTTP Method Blind**: Ignores HTTP verbs (e.g. `@RequestType` constraints are ignored during `@ApiPath` routing). +* **Enforced Verb Filtering**: Honors `@RequestType` filtering. If an incoming request's HTTP verb does not match the `@RequestType` constraints of the endpoint, it returns a `404 Error` instead of executing. * **Automated Exception Handling**: Any exception thrown is caught internally, wrapped into an `ApiException`, and returned as a standard JSON error: ```json {"ERROR": {"message": "Error details..."}} @@ -50,11 +50,11 @@ public void saveProfile(PrintWriter writer) { ... } ``` ### Critical Behaviors & Gotchas: -1. **Requires `@RequestPath`**: `@RequestType` is **only enforced** when paired with `@RequestPath`. -2. **Ignored on `@ApiPath`**: The `@ApiPath` lookup pipeline executes without HTTP method context, meaning any `@RequestType` constraint on an `@ApiPath` endpoint is **ignored** on the server side (though it is used by the frontend Axios generator). +1. **Fully Enforced on Route Handlers**: `@RequestType` is fully enforced when paired with either `@RequestPath` or `@ApiPath`. +2. **Server-Side API Enforcement**: If an incoming request to an `@ApiPath` endpoint does not match its `@RequestType` constraints (e.g., executing a POST-only endpoint using a GET request), the routing engine rejects the route and returns a `404 Error`. 3. **Ignored on Interceptors**: `@RequestBefore` and `@RequestAfter` do not respect `@RequestType`. They execute on any matching request path regardless of the HTTP method used. -4. **Default Behavior (Omitting `@RequestType`)**: If `@RequestType` is omitted from a `@RequestPath` method, it is **open to all HTTP methods** (GET, POST, PUT, DELETE, etc.). - * *Unit Test Verification*: This default behavior and its strict enforcement when `@RequestType` is provided are explicitly validated in the servlet library test suite under `BasePage_requestType_test.java`. +4. **Default Behavior (Omitting `@RequestType`)**: If `@RequestType` is omitted from an endpoint, it is **open to all HTTP methods** (GET, POST, PUT, DELETE, etc.). + * *Unit Test Verification*: Both `@RequestPath` and `@ApiPath` verb verification behaviors (including single, multiple, and omitted constraints) are validated in the library test suite under `BasePage_requestType_test.java`. --- diff --git a/src/main/java/picoded/servlet/internal/BasePageClassMap.java b/src/main/java/picoded/servlet/internal/BasePageClassMap.java index f027333..b6ac59a 100755 --- a/src/main/java/picoded/servlet/internal/BasePageClassMap.java +++ b/src/main/java/picoded/servlet/internal/BasePageClassMap.java @@ -437,8 +437,8 @@ public void handleRequest(BasePage page, String[] routePath) { */ protected boolean request_api(BasePage page, String[] requestPath) { try { - // Get list of valid paths - List pathList = apiMap.findValidKeys(requestPath); + // Get list of valid paths matching the request type verb + List pathList = apiMap.findValidKeys(requestPath, page.requestType()); // Return false (if no endpoint found) if (pathList == null || pathList.size() <= 0) { diff --git a/src/test/java/picoded/servlet/BasePage_requestType_test.java b/src/test/java/picoded/servlet/BasePage_requestType_test.java index 2bdfc26..18189e2 100755 --- a/src/test/java/picoded/servlet/BasePage_requestType_test.java +++ b/src/test/java/picoded/servlet/BasePage_requestType_test.java @@ -66,6 +66,29 @@ public void no_requestType() { getPrintWriter().println("world"); } + @ApiPath("api/single") + @RequestType("POST") + public Map apiSingle() { + Map ret = new ApiResponseMap(); + ret.put("result", "pong"); + return ret; + } + + @ApiPath("api/multiple") + @RequestType({ "GET", "POST" }) + public Map apiMultiple() { + Map ret = new ApiResponseMap(); + ret.put("result", "pong"); + return ret; + } + + @ApiPath("api/none") + public Map apiNone() { + Map ret = new ApiResponseMap(); + ret.put("result", "pong"); + return ret; + } + } @Test @@ -120,4 +143,50 @@ public void test_invalidRequestType_single() throws Exception { + "Request URI : /type/single", response.toString().trim()); } + @Test + public void test_api_none() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/api/none"; + ResponseHttp response = RequestHttp.get(testUrl, null, null, null); + assertEquals("{\"result\":\"pong\"}", response.toString().replaceAll("\\s+", "")); + response = RequestHttp.post(testUrl, null, null, null); + assertEquals("{\"result\":\"pong\"}", response.toString().replaceAll("\\s+", "")); + } + + @Test + public void test_api_single() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/api/single"; + ResponseHttp response = RequestHttp.post(testUrl, null, null, null); + assertEquals("{\"result\":\"pong\"}", response.toString().replaceAll("\\s+", "")); + } + + @Test + public void test_api_single_invalid() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/api/single"; + ResponseHttp response = RequestHttp.get(testUrl, null, null, null); + assertEquals("

404 Error

\n" + "The requested resource is not avaliable Q.Q\n" + "\n" + + "Request URI : /api/single", response.toString().trim()); + } + + @Test + public void test_api_multiple() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/api/multiple"; + ResponseHttp response = RequestHttp.get(testUrl, null, null, null); + assertEquals("{\"result\":\"pong\"}", response.toString().replaceAll("\\s+", "")); + response = RequestHttp.post(testUrl, null, null, null); + assertEquals("{\"result\":\"pong\"}", response.toString().replaceAll("\\s+", "")); + } + + @Test + public void test_api_multiple_invalid() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/api/multiple"; + ResponseHttp response = RequestHttp.put(testUrl, null, null, null); + assertEquals("

404 Error

\n" + "The requested resource is not avaliable Q.Q\n" + "\n" + + "Request URI : /api/multiple", response.toString().trim()); + } + } \ No newline at end of file From e15f2ea1257614b9d30a9f3906c5831ee77ddaa9 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 14:55:04 +0800 Subject: [PATCH 3/9] feat: support RESTful multi-verb same-route matching & refactor registration scanner --- docs/servlet_annotations.md | 18 ++++ .../java/picoded/servlet/AxiosApiBuilder.java | 10 +- .../servlet/internal/BasePageClassMap.java | 83 +++++++++++--- .../picoded/servlet/internal/EndpointMap.java | 44 ++++---- .../servlet/BasePage_requestType_test.java | 101 ++++++++++++++++++ 5 files changed, 218 insertions(+), 38 deletions(-) diff --git a/docs/servlet_annotations.md b/docs/servlet_annotations.md index 2a1e86a..3113128 100644 --- a/docs/servlet_annotations.md +++ b/docs/servlet_annotations.md @@ -56,6 +56,24 @@ public void saveProfile(PrintWriter writer) { ... } 4. **Default Behavior (Omitting `@RequestType`)**: If `@RequestType` is omitted from an endpoint, it is **open to all HTTP methods** (GET, POST, PUT, DELETE, etc.). * *Unit Test Verification*: Both `@RequestPath` and `@ApiPath` verb verification behaviors (including single, multiple, and omitted constraints) are validated in the library test suite under `BasePage_requestType_test.java`. +### Multi-Verb Same-Endpoint Routing + +You can map multiple distinct controller methods to the exact same route path by restricting each method with different HTTP verbs via `@RequestType`. This enables clean RESTful routing (e.g., mapping a `GET` request on `/users` to a list method, and a `POST` request on `/users` to a creation method). + +```java +@RequestPath("users") +@RequestType("GET") +public void listUsers() { ... } + +@RequestPath("users") +@RequestType("POST") +public void createUser() { ... } +``` + +#### Key Rules & Constraints: +1. **Duplicate Verbs Forbidden**: You cannot map overlapping HTTP verbs on the same route path (e.g. registering two `GET` handlers on `/users`, or a `GET` handler and a handler without any `@RequestType` constraints). Doing so will cause the scanner to fail with an `IllegalStateException: Duplicate endpoint registration` during class map initialization. +2. **Interceptors Apply Universally**: Interceptors (`@RequestBefore` and `@RequestAfter`) registered on the endpoint path (e.g. `/users`) are mapped using the `::all` wildcard suffix. They are executed on all requests matching that route path, completely ignoring the HTTP verb used. + --- ## 3. Lifecycle Interceptors: `@RequestBefore` and `@RequestAfter` diff --git a/src/main/java/picoded/servlet/AxiosApiBuilder.java b/src/main/java/picoded/servlet/AxiosApiBuilder.java index d83a3bb..8d799e5 100755 --- a/src/main/java/picoded/servlet/AxiosApiBuilder.java +++ b/src/main/java/picoded/servlet/AxiosApiBuilder.java @@ -126,8 +126,16 @@ public Map scanApiEndpoints() { } BasePageClassMap classMap = BasePageClassMap.setupAndCache(corePage); + Map rawEndpoints = new HashMap<>(); + classMap.getApiEndpointsFromClass("", corePage.getClass(), rawEndpoints); + + // Clean the keys from verb-based suffixes when building the scanned endpoints map + // so that the generated client-side JS and tests operate with clean, correct path keys. scannedApiEndpoints = new HashMap<>(); - classMap.getApiEndpointsFromClass("", corePage.getClass(), scannedApiEndpoints); + for (String key : rawEndpoints.keySet()) { + String cleanKey = BasePageClassMap.cleanEndpointPath(key); + scannedApiEndpoints.put(cleanKey, rawEndpoints.get(key)); + } return scannedApiEndpoints; } diff --git a/src/main/java/picoded/servlet/internal/BasePageClassMap.java b/src/main/java/picoded/servlet/internal/BasePageClassMap.java index b6ac59a..dd8ab38 100755 --- a/src/main/java/picoded/servlet/internal/BasePageClassMap.java +++ b/src/main/java/picoded/servlet/internal/BasePageClassMap.java @@ -151,6 +151,40 @@ protected void registerClassMethods(Class classObj) { } } + /** + * Centralized registration helper that inspects annotations and generates + * the unique verb suffixes for routing keys. + */ + private void registerEndpoint(EndpointMap map, String[] paths, T obj) { + if (obj instanceof Method) { + Method method = (Method) obj; + RequestType[] requestTypes = method.getAnnotationsByType(RequestType.class); + if (requestTypes != null && requestTypes.length > 0) { + for (String path : paths) { + for (RequestType requestType : requestTypes) { + for (String verb : requestType.value()) { + String uniqueKey = path + "::" + verb.toLowerCase(); + if (map.containsKey(uniqueKey)) { + throw new IllegalStateException("Duplicate endpoint registration for " + uniqueKey.toUpperCase()); + } + map.registerEndpointPath(uniqueKey, obj); + } + } + } + return; + } + } + + // Fallback for non-restricted methods or fields (e.g. Field reroutes, interceptors) + for (String path : paths) { + String uniqueKey = path + "::all"; + if (map.containsKey(uniqueKey)) { + throw new IllegalStateException("Duplicate endpoint registration for " + uniqueKey.toUpperCase()); + } + map.registerEndpointPath(uniqueKey, obj); + } + } + /** * Scans a single method for valid enpoint registrations * @@ -163,10 +197,10 @@ protected void registerMethod(Class classObj, Method methodObj) { // Minor note : Because annotation is not extendable, we cant fully refactor // the duplicative loop into a generic function, that is reusable. for (RequestBefore pathObj : methodObj.getAnnotationsByType(RequestBefore.class)) { - beforeMap.registerEndpointPath(pathObj.value(), methodObj); + registerEndpoint(beforeMap, pathObj.value(), methodObj); } for (RequestAfter pathObj : methodObj.getAnnotationsByType(RequestAfter.class)) { - afterMap.registerEndpointPath(pathObj.value(), methodObj); + registerEndpoint(afterMap, pathObj.value(), methodObj); } // @@ -182,10 +216,10 @@ protected void registerMethod(Class classObj, Method methodObj) { // if (retMethod != null && BasePage.class.isAssignableFrom(retMethod)) { for (RequestPath pathObj : methodObj.getAnnotationsByType(RequestPath.class)) { - rerouteMethodMap.registerEndpointPath(pathObj.value(), methodObj); + registerEndpoint(rerouteMethodMap, pathObj.value(), methodObj); } for (ApiPath pathObj : methodObj.getAnnotationsByType(ApiPath.class)) { - rerouteMethodMap.registerEndpointPath(pathObj.value(), methodObj); + registerEndpoint(rerouteMethodMap, pathObj.value(), methodObj); } return; } @@ -194,10 +228,10 @@ protected void registerMethod(Class classObj, Method methodObj) { // Assumes its a standard request method from here onwards // for (RequestPath pathObj : methodObj.getAnnotationsByType(RequestPath.class)) { - pathMap.registerEndpointPath(pathObj.value(), methodObj); + registerEndpoint(pathMap, pathObj.value(), methodObj); } for (ApiPath pathObj : methodObj.getAnnotationsByType(ApiPath.class)) { - apiMap.registerEndpointPath(pathObj.value(), methodObj); + registerEndpoint(apiMap, pathObj.value(), methodObj); } } @@ -213,7 +247,7 @@ protected void registerClassFields(Class classObj) { for (Field field : fieldList) { // Get and process each type of annotation we currently support for fields for (RequestPath path : field.getAnnotationsByType(RequestPath.class)) { - rerouteFieldMap.registerEndpointPath(path.value(), field); + registerEndpoint(rerouteFieldMap, path.value(), field); } } } @@ -253,8 +287,11 @@ public EndpointMap reroutePaths() { */ public void getApiEndpointsFromClass(String currentPath, Class clazz, Map endpoints) { + // Clean verb-based suffixes from the incoming currentPath so that string segmentation math works. + currentPath = cleanEndpointPath(currentPath); + // Remove the asterisk so that the path can be appended to the inner class's methods api endpoints - if (currentPath.endsWith("/*")) { + if (currentPath != null && currentPath.endsWith("/*")) { currentPath = currentPath.replaceAll("\\*$", ""); } @@ -263,20 +300,25 @@ public void getApiEndpointsFromClass(String currentPath, Class clazz, EndpointMap apis = basePageClassMap.apiEndpoints(); for (String key : apis.keySet()) { + // Strip the unique verb suffix (e.g. ::post, ::all) to compile a clean, unsuffixed key + // for the frontend client-side API map. + String cleanKey = cleanEndpointPath(key); // NOTE: The first implementation will be taken! Future implementation will be ignored // This is so that only one endpoint exist at a time // @TODO: Need to do a logic in cases where methods are extended // Example: Account login method, and there is another class that extends this method - endpoints.putIfAbsent(currentPath + key, apis.get(key)); - endpoints.putIfAbsent(key, apis.get(key)); + endpoints.putIfAbsent(currentPath + cleanKey, apis.get(key)); + endpoints.putIfAbsent(cleanKey, apis.get(key)); } // Recursively call through the reroute fields to retrieve the other api endpoints EndpointMap reroutePaths = basePageClassMap.reroutePaths(); for (String key : reroutePaths.keySet()) { - getApiEndpointsFromClass(currentPath + key, getRerouteClass(reroutePaths.get(key)), + // Strip unique verb suffix from the field reroute paths (e.g. reroute/*::all) + String cleanKey = cleanEndpointPath(key); + getApiEndpointsFromClass(currentPath + cleanKey, getRerouteClass(reroutePaths.get(key)), endpoints); - getApiEndpointsFromClass(key, getRerouteClass(reroutePaths.get(key)), endpoints); + getApiEndpointsFromClass(cleanKey, getRerouteClass(reroutePaths.get(key)), endpoints); } } @@ -352,7 +394,7 @@ protected String[] reroutePath(String[] requestPath, String routePath) { int partsCount = splitRoutePath.length; // Remove the trailing '/*' in the parts count - if (routePath.endsWith("/*")) { + if (cleanEndpointPath(routePath).endsWith("/*")) { --partsCount; } @@ -533,7 +575,7 @@ protected boolean request_fieldReroute(BasePage page, String[] requestPath) { String endpoint = pathList.get(0); // Validate reroute endpoint ends with /* - if (!endpoint.endsWith("/*")) { + if (!cleanEndpointPath(endpoint).endsWith("/*")) { throw new RuntimeException("Reroute paths are suppose to end with '/*'"); } @@ -596,7 +638,7 @@ protected boolean request_methodReroute(BasePage page, String[] requestPath) { String endpoint = pathList.get(0); // Validate reroute endpoint ends with /* - if (!endpoint.endsWith("/*")) { + if (!cleanEndpointPath(endpoint).endsWith("/*")) { throw new RuntimeException("Reroute paths are suppose to end with '/*'"); } @@ -916,4 +958,15 @@ protected void executeMethod(BasePage page, Method toExecute, String[] annotatio // @TODO - consider output failure for unknown non null value ?? } + /** + * Strips the unique verb suffix (e.g. "::get", "::all") from route path keys + * so that string operations like endsWith("/*") work correctly on the path string. + */ + public static String cleanEndpointPath(String path) { + if (path != null && path.contains("::")) { + return path.substring(0, path.indexOf("::")); + } + return path; + } + } diff --git a/src/main/java/picoded/servlet/internal/EndpointMap.java b/src/main/java/picoded/servlet/internal/EndpointMap.java index 151daa6..0180b8b 100755 --- a/src/main/java/picoded/servlet/internal/EndpointMap.java +++ b/src/main/java/picoded/servlet/internal/EndpointMap.java @@ -50,7 +50,7 @@ public EndpointMap() { /** * Local memoizer copy of `ServletStringUtil.splitUriString`. - * USed this only internally within enpoint map class + * Used this only internally within endpoint map class. */ public String[] splitUriString(String path) { // Get and return the cached result @@ -59,30 +59,25 @@ public String[] splitUriString(String path) { return res; } + // If path contains a verb suffix (e.g. "path/name::get"), strip the suffix + // to compute and cache segment-matching arrays on the raw path. + String cleanPath = path; + if (path != null && path.contains("::")) { + cleanPath = path.substring(0, path.indexOf("::")); + } + // Process the path, and cache the result - res = ServletStringUtil.splitUriString(path); + res = ServletStringUtil.splitUriString(cleanPath); _splitUriString.put(path, res); // And return it return res; } - /////////////////////////////////////////////////////// - // - // Path handling / lookup - // - /////////////////////////////////////////////////////// - - /** - * Register a method endpoint - * - * @param path of the method endpoint - * @param obj to register - */ public void registerEndpointPath(String path, V obj) { // And register the endpoint & cache its split path splitUriString(path); - this.put(path, obj); + super.put(path, obj); } /** @@ -356,20 +351,27 @@ && validateRequestType(endpoint, reqType)) { * */ private boolean validateRequestType(String endpointName, HttpRequestType requestType) { - - // No specific method is give, treat as allowed + // No specific method is given, treat as allowed if (requestType == null) { return true; } - // If the endpoint is not a method, treats as valid + // If key contains the unique verb suffix delimiter, perform a fast O(1) string check. + // This avoids doing slow reflection checks during request dispatch. + if (endpointName != null && endpointName.contains("::")) { + String suffix = endpointName.substring(endpointName.indexOf("::") + 2); + if (suffix.equalsIgnoreCase("all") || suffix.equalsIgnoreCase(requestType.toString())) { + return true; + } + return false; + } + + // Fallback for non-suffixed keys (legacy or third-party usage) Object endpoint = this.get(endpointName); if (!(endpoint instanceof Method)) { return true; } - // Check through the RequestType annotation of the endpoint and validates if the requestType - // is contained in it. If the endpoint does not have any RequestType set, treat as allowed Method endpointImplementation = (Method) endpoint; RequestType[] endpointRequestTypes = endpointImplementation .getAnnotationsByType(RequestType.class); @@ -384,8 +386,6 @@ private boolean validateRequestType(String endpointName, HttpRequestType request } } - // At this point, the method of the request does not match any of the endpoint's RequestType - // treat as false return false; } diff --git a/src/test/java/picoded/servlet/BasePage_requestType_test.java b/src/test/java/picoded/servlet/BasePage_requestType_test.java index 18189e2..9515469 100755 --- a/src/test/java/picoded/servlet/BasePage_requestType_test.java +++ b/src/test/java/picoded/servlet/BasePage_requestType_test.java @@ -89,6 +89,69 @@ public Map apiNone() { return ret; } + // Duplicate path different verbs tests + @RequestBefore("multi/verb") + public void before_multi_verb() { + getPrintWriter().print("[BEFORE] "); + } + + @RequestPath("multi/verb") + @RequestType("GET") + public void get_multi_verb() { + getPrintWriter().print("GET"); + } + + @RequestPath("multi/verb") + @RequestType("POST") + public void post_multi_verb() { + getPrintWriter().print("POST"); + } + + @RequestPath("multi/verb") + @RequestType("DELETE") + public void delete_multi_verb() { + getPrintWriter().print("DELETE"); + } + + @RequestAfter("multi/verb") + public void after_multi_verb() { + getPrintWriter().print(" [AFTER]"); + } + + @RequestBefore("api/multi/verb") + public void before_api_multi_verb() { + getApiResponseMap().put("before", "ok"); + } + + @ApiPath("api/multi/verb") + @RequestType("GET") + public Map get_api_multi_verb() { + Map ret = new ApiResponseMap(); + ret.put("method", "GET"); + return ret; + } + + @ApiPath("api/multi/verb") + @RequestType("POST") + public Map post_api_multi_verb() { + Map ret = new ApiResponseMap(); + ret.put("method", "POST"); + return ret; + } + + @ApiPath("api/multi/verb") + @RequestType("DELETE") + public Map delete_api_multi_verb() { + Map ret = new ApiResponseMap(); + ret.put("method", "DELETE"); + return ret; + } + + @RequestAfter("api/multi/verb") + public void after_api_multi_verb() { + getApiResponseMap().put("after", "ok"); + } + } @Test @@ -189,4 +252,42 @@ public void test_api_multiple_invalid() throws Exception { + "Request URI : /api/multiple", response.toString().trim()); } + @Test + public void test_requestPath_multipleVerbs() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/multi/verb"; + + ResponseHttp response = RequestHttp.get(testUrl, null, null, null); + assertEquals("[BEFORE] GET [AFTER]", response.toString().trim()); + + response = RequestHttp.post(testUrl, null, null, null); + assertEquals("[BEFORE] POST [AFTER]", response.toString().trim()); + + response = RequestHttp.delete(testUrl, null, null, null); + assertEquals("[BEFORE] DELETE [AFTER]", response.toString().trim()); + + response = RequestHttp.put(testUrl, null, null, null); + assertEquals("

404 Error

\n" + "The requested resource is not avaliable Q.Q\n" + "\n" + + "Request URI : /multi/verb", response.toString().trim()); + } + + @Test + public void test_apiPath_multipleVerbs() throws Exception { + assertNotNull(testServlet = new EmbeddedServlet(testPort, new HelloWorld())); + String testUrl = "http://127.0.0.1:" + testPort + "/api/multi/verb"; + + ResponseHttp response = RequestHttp.get(testUrl, null, null, null); + assertEquals("{\"method\":\"GET\",\"before\":\"ok\",\"after\":\"ok\"}", response.toString().replaceAll("\\s+", "")); + + response = RequestHttp.post(testUrl, null, null, null); + assertEquals("{\"method\":\"POST\",\"before\":\"ok\",\"after\":\"ok\"}", response.toString().replaceAll("\\s+", "")); + + response = RequestHttp.delete(testUrl, null, null, null); + assertEquals("{\"method\":\"DELETE\",\"before\":\"ok\",\"after\":\"ok\"}", response.toString().replaceAll("\\s+", "")); + + response = RequestHttp.put(testUrl, null, null, null); + assertEquals("

404 Error

\n" + "The requested resource is not avaliable Q.Q\n" + "\n" + + "Request URI : /api/multi/verb", response.toString().trim()); + } + } \ No newline at end of file From cae464159ef6f311d78e92420844e26489a95121 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 14:59:52 +0800 Subject: [PATCH 4/9] ci: add gh-build-test.yml GitHub Actions workflow configuration --- .github/workflows/gh-build-test.yml | 30 +++++++++++++++++++++++++++++ .gitignore | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gh-build-test.yml diff --git a/.github/workflows/gh-build-test.yml b/.github/workflows/gh-build-test.yml new file mode 100644 index 0000000..0207750 --- /dev/null +++ b/.github/workflows/gh-build-test.yml @@ -0,0 +1,30 @@ +name: JavaCommons-servlet CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive # Initializes and fetches all nested submodules recursively + + - name: Set up JDK 8 + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'temurin' + cache: gradle # Automatically caches Gradle wrapper and dependencies + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run All Tests + run: ./gradlew test diff --git a/.gitignore b/.gitignore index 6c7245f..f032b32 100755 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ test/tmp # Include back .gitignore # !.gitignore -!.travis.yml \ No newline at end of file +!.travis.yml +!.github/ \ No newline at end of file From 3062ab4c7c565375f41181866ba040b5e0530432 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 15:07:17 +0800 Subject: [PATCH 5/9] update gitignore --- .gitignore | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index f032b32..647df93 100755 --- a/.gitignore +++ b/.gitignore @@ -28,11 +28,4 @@ build/* # # Testing tmp folder # -test/tmp - -# -# Include back .gitignore -# -!.gitignore -!.travis.yml -!.github/ \ No newline at end of file +test/tmp \ No newline at end of file From 7f680efc5c16a9c6094fac9920f09be6e1287ca7 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 15:07:57 +0800 Subject: [PATCH 6/9] update dstack to 4.1.12 --- JavaCommons-dstack | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JavaCommons-dstack b/JavaCommons-dstack index 14641b3..7110e99 160000 --- a/JavaCommons-dstack +++ b/JavaCommons-dstack @@ -1 +1 @@ -Subproject commit 14641b3b4dd17ed17d4d0f2f867f8cf06c988d2e +Subproject commit 7110e99cadda2d0c8952f2afbc754f8f57dc17c2 From 0f56af163fbf6e48e3b2c505f6409e495b2643f5 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 15:08:58 +0800 Subject: [PATCH 7/9] version bump to 4.1.13 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index c2e540f..6016a0a 100755 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ repositories { //---------------------------------------------------------------- // The Project version -version = '4.1.10' +version = '4.1.13' // Setup java compilation version sourceCompatibility = 1.8 From 59771c0b3254bc6f11d4ae59ed08a5c5921019f6 Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 15:21:36 +0800 Subject: [PATCH 8/9] ci: split compiling from execution and enable verbose console test logging --- .github/workflows/gh-build-test.yml | 5 ++++- build.gradle | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gh-build-test.yml b/.github/workflows/gh-build-test.yml index 0207750..918febb 100644 --- a/.github/workflows/gh-build-test.yml +++ b/.github/workflows/gh-build-test.yml @@ -26,5 +26,8 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Run All Tests + - name: Compile and Assemble Classes + run: ./gradlew testClasses + + - name: Execute Unit Tests run: ./gradlew test diff --git a/build.gradle b/build.gradle index 6016a0a..8757c16 100755 --- a/build.gradle +++ b/build.gradle @@ -237,6 +237,13 @@ test { if (project.hasProperty('forkEvery')) { forkEvery = project.forkEvery as int } + + // Logging settings to output test results clearly to console/CI + testLogging { + events "passed", "skipped", "failed" + showStandardStreams = true + exceptionFormat = "full" + } } // Custom incremental test running From f1c1795706e8d63bcb8a792342bee9e22d1d490d Mon Sep 17 00:00:00 2001 From: Tai Shi Ling Date: Thu, 6 Aug 2026 15:48:04 +0800 Subject: [PATCH 9/9] ci: silence standard stream logs to keep test output clean --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8757c16..65ee461 100755 --- a/build.gradle +++ b/build.gradle @@ -241,7 +241,7 @@ test { // Logging settings to output test results clearly to console/CI testLogging { events "passed", "skipped", "failed" - showStandardStreams = true + showStandardStreams = false exceptionFormat = "full" } }