Skip to content

callibrity/ripcurl

Repository files navigation

RipCurl

Maven Central Version GitHub License

Maintainability Rating Reliability Rating Security Rating Quality Gate Status Coverage

A JSON-RPC 2.0 compliant dispatching framework for Spring Boot 4. RipCurl handles method routing, parameter resolution, and error handling — you provide the HTTP layer.

Quick Start

Add the starter:

<dependency>
    <groupId>com.callibrity.ripcurl</groupId>
    <artifactId>ripcurl-spring-boot-starter</artifactId>
    <version>2.11.0</version>
</dependency>

That's it — Methodical (including the Jackson 3 parameter binder) comes in transitively.

Defining Methods

Register the handler bean however your app normally does (@Component, @Bean, etc.) and annotate each handler method with @JsonRpcMethod:

@Component
public class MathService {

    @JsonRpcMethod("subtract")
    public int subtract(int minuend, int subtrahend) {
        return minuend - subtrahend;
    }

    @JsonRpcMethod("ping")
    public String ping() {
        return "pong";
    }
}

RipCurl scans every bean in the context for @JsonRpcMethod methods and registers them with the dispatcher. Parameters are resolved by name from the JSON-RPC params object (or by position from a JSON array).

Customizing Handlers

To attach behavior — metrics, auth, logging, custom parameter resolution, etc. — to every @JsonRpcMethod handler, register a JsonRpcMethodHandlerCustomizer bean. The customizer receives a config describing the handler (JSON-RPC method name, target Method, target bean) and appends either a MethodInterceptor or a ParameterResolver scoped to that specific handler.

Interceptor example — adding a per-handler metrics timer:

@Bean
JsonRpcMethodHandlerCustomizer timingCustomizer(MeterRegistry registry) {
    return config -> {
        var timer = registry.timer("ripcurl.handler", "method", config.name());
        config.interceptor(invocation -> {
            var sample = Timer.start();
            try {
                return invocation.proceed();
            } finally {
                sample.stop(timer);
            }
        });
    };
}

Resolver example — injecting a request-scoped TenantContext:

@Bean
JsonRpcMethodHandlerCustomizer tenantContextCustomizer(TenantContextLookup lookup) {
    return config -> config.resolver(info -> {
        if (!info.accepts(TenantContext.class)) {
            return Optional.empty();
        }
        // Binding is per-parameter; anything derivable from ParameterInfo can be
        // captured once here and reused on every dispatch.
        return Optional.of(params -> lookup.current());
    });
}

A ParameterResolver<A> is a factory: its bind(ParameterInfo) returns an Optional<Binding<A>>. A non-empty binding wins for that parameter slot, and its resolve(root) runs on every dispatch with the cached state captured at bind time.

Customizer-contributed resolvers slot between two built-in resolvers that RipCurl always applies: JsonRpcParamsResolver runs first (handling @JsonRpcParams parameters), then customizer resolvers in bean order (honoring @Order), then Jackson3ParameterResolver as the name/index catch-all. Methodical's @Argument tail runs last.

Customizers are the only extension path. The pre-2.8.0 bean-level autowiring of List<ParameterResolver<? super JsonNode>> and List<MethodInterceptor<? super JsonNode>> is gone — any such beans on the classpath no longer contribute to RipCurl's pipeline.

Message Types

RipCurl models the JSON-RPC 2.0 message types as a sealed hierarchy:

JsonRpcMessage (sealed)
├── JsonRpcRequest (sealed)
│   ├── JsonRpcCall (method + params + id) — expects a response
│   └── JsonRpcNotification (method + params) — fire-and-forget
└── JsonRpcResponse (sealed)
    ├── JsonRpcResult (result + id) — success
    └── JsonRpcError (error + id) — failure

Deserialize incoming JSON directly into the appropriate type via Jackson — every sealed interface has a @JsonCreator that structurally dispatches to the right concrete subtype:

JsonRpcMessage message = objectMapper.treeToValue(body, JsonRpcMessage.class);
return switch (message) {
    case JsonRpcCall call -> dispatcher.dispatch(call);
    case JsonRpcNotification notification -> handleNotification(notification);
    case JsonRpcResult result -> handleClientResult(result);
    case JsonRpcError error -> handleClientError(error);
};

Spring controllers can also take the sealed type directly as @RequestBody, letting Spring's message converter do the deserialization:

@PostMapping
public ResponseEntity<?> handle(@RequestBody JsonRpcMessage message) {
    return switch (message) {
        case JsonRpcCall call -> ResponseEntity.ok(dispatcher.dispatch(call));
        case JsonRpcNotification n -> { dispatcher.dispatch(n); yield ResponseEntity.accepted().build(); }
        case JsonRpcResult r -> handleClientResult(r);
        case JsonRpcError e -> handleClientError(e);
    };
}

