Skip to content

Commit 88a8e37

Browse files
committed
feat(jsonrpc): implement 1:1 service generation and O(1) global dispatcher
- Transition from artificial namespace routing to strict JSON-RPC 2.0 method mapping. - Update APT `JoobyProcessor` to generate isolated `*Rpc_` classes for each `@JsonRpc` annotated controller. - Preserve standard MVC factory constructor patterns (`Function<Context, T>`) in generated RPC services to maintain full Dependency Injection support. - Introduce `JsonRpcService.getMethods()` to explicitly declare supported protocol methods. - Implement $O(1)$ `HashMap` method resolution in the Tier 1 `JsonRpcDispatcher`. - Remove implicit `Extension` self-registration from generated classes in favor of explicit manual registration via the dispatcher constructor. - Fix `@Generated` annotation metadata output for Java (`.class`) and Kotlin (`::class`). - ref #3868
1 parent df73717 commit 88a8e37

23 files changed

Lines changed: 1754 additions & 91 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.annotation;
7+
8+
import java.lang.annotation.ElementType;
9+
import java.lang.annotation.Retention;
10+
import java.lang.annotation.RetentionPolicy;
11+
import java.lang.annotation.Target;
12+
13+
/**
14+
* Marks a class and its methods as a JSON-RPC 2.0 endpoint. *
15+
*
16+
* <p>To expose a method via JSON-RPC, the method <b>must</b> be annotated with {@code @JsonRpc}. If
17+
* a class contains methods annotated with {@code @JsonRpc}, the class itself can optionally be
18+
* annotated to define a common namespace. *
19+
*
20+
* <h3>Routing Rules:</h3>
21+
*
22+
* <ul>
23+
* <li><b>Class Level (Namespace):</b> When applied to a class, the {@link #value()} defines the
24+
* namespace for all JSON-RPC methods within that class. If the annotation is present but the
25+
* value is empty, the simple class name (e.g., {@code MovieService}) is used as the
26+
* namespace. If the class is not annotated at all, the methods are registered without a
27+
* namespace. *
28+
* <li><b>Method Level (Method Name):</b> When applied to a method, the method is exposed over
29+
* JSON-RPC. The {@link #value()} defines the exact RPC method name. If the value is empty,
30+
* the actual Java/Kotlin method name is used.
31+
* </ul>
32+
*
33+
* *
34+
*
35+
* <p>The final JSON-RPC method string expected by the dispatcher is formatted as {@code
36+
* "namespace.methodName"} (or just {@code "methodName"} if no namespace exists).
37+
*/
38+
@Target({ElementType.TYPE, ElementType.METHOD})
39+
@Retention(RetentionPolicy.RUNTIME)
40+
public @interface JsonRpc {
41+
42+
/**
43+
* The explicit namespace (when used on a class) or the explicit method name (when used on a
44+
* method). * @return The overridden name, or an empty string to use the defaults (Simple Class
45+
* Name or Method Name).
46+
*/
47+
String value() default "";
48+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.jsonrpc;
7+
8+
/**
9+
* A pre-resolved decoder used at runtime to deserialize JSON-RPC parameter nodes into complex Java
10+
* objects.
11+
*
12+
* <p>This interface is heavily utilized by the Jooby Annotation Processor (APT). When compiling
13+
* {@code @JsonRpc}-annotated controllers, the APT generates highly optimized routing code that
14+
* resolves the appropriate {@code JsonRpcDecoder} for each method argument. By pre-resolving these
15+
* decoders, Jooby efficiently parses incoming JSON arguments without incurring reflection overhead
16+
* on every request.
17+
*
18+
* <p>Note: Primitive types and standard wrappers (like {@code int}, {@code String}, {@code
19+
* boolean}) are typically handled directly by the {@link JsonRpcReader} rather than requiring a
20+
* dedicated decoder.
21+
*
22+
* @param <T> The target Java type this decoder produces.
23+
*/
24+
public interface JsonRpcDecoder<T> {
25+
26+
/**
27+
* Decodes a generic JSON node object into the target Java object.
28+
*
29+
* @param name The name of the parameter being decoded (useful for error reporting or wrapping).
30+
* @param node The generic JSON node (e.g., a Map, List, or library-specific AST node)
31+
* representing this specific argument.
32+
* @return The fully deserialized Java object.
33+
*/
34+
T decode(String name, Object node);
35+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.jsonrpc;
7+
8+
import java.util.ArrayList;
9+
import java.util.HashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
import io.jooby.*;
14+
15+
/**
16+
* Global Tier 1 Dispatcher for JSON-RPC 2.0 requests. *
17+
*
18+
* <p>This dispatcher acts as the central entry point for all JSON-RPC traffic. It manages the
19+
* lifecycle of a request by:
20+
*
21+
* <ul>
22+
* <li>Parsing the incoming body into a {@link JsonRpcRequest} (supporting both single and batch
23+
* shapes).
24+
* <li>Iterating through registered {@link JsonRpcService} instances to find a matching namespace.
25+
* <li>Handling <strong>Notifications</strong> (requests without an {@code id}) by suppressing
26+
* responses.
27+
* <li>Unifying batch results into a single JSON array or a single object response as per the
28+
* spec.
29+
* </ul>
30+
*
31+
* *
32+
*
33+
* <p>Usage:
34+
*
35+
* <pre>{@code
36+
* install(new JsonRpcDispatcher());
37+
* services().put(JsonRpcService.class, new MyServiceRpc(new MyService()));
38+
* }</pre>
39+
*
40+
* @author Edgar Espina
41+
* @since 4.0.17
42+
*/
43+
public class JsonRpcDispatcher implements Extension {
44+
45+
private final Map<String, JsonRpcService> services = new HashMap<>();
46+
47+
public JsonRpcDispatcher(JsonRpcService... services) {
48+
for (JsonRpcService service : services) {
49+
for (String method : service.getMethods()) {
50+
this.services.put(method, service);
51+
}
52+
}
53+
}
54+
55+
/**
56+
* Installs the JSON-RPC handler at the default {@code /rpc} endpoint.
57+
*
58+
* @param app The Jooby application instance.
59+
* @throws Exception If registration fails.
60+
*/
61+
@Override
62+
public void install(Jooby app) throws Exception {
63+
app.post("/rpc", this::handle);
64+
}
65+
66+
/**
67+
* Main handler for the JSON-RPC protocol. *
68+
*
69+
* <p>This method implements the flattened iteration logic. Because {@link JsonRpcRequest}
70+
* implements {@code Iterable}, this handler treats single requests and batch requests identically
71+
* during processing.
72+
*
73+
* @param ctx The current Jooby context.
74+
* @return A single {@link JsonRpcResponse}, a {@code List} of responses for batches, or an empty
75+
* string for notifications.
76+
*/
77+
public Object handle(Context ctx) {
78+
JsonRpcRequest input;
79+
try {
80+
input = ctx.body(JsonRpcRequest.class);
81+
} catch (Exception e) {
82+
// Spec: -32700 Parse error if the JSON is physically malformed.
83+
return JsonRpcResponse.error(null, -32700, "Parse error");
84+
}
85+
86+
List<JsonRpcResponse> responses = new ArrayList<>();
87+
88+
// Look up all generated *Rpc classes registered in the service registry
89+
90+
for (var request : input) {
91+
String fullMethod = request.getMethod();
92+
93+
// Spec: -32600 Invalid Request if the method member is missing or null
94+
if (fullMethod == null) {
95+
if (request.getId() != null) {
96+
responses.add(JsonRpcResponse.error(request.getId(), -32600, "Invalid Request"));
97+
}
98+
continue;
99+
}
100+
101+
try {
102+
JsonRpcService targetService = services.get(fullMethod);
103+
if (targetService != null) {
104+
Object result = targetService.execute(ctx, request);
105+
// Spec: If the "id" is missing, it is a notification and no response is returned.
106+
if (request.getId() != null) {
107+
responses.add(JsonRpcResponse.success(request.getId(), result));
108+
}
109+
} else {
110+
// Spec: -32601 Method not found
111+
if (request.getId() != null) {
112+
responses.add(
113+
JsonRpcResponse.error(request.getId(), -32601, "Method not found: " + fullMethod));
114+
}
115+
}
116+
} catch (JsonRpcException e) {
117+
// Domain-specific or protocol-level exceptions (e.g., -32602 Invalid Params)
118+
if (request.getId() != null) {
119+
responses.add(JsonRpcResponse.error(request.getId(), e.getCode(), e.getMessage()));
120+
}
121+
} catch (Exception e) {
122+
// Spec: -32603 Internal error for unhandled application exceptions
123+
if (request.getId() != null) {
124+
responses.add(
125+
JsonRpcResponse.error(request.getId(), -32603, "Internal error: " + e.getMessage()));
126+
}
127+
}
128+
}
129+
130+
// Handle the case where all requests in a batch were notifications
131+
if (responses.isEmpty()) {
132+
ctx.setResponseCode(StatusCode.NO_CONTENT);
133+
return "";
134+
}
135+
136+
// Spec: Return an array only if the original request was a batch
137+
return input.isBatch() ? responses : responses.getFirst();
138+
}
139+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.jsonrpc;
7+
8+
/**
9+
* Exception thrown when a JSON-RPC error occurs during routing, parsing, or execution.
10+
*
11+
* <p>Contains standard JSON-RPC 2.0 error codes. When caught by the dispatcher, this exception
12+
* should be transformed into a {@link JsonRpcResponse} containing the error details.
13+
*/
14+
public class JsonRpcException extends RuntimeException {
15+
16+
/**
17+
* Invalid JSON was received by the server. An error occurred on the server while parsing the JSON
18+
* text.
19+
*/
20+
public static final int PARSE_ERROR = -32700;
21+
22+
/** The JSON sent is not a valid Request object. */
23+
public static final int INVALID_REQUEST = -32600;
24+
25+
/** The method does not exist / is not available. */
26+
public static final int METHOD_NOT_FOUND = -32601;
27+
28+
/** Invalid method parameter(s). */
29+
public static final int INVALID_PARAMS = -32602;
30+
31+
/** Internal JSON-RPC error. */
32+
public static final int INTERNAL_ERROR = -32603;
33+
34+
private final int code;
35+
private final Object data;
36+
37+
/**
38+
* Constructs a new JSON-RPC exception.
39+
*
40+
* @param code The integer error code (preferably one of the standard constants).
41+
* @param message A short description of the error.
42+
*/
43+
public JsonRpcException(int code, String message) {
44+
super(message);
45+
this.code = code;
46+
this.data = null;
47+
}
48+
49+
/**
50+
* Constructs a new JSON-RPC exception with additional error data.
51+
*
52+
* @param code The integer error code.
53+
* @param message A short description of the error.
54+
* @param data Additional data about the error (e.g., stack trace or validation messages).
55+
*/
56+
public JsonRpcException(int code, String message, Object data) {
57+
super(message);
58+
this.code = code;
59+
this.data = data;
60+
}
61+
62+
/**
63+
* Returns the JSON-RPC error code.
64+
*
65+
* @return The JSON-RPC error code.
66+
*/
67+
public int getCode() {
68+
return code;
69+
}
70+
71+
/**
72+
* Returns additional data regarding the error.
73+
*
74+
* @return Additional data regarding the error, or null if none was provided.
75+
*/
76+
public Object getData() {
77+
return data;
78+
}
79+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.jsonrpc;
7+
8+
import java.lang.reflect.Type;
9+
10+
/**
11+
* The core JSON parsing SPI (Service Provider Interface) for JSON-RPC.
12+
*
13+
* <p>This factory is implemented by Jooby's JSON modules (such as Jackson or Avaje) and serves as
14+
* the bridge between the parsed JSON-RPC request and the application's Java objects.
15+
*
16+
* <p><b>Startup Optimization:</b>
17+
*
18+
* <p>The Jooby Annotation Processor (APT) generates highly optimized routing code that relies on
19+
* this interface. At application startup, the generated routes use this parser to eagerly resolve
20+
* and cache type-specific {@link JsonRpcDecoder}s. This ensures that during actual HTTP requests,
21+
* arguments are deserialized with near-zero reflection overhead.
22+
*/
23+
public interface JsonRpcParser {
24+
25+
/**
26+
* Resolves and caches a type-specific deserializer during route initialization.
27+
*
28+
* <p>This method is invoked by the APT-generated code when the application starts. By eagerly
29+
* requesting a decoder for complex generic types, the framework avoids expensive reflection
30+
* lookups during runtime request processing.
31+
*
32+
* @param type The target Java type (e.g., {@code List<Movie>}, {@code User}) to decode.
33+
* @param <T> The expected Java type.
34+
* @return A highly optimized decoder capable of parsing generic JSON nodes into the target type.
35+
*/
36+
<T> JsonRpcDecoder<T> decoder(Type type);
37+
38+
/**
39+
* Creates a stateful reader for extracting JSON-RPC arguments from the request parameters.
40+
*
41+
* <p>Because JSON-RPC 2.0 parameters can be either positional (a JSON Array) or named (a JSON
42+
* Object), this reader manages the extraction context to hand off the correct JSON segment to the
43+
* pre-resolved {@link JsonRpcDecoder}s or extract primitives.
44+
*
45+
* @param params The parsed parameter object (typically a List or Map depending on the underlying
46+
* JSON library) from the {@link JsonRpcRequest}.
47+
* @return A reader for argument extraction.
48+
*/
49+
JsonRpcReader reader(Object params);
50+
}

0 commit comments

Comments
 (0)