Writing a Controller

RipCurl doesn't include a controller — you write your own. This gives you full control over HTTP concerns (headers, auth, content types):

@RestController
@RequestMapping("/rpc")
public class JsonRpcController {

    private final JsonRpcDispatcher dispatcher;

    @PostMapping(consumes = "application/json", produces = "application/json")
    public ResponseEntity<?> handle(@RequestBody JsonRpcRequest request) {
        JsonRpcResponse response = dispatcher.dispatch(request);
        if (response == null) {
            return ResponseEntity.noContent().build(); // notification
        }
        return ResponseEntity.ok(response);
    }
}

The dispatcher accepts both JsonRpcCall and JsonRpcNotification (via JsonRpcRequest). It never throws — it returns either a JsonRpcResult (success), JsonRpcError (failure), or null (notification).

For notifications, the dispatcher invokes the method but always returns null — per the spec, the server must not reply. Pattern match on the request type if you need different HTTP handling:

return switch (request) {
    case JsonRpcCall call -> ResponseEntity.ok(dispatcher.dispatch(call));
    case JsonRpcNotification notification -> {
        dispatcher.dispatch(notification);
        yield ResponseEntity.accepted().build();
    }
};

Batch Requests

JSON-RPC 2.0 supports batch requests (an array of requests). Use dispatchBatch():

List<JsonRpcRequest> requests = /* parse array */;
List<JsonRpcResponse> responses = dispatcher.dispatchBatch(requests);
if (responses.isEmpty()) {
    return ResponseEntity.noContent().build(); // all notifications
}
return ResponseEntity.ok(responses);

dispatchBatch() dispatches calls concurrently on virtual threads via invokeAll. Notifications are fire-and-forget — they don't block the batch response.

Response Types

JsonRpcResponse is a sealed interface:

  • JsonRpcResult — success. Has result (JsonNode) and id.
  • JsonRpcError — failure. Has error (code + message + optional data) and id.

Result and error are mutually exclusive per the JSON-RPC 2.0 spec — enforced by the type system.

Creating Correlated Responses

JsonRpcCall has factory methods that echo the request id:

call.result(resultNode);          // JsonRpcResult with matching id
call.error(-32601, "Not found");  // JsonRpcError with matching id

Error Handling

The dispatcher catches all exceptions and returns appropriate JsonRpcError responses:

Error Code Constant When
-32700 JsonRpcProtocol.PARSE_ERROR Malformed JSON (controller concern)
-32600 JsonRpcProtocol.INVALID_REQUEST Bad jsonrpc version, missing method, invalid id/params type
-32601 JsonRpcProtocol.METHOD_NOT_FOUND Unknown method, rpc.* prefix
-32602 JsonRpcProtocol.INVALID_PARAMS Parameter deserialization failure
-32603 JsonRpcProtocol.INTERNAL_ERROR Unhandled runtime exception

Handlers can throw JsonRpcException with a specific code:

throw new JsonRpcException(JsonRpcProtocol.INVALID_PARAMS, "Name is required");

Method Invocation

RipCurl uses Methodical for reflection-based parameter resolution. Under the hood each handler's invoker is built with a fixed resolver chain — JsonRpcParamsResolver → customizer-added resolvers → Jackson3ParameterResolver → Methodical's @Argument tail — assembled once at handler-construction time. See Customizing Handlers for how to contribute your own resolvers.

Jakarta Validation (optional)

If you want constraint validation (@NotNull, @Min, @Valid, etc.) applied to your @JsonRpcMethod parameters and return values, add Spring Boot's validation starter plus RipCurl's jakarta-validation module:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>com.callibrity.ripcurl</groupId>
    <artifactId>ripcurl-jakarta-validation</artifactId>
    <version>2.11.0</version>
</dependency>

methodical-jakarta-validation comes in transitively. Methodical's autoconfiguration wires the validator into a MethodInterceptor; RipCurl's JakartaValidationCustomizer (auto-registered when both the jakarta module and a Validator bean are on the classpath) attaches that interceptor to every @JsonRpcMethod handler. When a constraint is violated, ConstraintViolationExceptionTranslator turns the resulting ConstraintViolationException into a -32602 Invalid params JSON-RPC error, emitting per-violation detail as a [{field, message}, ...] array in the response's data field:

{
  "code": -32602,
  "message": "Invalid params",
  "data": [
    {"field": "name", "message": "must not be blank"},
    {"field": "age",  "message": "must be greater than or equal to 0"}
  ]
}

invalidValue is deliberately omitted — reflecting the rejected input back at the client risks leaking sensitive parameters (passwords, tokens, PII). Clients that need that detail should capture it at the call site.

Requirements

  • Java 25+
  • Spring Boot 4.x
  • Methodical 0.8.0+ (transitive)