diff --git a/README.md b/README.md index 9a3a7c5..e71fd2e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Production-oriented JSON-RPC 2.0 server library for Java, with optional Spring W - Spring WebMVC transport adapter and Spring Boot auto-configuration - Multiple registration styles (annotation/manual/typed) - Explicit extension points (parser/validator/invoker/exception mapping/interceptors/metrics) +- Response-side protocol utilities for bidirectional transports (classifier/parser/validator) - Focused dependency surface (no direct Guava, Commons Lang3, or Jakarta Validation dependency) ## Specification @@ -20,7 +21,7 @@ Production-oriented JSON-RPC 2.0 server library for Java, with optional Spring W - Java: 17+ - Spring Boot baseline: 4.0.3 -- Jackson baseline: 3.0.x +- Jackson baseline: 3.1.x - Build: Gradle with Version Catalog - CI matrix: Java 17, 21, 25 @@ -174,6 +175,24 @@ Response: {"jsonrpc":"2.0","id":1,"result":"hello developer"} ``` +## Spring Configuration Example + +Use `jsonrpc.validation.request.*` and `jsonrpc.validation.response.*` for fine-grained validation control: + +```yaml +jsonrpc: + validation: + request: + params-type-violation-code-policy: INVALID_REQUEST + response: + require-response-id-member: true + allow-fractional-response-id: false + allow-request-fields-in-response: false +``` + +For the full list of validation keys and defaults, see +[`docs/configuration-reference.md`](docs/configuration-reference.md). + ## Quick Start (Pure Java) ```java @@ -256,11 +275,13 @@ Detailed docs are under [`docs/`](docs/): ## Sample - Spring Boot sample app: [`samples/spring-boot-demo`](samples/spring-boot-demo) +- Pure Java sample app: [`samples/pure-java-demo`](samples/pure-java-demo) Run: ```bash ./gradlew -p samples/spring-boot-demo bootRun +./gradlew -p samples/pure-java-demo run ``` ## Project Docs diff --git a/docs/architecture.md b/docs/architecture.md index 58e55eb..1aba8fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,13 @@ flowchart LR Design goal: each concern is replaceable without rewriting the dispatcher. +Response-side transport integrations can use these additional protocol components: + +- `JsonRpcEnvelopeClassifier` +- `JsonRpcResponseParser` +- `JsonRpcResponseValidator` +- `JsonRpcResponseValidationOptions` + ## 4. Registration Lifecycle in Auto-Configuration Method registration order in Spring Boot: @@ -59,7 +66,7 @@ Protocol-level normalization: - parse error -> `-32700` - invalid request -> `-32600` - method not found -> `-32601` -- invalid params -> `-32602` +- invalid params -> default `-32602` (`params` type-violation code is configurable in `DefaultJsonRpcRequestValidator`) - unhandled exceptions -> `-32603` Transport-level status is handled separately by `JsonRpcHttpStatusStrategy`. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 2efff75..d86d258 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -4,23 +4,35 @@ All properties are under `jsonrpc.*` and are bound to `JsonRpcProperties`. ## 1. Property Table -| Key | Type | Default | Description | -|-----------------------------------------------|-----------------------|------------|-----------------------------------------------------------------| -| `jsonrpc.enabled` | `boolean` | `true` | Enable/disable WebMVC endpoint auto-configuration | -| `jsonrpc.path` | `String` | `/jsonrpc` | JSON-RPC HTTP endpoint path | -| `jsonrpc.max-batch-size` | `int` | `100` | Maximum number of entries allowed in one batch request | -| `jsonrpc.max-request-bytes` | `int` | `1048576` | Raw HTTP request payload size limit in bytes | -| `jsonrpc.scan-annotated-methods` | `boolean` | `true` | Scan Spring beans for `@JsonRpcMethod` | -| `jsonrpc.include-error-data` | `boolean` | `false` | Include `JsonRpcException.data` in error responses | -| `jsonrpc.method-registration-conflict-policy` | `REJECT` or `REPLACE` | `REJECT` | Duplicate method name registration policy | -| `jsonrpc.method-allowlist` | `List` | `[]` | Allowlist for method access filtering | -| `jsonrpc.method-denylist` | `List` | `[]` | Denylist for method access filtering (higher priority) | -| `jsonrpc.metrics-enabled` | `boolean` | `true` | Enable Micrometer interceptor/observer when registry is present | -| `jsonrpc.metrics-latency-histogram-enabled` | `boolean` | `false` | Publish latency histogram buckets | -| `jsonrpc.metrics-latency-percentiles` | `List` | `[]` | Optional latency percentiles (`0.0 < p < 1.0`) | -| `jsonrpc.metrics-max-method-tag-values` | `int` | `100` | Max distinct method tag values before fallback to `other` | -| `jsonrpc.notification-executor-enabled` | `boolean` | `false` | Enable executor-backed notification dispatch | -| `jsonrpc.notification-executor-bean-name` | `String` | `""` | Preferred executor bean name for notifications | +| Key | Type | Default | Description | +|--------------------------------------------------------|---------------------------------------|------------------|----------------------------------------------------------------------| +| `jsonrpc.enabled` | `boolean` | `true` | Enable/disable WebMVC endpoint auto-configuration | +| `jsonrpc.path` | `String` | `/jsonrpc` | JSON-RPC HTTP endpoint path | +| `jsonrpc.max-batch-size` | `int` | `100` | Maximum number of entries allowed in one batch request | +| `jsonrpc.max-request-bytes` | `int` | `1048576` | Raw HTTP request payload size limit in bytes | +| `jsonrpc.scan-annotated-methods` | `boolean` | `true` | Scan Spring beans for `@JsonRpcMethod` | +| `jsonrpc.include-error-data` | `boolean` | `false` | Include `JsonRpcException.data` in error responses | +| `jsonrpc.validation.request.params-type-violation-code-policy` | `INVALID_PARAMS` or `INVALID_REQUEST` | `INVALID_PARAMS` | Error code used when `params` exists but is neither object nor array | +| `jsonrpc.validation.response.require-json-rpc-version-20` | `boolean` | `true` | Require incoming response `jsonrpc` to equal `"2.0"` | +| `jsonrpc.validation.response.require-response-id-member` | `boolean` | `true` | Require incoming responses to include an `id` member | +| `jsonrpc.validation.response.allow-null-response-id` | `boolean` | `true` | Allow `id: null` in incoming responses | +| `jsonrpc.validation.response.allow-string-response-id` | `boolean` | `true` | Allow string IDs in incoming responses | +| `jsonrpc.validation.response.allow-numeric-response-id` | `boolean` | `true` | Allow numeric IDs in incoming responses | +| `jsonrpc.validation.response.allow-fractional-response-id` | `boolean` | `true` | Allow fractional numeric IDs in incoming responses | +| `jsonrpc.validation.response.require-exclusive-result-or-error` | `boolean` | `true` | Require exactly one of `result` or `error` | +| `jsonrpc.validation.response.require-error-object-when-present` | `boolean` | `true` | Require `error` to be an object when present | +| `jsonrpc.validation.response.require-integer-error-code` | `boolean` | `true` | Require `error.code` to be an integer | +| `jsonrpc.validation.response.require-string-error-message` | `boolean` | `true` | Require `error.message` to be a string | +| `jsonrpc.validation.response.allow-request-fields-in-response` | `boolean` | `true` | Allow request-only fields (`method`/`params`) on responses | +| `jsonrpc.method-registration-conflict-policy` | `REJECT` or `REPLACE` | `REJECT` | Duplicate method name registration policy | +| `jsonrpc.method-allowlist` | `List` | `[]` | Allowlist for method access filtering | +| `jsonrpc.method-denylist` | `List` | `[]` | Denylist for method access filtering (higher priority) | +| `jsonrpc.metrics-enabled` | `boolean` | `true` | Enable Micrometer interceptor/observer when registry is present | +| `jsonrpc.metrics-latency-histogram-enabled` | `boolean` | `false` | Publish latency histogram buckets | +| `jsonrpc.metrics-latency-percentiles` | `List` | `[]` | Optional latency percentiles (`0.0 < p < 1.0`) | +| `jsonrpc.metrics-max-method-tag-values` | `int` | `100` | Max distinct method tag values before fallback to `other` | +| `jsonrpc.notification-executor-enabled` | `boolean` | `false` | Enable executor-backed notification dispatch | +| `jsonrpc.notification-executor-bean-name` | `String` | `""` | Preferred executor bean name for notifications | ## 2. Validation Rules (Fail Fast) @@ -36,6 +48,10 @@ Startup fails with `IllegalArgumentException` when any of these conditions occur - `jsonrpc.metrics-latency-percentiles` is null - any percentile is null, `<= 0.0`, or `>= 1.0` - `jsonrpc.notification-executor-bean-name` is null +- `jsonrpc.validation` is null +- `jsonrpc.validation.request` is null +- `jsonrpc.validation.request.params-type-violation-code-policy` is null +- `jsonrpc.validation.response` is null - allowlist/denylist list itself is null - allowlist/denylist contains null or blank values @@ -86,6 +102,7 @@ Example environment variable mapping: - `jsonrpc.max-request-bytes` -> `JSONRPC_MAX_REQUEST_BYTES` - `jsonrpc.method-registration-conflict-policy` -> `JSONRPC_METHOD_REGISTRATION_CONFLICT_POLICY` +- `jsonrpc.validation.request.params-type-violation-code-policy` -> `JSONRPC_VALIDATION_REQUEST_PARAMS_TYPE_VIOLATION_CODE_POLICY` ## 5. Example Configurations @@ -99,6 +116,21 @@ jsonrpc: method-registration-conflict-policy: REJECT scan-annotated-methods: true include-error-data: false + validation: + request: + params-type-violation-code-policy: INVALID_PARAMS + response: + require-json-rpc-version-20: true + require-response-id-member: true + allow-null-response-id: true + allow-string-response-id: true + allow-numeric-response-id: true + allow-fractional-response-id: true + require-exclusive-result-or-error: true + require-error-object-when-present: true + require-integer-error-code: true + require-string-error-message: true + allow-request-fields-in-response: true method-allowlist: [] method-denylist: [] ``` @@ -139,7 +171,7 @@ The project ships Spring Boot configuration metadata via: This enables: - property key completion -- enum value suggestions (`REJECT`, `REPLACE`) +- enum value suggestions (`REJECT`, `REPLACE`, `INVALID_PARAMS`, `INVALID_REQUEST`) - metadata hints in IntelliJ and Spring-aware tooling ## 7. Related References diff --git a/docs/extension-points.md b/docs/extension-points.md index d3376b6..ee0fc78 100644 --- a/docs/extension-points.md +++ b/docs/extension-points.md @@ -14,6 +14,26 @@ You can override any of these with custom Spring beans: - `JsonRpcResponseComposer` - `JsonRpcNotificationExecutor` +Response-side interfaces are available in `jsonrpc-core` for transport integrations: + +- `JsonRpcEnvelopeClassifier` +- `JsonRpcResponseParser` +- `JsonRpcResponseValidator` +- `JsonRpcResponseValidationOptions` + +Spring Boot auto-configuration currently wires request-dispatch components by default. For response-side +processing, create and use these components explicitly in your transport adapter. + +Request validator customization example: + +- Spring Boot property `jsonrpc.validation.request.params-type-violation-code-policy=INVALID_PARAMS` + keeps default `-32602` for `params` type violations. +- Spring Boot property `jsonrpc.validation.request.params-type-violation-code-policy=INVALID_REQUEST` + maps the same violation to `-32600`. +- For non-Spring or fully custom logic, you can still construct + `DefaultJsonRpcRequestValidator(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS)` or + `DefaultJsonRpcRequestValidator(JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST)` directly. + ## 2. Interceptor Chain `JsonRpcInterceptor` hooks: diff --git a/docs/index.md b/docs/index.md index 5f3a032..4ffbc97 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ ## 2. Core Reference - Protocol behavior and JSON-RPC compliance: [`protocol-and-compliance.md`](protocol-and-compliance.md) +- Incoming response classification/parsing/validation guidance: [`protocol-and-compliance.md`](protocol-and-compliance.md), [`pure-java-guide.md`](pure-java-guide.md) - Registration styles and parameter binding rules: [`registration-and-binding.md`](registration-and-binding.md) - Configuration keys, defaults, constraints, and precedence: [`configuration-reference.md`](configuration-reference.md) - Extension interfaces and override points: [`extension-points.md`](extension-points.md) diff --git a/docs/protocol-and-compliance.md b/docs/protocol-and-compliance.md index c04164e..fe052f4 100644 --- a/docs/protocol-and-compliance.md +++ b/docs/protocol-and-compliance.md @@ -9,31 +9,31 @@ This document maps current behavior to JSON-RPC 2.0 requirements. ## Compliance Matrix -| Rule | Spec Requirement | Implementation Behavior | -|---|---|---| -| `jsonrpc` field | MUST be string `"2.0"` | Validated; otherwise `-32600 Invalid Request` | -| `method` field | MUST be string | Validated non-null/non-blank; otherwise `-32600` | -| `params` field | MAY be array/object | If present and not array/object -> `-32602 Invalid params` | -| `id` field type | String/Number/Null (if present) | Invalid `id` type -> `-32600`; error response id normalized to `null` | -| Notification | Request without `id` | Invoked with no response payload | -| Success response | MUST contain `result` (no `error`) | Enforced by `JsonRpcResponse` invariant | -| Error response | MUST contain `error` (no `result`) | Enforced by `JsonRpcResponse` invariant | -| Parse error | Invalid JSON text | `-32700 Parse error`, `id: null` | -| Method not found | Unknown method | `-32601 Method not found` | -| Internal error | Unhandled runtime/checked exceptions | `-32603 Internal error` | -| Batch request | Array of requests | Supported | -| Empty batch | Invalid request | Single error object with `-32600` | -| Notification-only batch | No response | HTTP adapter returns no body | +| Rule | Spec Requirement | Implementation Behavior | +|-------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `jsonrpc` field | MUST be string `"2.0"` | Validated; otherwise `-32600 Invalid Request` | +| `method` field | MUST be string | Validated non-null/non-blank; otherwise `-32600` | +| `params` field | MAY be array/object | If present and not array/object -> default `-32602 Invalid params` (configurable to `-32600 Invalid Request` via validator policy / Spring property) | +| `id` field type | String/Number/Null (if present) | Invalid `id` type -> `-32600`; error response id normalized to `null` | +| Notification | Request without `id` | Invoked with no response payload | +| Success response | MUST contain `result` (no `error`) | Enforced by `JsonRpcResponse` invariant | +| Error response | MUST contain `error` (no `result`) | Enforced by `JsonRpcResponse` invariant | +| Parse error | Invalid JSON text | `-32700 Parse error`, `id: null` | +| Method not found | Unknown method | `-32601 Method not found` | +| Internal error | Unhandled runtime/checked exceptions | `-32603 Internal error` | +| Batch request | Array of requests | Supported | +| Empty batch | Invalid request | Single error object with `-32600` | +| Notification-only batch | No response | HTTP adapter returns no body | ## Error Codes -| Code | Meaning | -|---|---| -| `-32700` | Parse error | -| `-32600` | Invalid Request | +| Code | Meaning | +|----------|------------------| +| `-32700` | Parse error | +| `-32600` | Invalid Request | | `-32601` | Method not found | -| `-32602` | Invalid params | -| `-32603` | Internal error | +| `-32602` | Invalid params | +| `-32603` | Internal error | Implementation constants are in `JsonRpcErrorCode` and messages in `JsonRpcConstants`. @@ -46,6 +46,52 @@ Implementation constants are in `JsonRpcErrorCode` and messages in `JsonRpcConst 5. Params are bound/invoked. 6. Result or error is composed into JSON-RPC response. +## Incoming Response Validation + +`jsonrpc-core` also provides response-side protocol utilities: + +- `JsonRpcEnvelopeClassifier` +- `JsonRpcResponseParser` +- `JsonRpcResponseValidator` +- `JsonRpcResponseValidationOptions` + +These APIs are transport-agnostic and useful for bidirectional channels (for example WebSocket) where +request/response envelopes may arrive on the same connection. + +### Default Validation Rules (RFC MUST) + +By default, `JsonRpcResponseValidationOptions.defaults()` enforces: + +- top-level response is an object +- `jsonrpc` equals `"2.0"` +- `id` member exists and is `string | number | null` +- exactly one of `result` or `error` is present +- when `error` is present: + - `error` is an object + - `error.code` is an integer + - `error.message` is a string + +RFC SHOULD or stricter interoperability policies are configurable via per-rule options. +This library does not expose predefined strict/lenient modes; policy is controlled per rule. + +### Validation Options + +`JsonRpcResponseValidationOptions` exposes per-rule switches: + +- `requireJsonRpcVersion20` (default: `true`) +- `requireResponseIdMember` (default: `true`) +- `allowNullResponseId` (default: `true`) +- `allowStringResponseId` (default: `true`) +- `allowNumericResponseId` (default: `true`) +- `allowFractionalResponseId` (default: `true`) +- `requireExclusiveResultOrError` (default: `true`) +- `requireErrorObjectWhenPresent` (default: `true`) +- `requireIntegerErrorCode` (default: `true`) +- `requireStringErrorMessage` (default: `true`) +- `allowRequestFieldsInResponse` (default: `true`) + +`allowRequestFieldsInResponse=true` is a compatibility default and is not an RFC MUST rule. + ## `id` Handling Details - Notification is defined by **absence** of `id` field (`idPresent == false`). @@ -78,3 +124,4 @@ This is transport policy, not protocol rule, and can be overridden via `JsonRpcH - Oversized request body (`jsonrpc.max-request-bytes`) maps to protocol error `-32600` with message `Request payload too large`. - Parse errors always use `id: null`. - Generic exceptions are intentionally normalized to `-32603` to avoid leaking internals. +- `params` type violations (non-array/object) default to `-32602`; in Spring Boot this can be changed with `jsonrpc.validation.request.params-type-violation-code-policy=INVALID_REQUEST`. diff --git a/docs/pure-java-guide.md b/docs/pure-java-guide.md index 0ce68af..cebc9d7 100644 --- a/docs/pure-java-guide.md +++ b/docs/pure-java-guide.md @@ -188,13 +188,14 @@ import com.limehee.jsonrpc.core.DefaultJsonRpcResponseComposer; import com.limehee.jsonrpc.core.DirectJsonRpcNotificationExecutor; import com.limehee.jsonrpc.core.InMemoryJsonRpcMethodRegistry; import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; import com.limehee.jsonrpc.core.JsonRpcMethodRegistrationConflictPolicy; import java.util.List; JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( new InMemoryJsonRpcMethodRegistry(JsonRpcMethodRegistrationConflictPolicy.REJECT), new DefaultJsonRpcRequestParser(), - new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcRequestValidator(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS), new DefaultJsonRpcMethodInvoker(), new DefaultJsonRpcExceptionResolver(false), new DefaultJsonRpcResponseComposer(), @@ -206,6 +207,12 @@ JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( This keeps protocol behavior while letting you customize policy and implementation. +`JsonRpcParamsTypeViolationCodePolicy` controls which code is used when request `params` is present but not +an object/array: + +- `INVALID_PARAMS` (default behavior): `-32602` +- `INVALID_REQUEST`: `-32600` + ## 7. Custom Transport Pattern When using Netty, Undertow, Vert.x, CLI stdin/stdout, message queues, or any custom transport, use this pattern: @@ -216,13 +223,55 @@ When using Netty, Undertow, Vert.x, CLI stdin/stdout, message queues, or any cus 4. If `hasResponse()` is false, do not emit body. 5. If response exists, serialize single response or response list to JSON. -## 8. Concurrency Notes +## 8. Incoming Response Pattern (Bidirectional Transport) + +Use response-side protocol utilities when the same channel receives both requests and responses. + +```java +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import com.limehee.jsonrpc.core.DefaultJsonRpcEnvelopeClassifier; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseParser; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseValidator; +import com.limehee.jsonrpc.core.JsonRpcEnvelopeClassifier; +import com.limehee.jsonrpc.core.JsonRpcEnvelopeType; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponseEnvelope; +import com.limehee.jsonrpc.core.JsonRpcResponseParser; +import com.limehee.jsonrpc.core.JsonRpcResponseValidator; + +ObjectMapper mapper = JsonMapper.builder().build(); +JsonRpcEnvelopeClassifier classifier = new DefaultJsonRpcEnvelopeClassifier(); +JsonRpcResponseParser responseParser = new DefaultJsonRpcResponseParser(); +JsonRpcResponseValidator responseValidator = new DefaultJsonRpcResponseValidator(); + +JsonNode payload = mapper.readTree(rawMessage); +JsonRpcEnvelopeType envelopeType = classifier.classify(payload); + +if (envelopeType == JsonRpcEnvelopeType.REQUEST) { + // handle request path through JsonRpcDispatcher +} else if (envelopeType == JsonRpcEnvelopeType.RESPONSE) { + JsonRpcIncomingResponseEnvelope envelope = responseParser.parse(payload); + for (JsonRpcIncomingResponse response : envelope.responses()) { + responseValidator.validate(response); + // route by response.id() to pending-call registry + } +} else { + // invalid envelope handling +} +``` + +For policy tuning, customize `JsonRpcResponseValidationOptions` and pass it into +`DefaultJsonRpcResponseValidator`. + +## 9. Concurrency Notes - `JsonRpcDispatcher` invocation path is stateless per request except method registry lookups. - Notification behavior depends on the configured `JsonRpcNotificationExecutor`. - For asynchronous notification isolation in plain Java, provide an executor-backed implementation. -## 9. Deep References +## 10. Deep References - Protocol matrix: [`protocol-and-compliance.md`](protocol-and-compliance.md) - Registration/binding semantics: [`registration-and-binding.md`](registration-and-binding.md) diff --git a/docs/spring-boot-guide.md b/docs/spring-boot-guide.md index 44e3334..c976728 100644 --- a/docs/spring-boot-guide.md +++ b/docs/spring-boot-guide.md @@ -241,6 +241,58 @@ Supported practical types include: - `Map`, `List`, collection types - `JsonNode` +### 5.4 Params type violation code policy + +Default auto-configuration uses `DefaultJsonRpcRequestValidator` with +`JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS`, so request `params` with non-object/non-array shape +returns `-32602`. + +Use configuration to change this behavior: + +```yaml +jsonrpc: + validation: + request: + params-type-violation-code-policy: INVALID_REQUEST +``` + +If you need fully custom validation logic, you can still override the validator bean. + +### 5.5 Incoming response validation options + +`JsonRpcResponseValidationOptions` is also configurable through properties: + +```yaml +jsonrpc: + validation: + response: + require-json-rpc-version-20: true + require-response-id-member: true + allow-null-response-id: true + allow-string-response-id: true + allow-numeric-response-id: true + allow-fractional-response-id: true + require-exclusive-result-or-error: true + require-error-object-when-present: true + require-integer-error-code: true + require-string-error-message: true + allow-request-fields-in-response: true +``` + +You can override only the options you need. Example: + +```yaml +jsonrpc: + validation: + response: + allow-fractional-response-id: false + allow-request-fields-in-response: false +``` + +Auto-configuration exposes both `JsonRpcResponseValidationOptions` and +`JsonRpcResponseValidator` beans. They are intended for client/bidirectional integrations and are +not part of the default HTTP request dispatch path. + ## 6. Control Scanning Scope Disable annotation scanning when you only want explicit registrations: @@ -327,6 +379,16 @@ Common override points: - `JsonRpcNotificationExecutor` - `JsonRpcHttpStatusStrategy` +Response-side protocol components are also available in `jsonrpc-core`: + +- `JsonRpcEnvelopeClassifier` +- `JsonRpcResponseParser` +- `JsonRpcResponseValidator` +- `JsonRpcResponseValidationOptions` + +Auto-configuration provides these as beans for reuse in custom bidirectional transports. They are +not used by the default HTTP request dispatcher. + Example HTTP status strategy: ```java diff --git a/docs/testing-and-quality.md b/docs/testing-and-quality.md index 679f593..e0ddd9e 100644 --- a/docs/testing-and-quality.md +++ b/docs/testing-and-quality.md @@ -27,6 +27,10 @@ Commands: ### Core (`jsonrpc-core`) - Parser/validator rules (`jsonrpc`, `method`, `params`, `id`) +- Response-side utilities: + - envelope classifier (`REQUEST` / `RESPONSE` / `INVALID`) + - incoming response parser (single/batch and field-presence semantics) + - configurable response validator (`JsonRpcResponseValidationOptions`) - Dispatcher branches: - success - invalid request diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 24a7956..654333a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,6 +15,7 @@ Checks: - `jsonrpc.path` starts with `/` and has no whitespace - `jsonrpc.max-batch-size > 0` - `jsonrpc.max-request-bytes > 0` +- `jsonrpc.validation.request.params-type-violation-code-policy` is set to a valid enum value - allowlist/denylist entries are not blank - `jsonrpc.notification-executor-bean-name` is not null @@ -32,7 +33,7 @@ Checks: - `@JsonRpcMethod` name or registration name - `jsonrpc.method-allowlist` / `jsonrpc.method-denylist` -## Params Return `-32602 Invalid params` +## Params Return `-32602` or `-32600` Possible causes: @@ -40,12 +41,14 @@ Possible causes: - Missing named argument in object mode - Positional array size mismatch - Jackson conversion failure for target type +- Custom `JsonRpcRequestValidator` policy maps params-shape errors to `-32600` Checks: - For multi-arg object params, confirm `@JsonRpcParam` names - For reflection names, ensure build uses `-parameters` - Validate incoming JSON types +- Confirm `jsonrpc.validation.request.params-type-violation-code-policy` matches expected code semantics ## Notification Did Not Return Body diff --git a/jsonrpc-core/src/e2eTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaE2ETest.java b/jsonrpc-core/src/e2eTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaE2ETest.java index fdb4889..9318654 100644 --- a/jsonrpc-core/src/e2eTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaE2ETest.java +++ b/jsonrpc-core/src/e2eTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaE2ETest.java @@ -42,8 +42,8 @@ void e2eReturnsSuccessJsonForManualAndTypedMethods() throws Exception { {"jsonrpc":"2.0","method":"typed.upper","params":{"value":"core"},"id":2} """)); - assertEquals("pong", ping.get("result").asText()); - assertEquals("CORE", upper.get("result").get("value").asText()); + assertEquals("pong", ping.get("result").asString()); + assertEquals("CORE", upper.get("result").get("value").asString()); } @Test @@ -66,7 +66,7 @@ void e2eHandlesBatchAndParseError() throws Exception { assertTrue(batch.isArray()); assertEquals(2, batch.size()); - assertEquals("alpha", batch.get(0).get("result").get(0).asText()); + assertEquals("alpha", batch.get(0).get("result").get(0).asString()); assertEquals(-32601, batch.get(1).get("error").get("code").asInt()); assertEquals(-32700, parseError.get("error").get("code").asInt()); } diff --git a/jsonrpc-core/src/integrationTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaIntegrationTest.java b/jsonrpc-core/src/integrationTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaIntegrationTest.java index c7f1ece..643bd9c 100644 --- a/jsonrpc-core/src/integrationTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaIntegrationTest.java +++ b/jsonrpc-core/src/integrationTest/java/com/limehee/jsonrpc/core/JsonRpcPureJavaIntegrationTest.java @@ -42,9 +42,9 @@ void supportsManualAndTypedRegistrationsWithoutSpring() throws Exception { {"jsonrpc":"2.0","method":"typed.user","params":{"id":7},"id":2} """); - assertEquals("pong", ping.get("result").asText()); + assertEquals("pong", ping.get("result").asString()); assertEquals(7, user.get("result").get("id").asInt()); - assertEquals("user-7", user.get("result").get("name").asText()); + assertEquals("user-7", user.get("result").get("name").asString()); } @Test @@ -55,8 +55,8 @@ void supportsClassParamRecordReturnAndCollectionReturn() throws Exception { assertTrue(tags.get("result").isArray()); assertEquals(2, tags.get("result").size()); - assertEquals("alpha", tags.get("result").get(0).asText()); - assertEquals("beta", tags.get("result").get(1).asText()); + assertEquals("alpha", tags.get("result").get(0).asString()); + assertEquals("beta", tags.get("result").get(1).asString()); } @Test @@ -71,7 +71,7 @@ void supportsBatchInPureJavaEnvironment() throws Exception { assertTrue(batchResult.isArray()); assertEquals(2, batchResult.size()); - assertEquals("pong", batchResult.get(0).get("result").asText()); + assertEquals("pong", batchResult.get(0).get("result").asString()); assertEquals(-32601, batchResult.get(1).get("error").get("code").asInt()); } diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcEnvelopeClassifier.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcEnvelopeClassifier.java new file mode 100644 index 0000000..f2d8d21 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcEnvelopeClassifier.java @@ -0,0 +1,67 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** + * Default envelope classifier using top-level field presence heuristics. + */ +public class DefaultJsonRpcEnvelopeClassifier implements JsonRpcEnvelopeClassifier { + + /** + * {@inheritDoc} + */ + @Override + public JsonRpcEnvelopeType classify(@Nullable JsonNode payload) { + if (payload == null) { + return JsonRpcEnvelopeType.INVALID; + } + if (payload.isObject()) { + return classifyObject(payload); + } + if (!payload.isArray() || payload.isEmpty()) { + return JsonRpcEnvelopeType.INVALID; + } + + JsonRpcEnvelopeType batchType = null; + for (JsonNode element : payload) { + JsonRpcEnvelopeType current = classifyObject(element); + if (current == JsonRpcEnvelopeType.INVALID) { + return JsonRpcEnvelopeType.INVALID; + } + if (batchType == null) { + batchType = current; + continue; + } + if (batchType != current) { + return JsonRpcEnvelopeType.INVALID; + } + } + return batchType == null ? JsonRpcEnvelopeType.INVALID : batchType; + } + + /** + * Classifies a single object node by request/response marker fields. + *

+ * When request and response hints coexist, response classification takes precedence to keep + * routing aligned with response-side validation policies. + * + * @param node object node candidate + * @return envelope classification + */ + private JsonRpcEnvelopeType classifyObject(JsonNode node) { + if (node == null || !node.isObject()) { + return JsonRpcEnvelopeType.INVALID; + } + + boolean hasResponseHint = node.has("result") || node.has("error"); + boolean hasRequestHint = node.has("method") || node.has("params"); + if (hasResponseHint) { + return JsonRpcEnvelopeType.RESPONSE; + } + if (hasRequestHint) { + return JsonRpcEnvelopeType.REQUEST; + } + return JsonRpcEnvelopeType.INVALID; + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParser.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParser.java index dd2caeb..39de595 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParser.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParser.java @@ -21,10 +21,10 @@ public JsonRpcRequest parse(JsonNode node) { } JsonNode jsonrpcNode = node.get("jsonrpc"); - String jsonrpc = jsonrpcNode != null && jsonrpcNode.isTextual() ? jsonrpcNode.asText() : null; + String jsonrpc = jsonrpcNode != null && jsonrpcNode.isString() ? jsonrpcNode.stringValue() : null; JsonNode methodNode = node.get("method"); - String method = methodNode != null && methodNode.isTextual() ? methodNode.asText() : null; + String method = methodNode != null && methodNode.isString() ? methodNode.stringValue() : null; JsonNode id = node.get("id"); boolean idPresent = node.has("id"); diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidator.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidator.java index 611b02a..23b46b3 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidator.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidator.java @@ -2,11 +2,36 @@ import tools.jackson.databind.JsonNode; +import java.util.Objects; + /** * Default JSON-RPC request validator enforcing core protocol constraints. */ public class DefaultJsonRpcRequestValidator implements JsonRpcRequestValidator { + private final JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy; + + /** + * Creates a validator using {@link JsonRpcParamsTypeViolationCodePolicy#INVALID_PARAMS} for + * invalid {@code params} type violations. + */ + public DefaultJsonRpcRequestValidator() { + this(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + } + + /** + * Creates a validator with an explicit error-code policy for invalid {@code params} type. + * + * @param paramsTypeViolationCodePolicy policy selecting the error code for invalid + * {@code params} type violations + */ + public DefaultJsonRpcRequestValidator(JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy) { + this.paramsTypeViolationCodePolicy = Objects.requireNonNull( + paramsTypeViolationCodePolicy, + "paramsTypeViolationCodePolicy" + ); + } + /** * Validates protocol version, method presence, id shape, and params type. * @@ -26,12 +51,15 @@ public void validate(JsonRpcRequest request) { } JsonNode id = request.id(); - if (request.idPresent() && id != null && !id.isNull() && !id.isTextual() && !id.isNumber()) { + if (request.idPresent() && id != null && !id.isNull() && !id.isString() && !id.isNumber()) { throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); } JsonNode params = request.params(); if (params != null && !params.isArray() && !params.isObject()) { + if (paramsTypeViolationCodePolicy == JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + } throw new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, JsonRpcConstants.MESSAGE_INVALID_PARAMS); } } diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParser.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParser.java new file mode 100644 index 0000000..11c734e --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParser.java @@ -0,0 +1,79 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Default parser for incoming JSON-RPC response payloads. + */ +public class DefaultJsonRpcResponseParser implements JsonRpcResponseParser { + + /** + * {@inheritDoc} + */ + @Override + public JsonRpcIncomingResponseEnvelope parse(@Nullable JsonNode payload) { + if (payload == null) { + throw invalidResponseEnvelope(); + } + if (payload.isObject()) { + return JsonRpcIncomingResponseEnvelope.single(parseObject(payload)); + } + if (!payload.isArray() || payload.isEmpty()) { + throw invalidResponseEnvelope(); + } + + List responses = new ArrayList<>(payload.size()); + for (JsonNode element : payload) { + responses.add(parseObject(element)); + } + return JsonRpcIncomingResponseEnvelope.batch(responses); + } + + /** + * Parses a response object and extracts known top-level members. + * + * @param node response object candidate + * @return parsed incoming response + */ + private JsonRpcIncomingResponse parseObject(JsonNode node) { + if (node == null || !node.isObject()) { + throw invalidResponseEnvelope(); + } + + JsonNode jsonrpcNode = node.get("jsonrpc"); + String jsonrpc = jsonrpcNode != null && jsonrpcNode.isString() ? jsonrpcNode.stringValue() : null; + + boolean idPresent = node.has("id"); + JsonNode id = node.get("id"); + + boolean resultPresent = node.has("result"); + JsonNode result = node.get("result"); + + boolean errorPresent = node.has("error"); + JsonNode error = node.get("error"); + + return new JsonRpcIncomingResponse( + node, + jsonrpc, + id, + idPresent, + result, + resultPresent, + error, + errorPresent + ); + } + + /** + * Creates a standardized invalid-response exception. + * + * @return invalid-response exception + */ + private JsonRpcException invalidResponseEnvelope() { + return new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "Invalid response envelope"); + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidator.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidator.java new file mode 100644 index 0000000..f950419 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidator.java @@ -0,0 +1,142 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; + +import java.util.Objects; + +/** + * Default validator for parsed incoming JSON-RPC responses. + */ +public class DefaultJsonRpcResponseValidator implements JsonRpcResponseValidator { + + private final JsonRpcResponseValidationOptions options; + + /** + * Creates a validator with default RFC-aligned options. + */ + public DefaultJsonRpcResponseValidator() { + this(JsonRpcResponseValidationOptions.defaults()); + } + + /** + * Creates a validator with explicit validation options. + * + * @param options response validation options + */ + public DefaultJsonRpcResponseValidator(JsonRpcResponseValidationOptions options) { + this.options = Objects.requireNonNull(options, "options"); + } + + /** + * {@inheritDoc} + */ + @Override + public void validate(JsonRpcIncomingResponse response) { + if (response == null) { + throw invalid("response must not be null"); + } + + if (options.requireJsonRpcVersion20() && !JsonRpcConstants.VERSION.equals(response.jsonrpc())) { + throw invalid("response jsonrpc must be \"2.0\""); + } + + if (options.requireResponseIdMember() && !response.idPresent()) { + throw invalid("response id must be present"); + } + + if (response.idPresent()) { + validateId(response.id()); + } + + if (options.requireExclusiveResultOrError()) { + boolean hasResult = response.resultPresent(); + boolean hasError = response.errorPresent(); + if (hasResult == hasError) { + throw invalid("response must contain exactly one of result or error"); + } + } + + if (!options.allowRequestFieldsInResponse()) { + JsonNode source = response.source(); + if (source == null || source.has("method") || source.has("params")) { + throw invalid("response must not contain request fields method/params"); + } + } + + if (response.errorPresent()) { + validateError(response.error()); + } + } + + /** + * Validates response {@code id} against configured ID rules. + * + * @param id response id node + */ + private void validateId(JsonNode id) { + if (id == null || id.isNull()) { + if (!options.allowNullResponseId()) { + throw invalid("response id must not be null"); + } + return; + } + + if (id.isString()) { + if (!options.allowStringResponseId()) { + throw invalid("response string id is not allowed"); + } + return; + } + + if (id.isNumber()) { + if (!options.allowNumericResponseId()) { + throw invalid("response numeric id is not allowed"); + } + if (!options.allowFractionalResponseId() && id.isFloatingPointNumber()) { + throw invalid("response fractional numeric id is not allowed"); + } + return; + } + + throw invalid("response id must be string, number, or null"); + } + + /** + * Validates the response {@code error} object and its required members. + * + * @param error error node + */ + private void validateError(JsonNode error) { + if (options.requireErrorObjectWhenPresent() && (error == null || !error.isObject())) { + throw invalid("response error must be an object"); + } + + if (error == null || !error.isObject()) { + return; + } + + JsonNode code = error.get("code"); + if (options.requireIntegerErrorCode()) { + if (code == null || !code.isNumber() || code.isFloatingPointNumber()) { + throw invalid("response error.code must be an integer"); + } + } + + JsonNode message = error.get("message"); + if (options.requireStringErrorMessage()) { + if (message == null || !message.isString()) { + throw invalid("response error.message must be a string"); + } + } + } + + /** + * Creates a standardized invalid-response exception. + * + * @param message detail message + * @return invalid-request exception + */ + private JsonRpcException invalid(String message) { + return new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, message); + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcTypedMethodHandlerFactory.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcTypedMethodHandlerFactory.java index d20794e..f8f681a 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcTypedMethodHandlerFactory.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcTypedMethodHandlerFactory.java @@ -2,6 +2,7 @@ import tools.jackson.databind.JsonNode; +import java.util.Objects; import java.util.function.Function; import java.util.function.Supplier; @@ -20,8 +21,8 @@ public class DefaultJsonRpcTypedMethodHandlerFactory implements JsonRpcTypedMeth * @param resultWriter serializer for converting Java return values to JSON */ public DefaultJsonRpcTypedMethodHandlerFactory(JsonRpcParameterBinder parameterBinder, JsonRpcResultWriter resultWriter) { - this.parameterBinder = parameterBinder; - this.resultWriter = resultWriter; + this.parameterBinder = Objects.requireNonNull(parameterBinder, "parameterBinder"); + this.resultWriter = Objects.requireNonNull(resultWriter, "resultWriter"); } /** diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/ExecutorJsonRpcNotificationExecutor.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/ExecutorJsonRpcNotificationExecutor.java index 0b79fb6..47d7e6f 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/ExecutorJsonRpcNotificationExecutor.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/ExecutorJsonRpcNotificationExecutor.java @@ -1,6 +1,7 @@ package com.limehee.jsonrpc.core; import java.util.concurrent.Executor; +import java.util.Objects; /** * Notification executor that delegates execution to a supplied {@link Executor}. @@ -15,7 +16,7 @@ public class ExecutorJsonRpcNotificationExecutor implements JsonRpcNotificationE * @param executor target executor that performs delegated notification execution */ public ExecutorJsonRpcNotificationExecutor(Executor executor) { - this.executor = executor; + this.executor = Objects.requireNonNull(executor, "executor"); } /** diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JacksonJsonRpcParameterBinder.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JacksonJsonRpcParameterBinder.java index c17c89c..173a3f6 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JacksonJsonRpcParameterBinder.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JacksonJsonRpcParameterBinder.java @@ -5,6 +5,8 @@ import tools.jackson.databind.ObjectMapper; import org.jspecify.annotations.Nullable; +import java.util.Objects; + /** * {@link JsonRpcParameterBinder} implementation based on Jackson object mapping. */ @@ -18,7 +20,7 @@ public class JacksonJsonRpcParameterBinder implements JsonRpcParameterBinder { * @param objectMapper Jackson mapper used for tree-to-value conversion */ public JacksonJsonRpcParameterBinder(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); } /** @@ -37,7 +39,7 @@ public T bind(@Nullable JsonNode params, Class targetType) { if (params == null || params.isNull()) { return objectMapper.convertValue(null, targetType); } - return objectMapper.treeToValue(params, targetType); + return objectMapper.convertValue(params, targetType); } catch (JacksonException | IllegalArgumentException ex) { throw new JsonRpcException( JsonRpcErrorCode.INVALID_PARAMS, diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcDispatcher.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcDispatcher.java index 3e694f0..f5c068d 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcDispatcher.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcDispatcher.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; /** @@ -147,16 +148,16 @@ public JsonRpcDispatcher( List interceptors, JsonRpcNotificationExecutor notificationExecutor ) { - this.methodRegistry = methodRegistry; - this.requestParser = requestParser; - this.requestValidator = requestValidator; - this.methodInvoker = methodInvoker; - this.exceptionResolver = exceptionResolver; - this.responseComposer = responseComposer; + this.methodRegistry = Objects.requireNonNull(methodRegistry, "methodRegistry"); + this.requestParser = Objects.requireNonNull(requestParser, "requestParser"); + this.requestValidator = Objects.requireNonNull(requestValidator, "requestValidator"); + this.methodInvoker = Objects.requireNonNull(methodInvoker, "methodInvoker"); + this.exceptionResolver = Objects.requireNonNull(exceptionResolver, "exceptionResolver"); + this.responseComposer = Objects.requireNonNull(responseComposer, "responseComposer"); this.maxBatchSize = maxBatchSize; - this.interceptors = List.copyOf(interceptors); + this.interceptors = List.copyOf(Objects.requireNonNull(interceptors, "interceptors")); this.hasInterceptors = !this.interceptors.isEmpty(); - this.notificationExecutor = notificationExecutor; + this.notificationExecutor = Objects.requireNonNull(notificationExecutor, "notificationExecutor"); } /** @@ -216,6 +217,8 @@ public JsonRpcDispatchResult dispatch(@Nullable JsonNode payload) { requestValidator.validate(request); validRequest = true; return dispatchSingleRequest(request).orElse(null); + } catch (Error error) { + throw error; } catch (Throwable ex) { JsonNode id = request == null ? null : normalizeErrorId(request.id()); return handleRequestError(id, request, validRequest, ex).orElse(null); @@ -256,6 +259,8 @@ private Optional dispatchSingleNode(@Nullable JsonNode node) { requestValidator.validate(request); validRequest = true; return dispatchSingleRequest(request); + } catch (Error error) { + throw error; } catch (Throwable ex) { return handleRequestError(errorId, request, validRequest, ex); } @@ -342,7 +347,7 @@ private Optional handleRequestError( * @return normalized id or {@code null} */ private @Nullable JsonNode normalizeErrorId(@Nullable JsonNode id) { - if (id == null || id.isNull() || id.isTextual() || id.isNumber()) { + if (id == null || id.isNull() || id.isString() || id.isNumber()) { return id; } return null; @@ -441,6 +446,8 @@ private void invokeNotificationHandler(JsonRpcRequest request, JsonRpcMethodHand runBeforeInvoke(request); JsonNode result = methodInvoker.invoke(handler, request.params()); runAfterInvoke(request, result); + } catch (Error error) { + throw error; } catch (Throwable ex) { JsonRpcError error = exceptionResolver.resolve(ex); runOnError(request, ex, error); diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcEnvelopeClassifier.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcEnvelopeClassifier.java new file mode 100644 index 0000000..a51a1a0 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcEnvelopeClassifier.java @@ -0,0 +1,18 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** + * Classifies raw JSON payloads into request/response/invalid envelope types. + */ +public interface JsonRpcEnvelopeClassifier { + + /** + * Classifies the provided payload shape. + * + * @param payload raw JSON payload + * @return envelope type classification + */ + JsonRpcEnvelopeType classify(@Nullable JsonNode payload); +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcEnvelopeType.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcEnvelopeType.java new file mode 100644 index 0000000..84dcb61 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcEnvelopeType.java @@ -0,0 +1,19 @@ +package com.limehee.jsonrpc.core; + +/** + * High-level JSON-RPC payload type classification. + */ +public enum JsonRpcEnvelopeType { + /** + * Payload should be handled by the request dispatch pipeline. + */ + REQUEST, + /** + * Payload should be handled by response-side processing. + */ + RESPONSE, + /** + * Payload shape cannot be treated as request or response. + */ + INVALID +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponse.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponse.java new file mode 100644 index 0000000..30ccafe --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponse.java @@ -0,0 +1,28 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** + * Parsed incoming JSON-RPC response model preserving field presence semantics. + * + * @param source original response object node + * @param jsonrpc protocol version field value when textual; otherwise {@code null} + * @param id id field value when present; may be {@code null} + * @param idPresent whether the response explicitly contained an {@code id} member + * @param result result field value when present; may be {@code null} + * @param resultPresent whether the response explicitly contained a {@code result} member + * @param error error field value when present; may be {@code null} + * @param errorPresent whether the response explicitly contained an {@code error} member + */ +public record JsonRpcIncomingResponse( + JsonNode source, + @Nullable String jsonrpc, + @Nullable JsonNode id, + boolean idPresent, + @Nullable JsonNode result, + boolean resultPresent, + @Nullable JsonNode error, + boolean errorPresent +) { +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseEnvelope.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseEnvelope.java new file mode 100644 index 0000000..9bd43ee --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseEnvelope.java @@ -0,0 +1,68 @@ +package com.limehee.jsonrpc.core; + +import java.util.List; +import java.util.Optional; + +/** + * Parsed incoming response envelope for single or batch payloads. + */ +public final class JsonRpcIncomingResponseEnvelope { + + private final boolean batch; + private final List responses; + + private JsonRpcIncomingResponseEnvelope(boolean batch, List responses) { + this.batch = batch; + this.responses = List.copyOf(responses); + } + + /** + * Creates a single-response envelope. + * + * @param response parsed response entry + * @return single envelope + */ + public static JsonRpcIncomingResponseEnvelope single(JsonRpcIncomingResponse response) { + return new JsonRpcIncomingResponseEnvelope(false, List.of(response)); + } + + /** + * Creates a batch-response envelope. + * + * @param responses parsed response entries + * @return batch envelope + */ + public static JsonRpcIncomingResponseEnvelope batch(List responses) { + return new JsonRpcIncomingResponseEnvelope(true, responses); + } + + /** + * Indicates whether the source payload was a batch response. + * + * @return {@code true} when batch + */ + public boolean isBatch() { + return batch; + } + + /** + * Returns immutable parsed entries. + * + * @return parsed response list + */ + public List responses() { + return responses; + } + + /** + * Returns the single entry when available. + * + * @return optional single response + */ + public Optional singleResponse() { + if (batch || responses.isEmpty()) { + return Optional.empty(); + } + return Optional.of(responses.get(0)); + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcParamsTypeViolationCodePolicy.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcParamsTypeViolationCodePolicy.java new file mode 100644 index 0000000..a67a997 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcParamsTypeViolationCodePolicy.java @@ -0,0 +1,15 @@ +package com.limehee.jsonrpc.core; + +/** + * Policy controlling which JSON-RPC error code is emitted when {@code params} has an invalid type. + */ +public enum JsonRpcParamsTypeViolationCodePolicy { + /** + * Maps invalid {@code params} type to {@code -32602 Invalid params}. + */ + INVALID_PARAMS, + /** + * Maps invalid {@code params} type to {@code -32600 Invalid Request}. + */ + INVALID_REQUEST +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseParser.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseParser.java new file mode 100644 index 0000000..eb0ee9c --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseParser.java @@ -0,0 +1,19 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** + * Parses raw JSON payloads into incoming JSON-RPC response envelopes. + */ +public interface JsonRpcResponseParser { + + /** + * Parses a single response object or a batch response array. + * + * @param payload raw JSON payload + * @return parsed incoming response envelope + * @throws JsonRpcException when payload is not a valid response envelope container + */ + JsonRpcIncomingResponseEnvelope parse(@Nullable JsonNode payload); +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptions.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptions.java new file mode 100644 index 0000000..098540c --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptions.java @@ -0,0 +1,283 @@ +package com.limehee.jsonrpc.core; + +/** + * Fine-grained validation switches for incoming JSON-RPC response validation. + */ +public final class JsonRpcResponseValidationOptions { + + private final boolean requireJsonRpcVersion20; + private final boolean requireResponseIdMember; + private final boolean allowNullResponseId; + private final boolean allowStringResponseId; + private final boolean allowNumericResponseId; + private final boolean allowFractionalResponseId; + private final boolean requireExclusiveResultOrError; + private final boolean requireErrorObjectWhenPresent; + private final boolean requireIntegerErrorCode; + private final boolean requireStringErrorMessage; + private final boolean allowRequestFieldsInResponse; + + private JsonRpcResponseValidationOptions(Builder builder) { + this.requireJsonRpcVersion20 = builder.requireJsonRpcVersion20; + this.requireResponseIdMember = builder.requireResponseIdMember; + this.allowNullResponseId = builder.allowNullResponseId; + this.allowStringResponseId = builder.allowStringResponseId; + this.allowNumericResponseId = builder.allowNumericResponseId; + this.allowFractionalResponseId = builder.allowFractionalResponseId; + this.requireExclusiveResultOrError = builder.requireExclusiveResultOrError; + this.requireErrorObjectWhenPresent = builder.requireErrorObjectWhenPresent; + this.requireIntegerErrorCode = builder.requireIntegerErrorCode; + this.requireStringErrorMessage = builder.requireStringErrorMessage; + this.allowRequestFieldsInResponse = builder.allowRequestFieldsInResponse; + } + + /** + * Returns default validation options. + *

+ * RFC MUST rules are enabled by default. Compatibility-related rules are also configured with + * permissive defaults unless explicitly restricted through builder switches. + * + * @return default options + */ + public static JsonRpcResponseValidationOptions defaults() { + return builder().build(); + } + + /** + * Creates a mutable builder initialized with default values. + * + * @return options builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * @return whether {@code jsonrpc=="2.0"} is required + */ + public boolean requireJsonRpcVersion20() { + return requireJsonRpcVersion20; + } + + /** + * @return whether the {@code id} member must exist + */ + public boolean requireResponseIdMember() { + return requireResponseIdMember; + } + + /** + * @return whether {@code id:null} is allowed + */ + public boolean allowNullResponseId() { + return allowNullResponseId; + } + + /** + * @return whether string IDs are allowed + */ + public boolean allowStringResponseId() { + return allowStringResponseId; + } + + /** + * @return whether numeric IDs are allowed + */ + public boolean allowNumericResponseId() { + return allowNumericResponseId; + } + + /** + * @return whether fractional numeric IDs are allowed + */ + public boolean allowFractionalResponseId() { + return allowFractionalResponseId; + } + + /** + * @return whether exactly one of {@code result}/{@code error} is required + */ + public boolean requireExclusiveResultOrError() { + return requireExclusiveResultOrError; + } + + /** + * @return whether {@code error} must be an object when present + */ + public boolean requireErrorObjectWhenPresent() { + return requireErrorObjectWhenPresent; + } + + /** + * @return whether {@code error.code} must be an integer number + */ + public boolean requireIntegerErrorCode() { + return requireIntegerErrorCode; + } + + /** + * @return whether {@code error.message} must be a string + */ + public boolean requireStringErrorMessage() { + return requireStringErrorMessage; + } + + /** + * @return whether response objects may include request-only fields such as {@code method}/{@code params}; + * this is a compatibility policy and not an RFC MUST rule + */ + public boolean allowRequestFieldsInResponse() { + return allowRequestFieldsInResponse; + } + + /** + * Builder for response validation options. + */ + public static final class Builder { + + private boolean requireJsonRpcVersion20 = true; + private boolean requireResponseIdMember = true; + private boolean allowNullResponseId = true; + private boolean allowStringResponseId = true; + private boolean allowNumericResponseId = true; + private boolean allowFractionalResponseId = true; + private boolean requireExclusiveResultOrError = true; + private boolean requireErrorObjectWhenPresent = true; + private boolean requireIntegerErrorCode = true; + private boolean requireStringErrorMessage = true; + private boolean allowRequestFieldsInResponse = true; + + private Builder() { + } + + /** + * Enables or disables strict validation of the {@code jsonrpc} version field. + * + * @param enabled {@code true} to require {@code "2.0"} + * @return this builder + */ + public Builder requireJsonRpcVersion20(boolean enabled) { + this.requireJsonRpcVersion20 = enabled; + return this; + } + + /** + * Enables or disables required presence of the {@code id} member. + * + * @param enabled {@code true} to require an {@code id} member + * @return this builder + */ + public Builder requireResponseIdMember(boolean enabled) { + this.requireResponseIdMember = enabled; + return this; + } + + /** + * Enables or disables support for explicit {@code id:null}. + * + * @param enabled {@code true} to accept null IDs + * @return this builder + */ + public Builder allowNullResponseId(boolean enabled) { + this.allowNullResponseId = enabled; + return this; + } + + /** + * Enables or disables support for textual response IDs. + * + * @param enabled {@code true} to accept string IDs + * @return this builder + */ + public Builder allowStringResponseId(boolean enabled) { + this.allowStringResponseId = enabled; + return this; + } + + /** + * Enables or disables support for numeric response IDs. + * + * @param enabled {@code true} to accept numeric IDs + * @return this builder + */ + public Builder allowNumericResponseId(boolean enabled) { + this.allowNumericResponseId = enabled; + return this; + } + + /** + * Enables or disables support for fractional numeric response IDs. + * + * @param enabled {@code true} to accept fractional numbers (for example {@code 1.5}) + * @return this builder + */ + public Builder allowFractionalResponseId(boolean enabled) { + this.allowFractionalResponseId = enabled; + return this; + } + + /** + * Enables or disables strict exclusivity between {@code result} and {@code error}. + * + * @param enabled {@code true} to require exactly one of the two members + * @return this builder + */ + public Builder requireExclusiveResultOrError(boolean enabled) { + this.requireExclusiveResultOrError = enabled; + return this; + } + + /** + * Enables or disables object-shape enforcement for the {@code error} member. + * + * @param enabled {@code true} to require {@code error} to be an object when present + * @return this builder + */ + public Builder requireErrorObjectWhenPresent(boolean enabled) { + this.requireErrorObjectWhenPresent = enabled; + return this; + } + + /** + * Enables or disables integer enforcement for {@code error.code}. + * + * @param enabled {@code true} to require an integer code + * @return this builder + */ + public Builder requireIntegerErrorCode(boolean enabled) { + this.requireIntegerErrorCode = enabled; + return this; + } + + /** + * Enables or disables string enforcement for {@code error.message}. + * + * @param enabled {@code true} to require a string message + * @return this builder + */ + public Builder requireStringErrorMessage(boolean enabled) { + this.requireStringErrorMessage = enabled; + return this; + } + + /** + * Enables or disables tolerance for request-only fields in response objects. + * + * @param enabled {@code true} to allow response objects containing {@code method}/{@code params} + * @return this builder + */ + public Builder allowRequestFieldsInResponse(boolean enabled) { + this.allowRequestFieldsInResponse = enabled; + return this; + } + + /** + * Builds immutable validation options. + * + * @return immutable response validation options + */ + public JsonRpcResponseValidationOptions build() { + return new JsonRpcResponseValidationOptions(this); + } + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidator.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidator.java new file mode 100644 index 0000000..6e56cf3 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidator.java @@ -0,0 +1,15 @@ +package com.limehee.jsonrpc.core; + +/** + * Validates parsed incoming JSON-RPC responses against protocol rules. + */ +public interface JsonRpcResponseValidator { + + /** + * Validates a single parsed incoming response. + * + * @param response parsed response + * @throws JsonRpcException when the response violates configured validation constraints + */ + void validate(JsonRpcIncomingResponse response); +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/CoreConstructorNullGuardTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/CoreConstructorNullGuardTest.java new file mode 100644 index 0000000..cc5834b --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/CoreConstructorNullGuardTest.java @@ -0,0 +1,78 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.StringNode; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CoreConstructorNullGuardTest { + + @Test + void dispatcherConstructorRejectsNullMethodRegistry() { + assertThrows( + NullPointerException.class, + () -> new JsonRpcDispatcher( + null, + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 100, + List.of(), + new DirectJsonRpcNotificationExecutor() + ) + ); + } + + @Test + void dispatcherConstructorRejectsNullInterceptors() { + assertThrows( + NullPointerException.class, + () -> new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 100, + null, + new DirectJsonRpcNotificationExecutor() + ) + ); + } + + @Test + void parameterBinderConstructorRejectsNullObjectMapper() { + assertThrows(NullPointerException.class, () -> new JacksonJsonRpcParameterBinder(null)); + } + + @Test + void typedMethodHandlerFactoryConstructorRejectsNullDependencies() { + assertThrows( + NullPointerException.class, + () -> new DefaultJsonRpcTypedMethodHandlerFactory(null, value -> StringNode.valueOf("ok")) + ); + assertThrows( + NullPointerException.class, + () -> new DefaultJsonRpcTypedMethodHandlerFactory( + new JsonRpcParameterBinder() { + @Override + public T bind(JsonNode params, Class targetType) { + return null; + } + }, + null + ) + ); + } + + @Test + void executorNotificationConstructorRejectsNullExecutor() { + assertThrows(NullPointerException.class, () -> new ExecutorJsonRpcNotificationExecutor(null)); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcEnvelopeClassifierTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcEnvelopeClassifierTest.java new file mode 100644 index 0000000..c28c824 --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcEnvelopeClassifierTest.java @@ -0,0 +1,133 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DefaultJsonRpcEnvelopeClassifierTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private final JsonRpcEnvelopeClassifier classifier = new DefaultJsonRpcEnvelopeClassifier(); + + @Test + void classifyReturnsRequestForMethodObject() throws Exception { + assertEquals( + JsonRpcEnvelopeType.REQUEST, + classifier.classify(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1} + """)) + ); + } + + @Test + void classifyReturnsRequestForParamsOnlyObject() throws Exception { + assertEquals( + JsonRpcEnvelopeType.REQUEST, + classifier.classify(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","params":{"x":1}} + """)) + ); + } + + @Test + void classifyReturnsResponseForResultObject() throws Exception { + assertEquals( + JsonRpcEnvelopeType.RESPONSE, + classifier.classify(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","id":1,"result":true} + """)) + ); + } + + @Test + void classifyReturnsResponseForErrorObject() throws Exception { + assertEquals( + JsonRpcEnvelopeType.RESPONSE, + classifier.classify(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}} + """)) + ); + } + + @Test + void classifyReturnsResponseWhenResponseHintsExistWithRequestFields() throws Exception { + assertEquals( + JsonRpcEnvelopeType.RESPONSE, + classifier.classify(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","result":true} + """)) + ); + } + + @Test + void classifyReturnsInvalidForObjectWithoutHints() throws Exception { + assertEquals( + JsonRpcEnvelopeType.INVALID, + classifier.classify(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","id":1} + """)) + ); + } + + @Test + void classifyReturnsRequestForHomogeneousRequestBatch() throws Exception { + assertEquals( + JsonRpcEnvelopeType.REQUEST, + classifier.classify(OBJECT_MAPPER.readTree(""" + [ + {"jsonrpc":"2.0","method":"a","id":1}, + {"jsonrpc":"2.0","method":"b"} + ] + """)) + ); + } + + @Test + void classifyReturnsResponseForHomogeneousResponseBatch() throws Exception { + assertEquals( + JsonRpcEnvelopeType.RESPONSE, + classifier.classify(OBJECT_MAPPER.readTree(""" + [ + {"jsonrpc":"2.0","id":1,"result":1}, + {"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"x"}} + ] + """)) + ); + } + + @Test + void classifyReturnsInvalidForMixedBatch() throws Exception { + assertEquals( + JsonRpcEnvelopeType.INVALID, + classifier.classify(OBJECT_MAPPER.readTree(""" + [ + {"jsonrpc":"2.0","method":"a","id":1}, + {"jsonrpc":"2.0","id":1,"result":1} + ] + """)) + ); + } + + @Test + void classifyReturnsInvalidForNonObjectBatchEntry() throws Exception { + assertEquals( + JsonRpcEnvelopeType.INVALID, + classifier.classify(OBJECT_MAPPER.readTree(""" + [ + {"jsonrpc":"2.0","id":1,"result":1}, + 3 + ] + """)) + ); + } + + @Test + void classifyReturnsInvalidForEmptyArrayOrPrimitiveOrNull() throws Exception { + assertEquals(JsonRpcEnvelopeType.INVALID, classifier.classify(OBJECT_MAPPER.readTree("[]"))); + assertEquals(JsonRpcEnvelopeType.INVALID, classifier.classify(OBJECT_MAPPER.readTree("1"))); + assertEquals(JsonRpcEnvelopeType.INVALID, classifier.classify(null)); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcExceptionResolverTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcExceptionResolverTest.java index a9316ec..1501b81 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcExceptionResolverTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcExceptionResolverTest.java @@ -25,7 +25,7 @@ void includesErrorDataWhenEnabled() { JsonRpcError error = resolver.resolve(new JsonRpcException(-32000, "domain", StringNode.valueOf("secret"))); assertEquals(-32000, error.code()); - assertEquals("secret", error.data().asText()); + assertEquals("secret", error.data().asString()); } @Test diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParserTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParserTest.java index 6a7a2a9..4638dca 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParserTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestParserTest.java @@ -40,7 +40,7 @@ void parseExtractsRequestFields() throws Exception { assertEquals("2.0", request.jsonrpc()); assertEquals("ping", request.method()); assertEquals(1, request.params().get("value").asInt()); - assertEquals("abc", request.id().asText()); + assertEquals("abc", request.id().asString()); assertTrue(request.idPresent()); assertFalse(request.isNotification()); } diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidatorTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidatorTest.java index 8d64c40..33d5aaa 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidatorTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcRequestValidatorTest.java @@ -62,6 +62,25 @@ void validateRejectsPrimitiveParams() { assertEquals(JsonRpcErrorCode.INVALID_PARAMS, ex.getCode()); } + @Test + void validateRejectsPrimitiveParamsAsInvalidRequestWhenPolicyIsConfigured() { + DefaultJsonRpcRequestValidator strictShapeValidator = new DefaultJsonRpcRequestValidator( + JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(1), "ping", IntNode.valueOf(3), true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> strictShapeValidator.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void constructorRejectsNullParamsTypeViolationPolicy() { + assertThrows( + NullPointerException.class, + () -> new DefaultJsonRpcRequestValidator(null) + ); + } + @Test void validateAllowsTextOrNumberOrNullId() { assertDoesNotThrow(() -> validator.validate( diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParserTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParserTest.java new file mode 100644 index 0000000..cd84828 --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParserTest.java @@ -0,0 +1,82 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultJsonRpcResponseParserTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private final JsonRpcResponseParser parser = new DefaultJsonRpcResponseParser(); + + @Test + void parseParsesSingleResponseObject() throws Exception { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","id":1,"result":{"ok":true}} + """)); + + assertFalse(envelope.isBatch()); + JsonRpcIncomingResponse response = envelope.singleResponse().orElseThrow(); + assertEquals("2.0", response.jsonrpc()); + assertTrue(response.idPresent()); + assertTrue(response.resultPresent()); + assertFalse(response.errorPresent()); + } + + @Test + void parseParsesBatchResponseArray() throws Exception { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(OBJECT_MAPPER.readTree(""" + [ + {"jsonrpc":"2.0","id":"a","result":1}, + {"jsonrpc":"2.0","id":"b","error":{"code":-32000,"message":"x"}} + ] + """)); + + assertTrue(envelope.isBatch()); + assertEquals(2, envelope.responses().size()); + } + + @Test + void parseStoresNullVersionWhenJsonrpcFieldIsNotString() throws Exception { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":2,"id":1,"result":true} + """)); + + JsonRpcIncomingResponse response = envelope.singleResponse().orElseThrow(); + assertNull(response.jsonrpc()); + } + + @Test + void parsePreservesFieldPresenceForNullValues() throws Exception { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","id":null,"result":null} + """)); + + JsonRpcIncomingResponse response = envelope.singleResponse().orElseThrow(); + assertTrue(response.idPresent()); + assertTrue(response.resultPresent()); + assertFalse(response.errorPresent()); + } + + @Test + void parseRejectsNullPayload() { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse(null)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void parseRejectsPrimitiveOrEmptyArrayOrNonObjectBatchEntries() throws Exception { + assertThrows(JsonRpcException.class, () -> parser.parse(OBJECT_MAPPER.readTree("1"))); + assertThrows(JsonRpcException.class, () -> parser.parse(OBJECT_MAPPER.readTree("[]"))); + assertThrows(JsonRpcException.class, () -> parser.parse(OBJECT_MAPPER.readTree(""" + [{"jsonrpc":"2.0","id":1,"result":1}, 2] + """))); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidatorTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidatorTest.java new file mode 100644 index 0000000..668399f --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidatorTest.java @@ -0,0 +1,248 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DefaultJsonRpcResponseValidatorTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + private static final JsonRpcResponseParser PARSER = new DefaultJsonRpcResponseParser(); + + private final JsonRpcResponseValidator validator = new DefaultJsonRpcResponseValidator(); + + @Test + void validateAcceptsValidResultResponse() throws Exception { + assertDoesNotThrow(() -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":{"ok":true}} + """))); + } + + @Test + void validateAcceptsValidErrorResponse() throws Exception { + assertDoesNotThrow(() -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":"abc","error":{"code":-32000,"message":"x"}} + """))); + } + + @Test + void validateRejectsNullResponse() { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> validator.validate(null)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateRejectsWrongProtocolVersionByDefault() throws Exception { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"1.0","id":1,"result":1} + """))); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateAllowsWrongProtocolVersionWhenDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireJsonRpcVersion20(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"1.0","id":1,"result":1} + """))); + } + + @Test + void validateRejectsMissingIdByDefault() throws Exception { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","result":1} + """))); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateAllowsMissingIdWhenRuleDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireResponseIdMember(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","result":1} + """))); + } + + @Test + void validateRejectsInvalidIdTypes() throws Exception { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":{"x":1},"result":1} + """))); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateRespectsIdTypeOptions() throws Exception { + JsonRpcResponseValidator noNull = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder().allowNullResponseId(false).build() + ); + assertThrows(JsonRpcException.class, () -> noNull.validate(incoming(""" + {"jsonrpc":"2.0","id":null,"result":1} + """))); + + JsonRpcResponseValidator noString = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder().allowStringResponseId(false).build() + ); + assertThrows(JsonRpcException.class, () -> noString.validate(incoming(""" + {"jsonrpc":"2.0","id":"x","result":1} + """))); + + JsonRpcResponseValidator noNumeric = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder().allowNumericResponseId(false).build() + ); + assertThrows(JsonRpcException.class, () -> noNumeric.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":1} + """))); + } + + @Test + void validateRejectsFractionalIdWhenDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .allowFractionalResponseId(false) + .build() + ); + + assertThrows(JsonRpcException.class, () -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1.5,"result":1} + """))); + } + + @Test + void validateRejectsInvalidResultAndErrorCombination() throws Exception { + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":1,"error":{"code":-32000,"message":"x"}} + """))); + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1} + """))); + } + + @Test + void validateAllowsResultAndErrorCombinationWhenExclusiveRuleDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireExclusiveResultOrError(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":1,"error":{"code":-32000,"message":"x"}} + """))); + } + + @Test + void validateAllowsMissingResultAndErrorWhenExclusiveRuleDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireExclusiveResultOrError(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1} + """))); + } + + @Test + void validateRejectsInvalidErrorObjectStructure() throws Exception { + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":1} + """))); + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000}} + """))); + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":1.5,"message":"x"}} + """))); + } + + @Test + void validateRejectsNonNumericErrorCodeAndNonStringErrorMessage() throws Exception { + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":"x","message":"err"}} + """))); + assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":3}} + """))); + } + + @Test + void validateAllowsNonObjectErrorWhenObjectRuleIsDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireErrorObjectWhenPresent(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":1} + """))); + } + + @Test + void validateAllowsMissingErrorCodeAndMessageWhenRulesAreDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireIntegerErrorCode(false) + .requireStringErrorMessage(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"data":{"x":1}}} + """))); + } + + @Test + void validateCanRejectRequestFieldsWhenOptionDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .allowRequestFieldsInResponse(false) + .build() + ); + + assertThrows(JsonRpcException.class, () -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":1,"method":"ping"} + """))); + } + + @Test + void validateAllowsRequestFieldsByDefault() throws Exception { + assertDoesNotThrow(() -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":1,"method":"ping","params":{"a":1}} + """))); + } + + @Test + void validateAllowsFractionalIdByDefault() throws Exception { + assertDoesNotThrow(() -> validator.validate(incoming(""" + {"jsonrpc":"2.0","id":1.5,"result":1} + """))); + } + + @Test + void constructorRejectsNullOptions() { + assertThrows(NullPointerException.class, () -> new DefaultJsonRpcResponseValidator(null)); + } + + private JsonRpcIncomingResponse incoming(String json) throws Exception { + JsonNode payload = OBJECT_MAPPER.readTree(json); + return PARSER.parse(payload).singleResponse().orElseThrow(); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/InMemoryJsonRpcMethodRegistryTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/InMemoryJsonRpcMethodRegistryTest.java index 10e8e81..f20fb9c 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/InMemoryJsonRpcMethodRegistryTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/InMemoryJsonRpcMethodRegistryTest.java @@ -26,7 +26,7 @@ void replacesRegistrationWhenConfigured() { registry.register("ping", params -> StringNode.valueOf("pong1")); registry.register("ping", params -> StringNode.valueOf("pong2")); - assertEquals("pong2", registry.find("ping").orElseThrow().handle(null).asText()); + assertEquals("pong2", registry.find("ping").orElseThrow().handle(null).asString()); } @Test diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcDispatcherTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcDispatcherTest.java index 9c23713..d179b17 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcDispatcherTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcDispatcherTest.java @@ -37,7 +37,7 @@ void dispatchSingleRequestReturnsSuccess() throws Exception { JsonRpcResponse response = result.singleResponse().orElseThrow(); assertEquals("2.0", response.jsonrpc()); assertEquals(1, response.id().asInt()); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); } @Test @@ -134,7 +134,7 @@ void dispatchRequestWithExplicitNullIdReturnsResponse() throws Exception { assertTrue(result.hasResponse()); JsonRpcResponse response = result.singleResponse().orElseThrow(); assertTrue(response.id().isNull()); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); } @Test @@ -182,7 +182,7 @@ void dispatchBatchReturnsOnlyNonNotificationResponses() throws Exception { assertEquals(3, responses.size()); assertEquals(1, responses.get(0).id().asInt()); - assertEquals("pong", responses.get(0).result().asText()); + assertEquals("pong", responses.get(0).result().asString()); assertEquals(2, responses.get(1).id().asInt()); assertEquals(JsonRpcErrorCode.METHOD_NOT_FOUND, responses.get(1).error().code()); @@ -224,7 +224,7 @@ void dispatchBatchInvalidIdTypeUsesNullIdInErrorResponse() throws Exception { assertEquals(2, result.responses().size()); assertEquals(JsonRpcErrorCode.INVALID_REQUEST, result.responses().get(0).error().code()); assertNull(result.responses().get(0).id()); - assertEquals("pong", result.responses().get(1).result().asText()); + assertEquals("pong", result.responses().get(1).result().asString()); } @Test @@ -315,7 +315,7 @@ void legacyDispatchMethodSupportsSingleRequest() { JsonRpcResponse response = dispatcher.dispatch(request); assertNotNull(response); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); } @Test @@ -437,6 +437,42 @@ public void onError(JsonRpcRequest request, Throwable throwable, JsonRpcError ma assertTrue(interceptor.events.contains("onError:-32601")); } + @Test + void dispatchRequestPropagatesErrorFromHandler() throws Exception { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); + dispatcher.register("fatal", params -> { + throw new AssertionError("fatal"); + }); + + assertThrows(AssertionError.class, () -> dispatcher.dispatch(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"fatal","id":1} + """))); + } + + @Test + void legacyDispatchRequestPropagatesErrorFromHandler() { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); + dispatcher.register("fatal", params -> { + throw new AssertionError("fatal"); + }); + + JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(1), "fatal", null, true); + + assertThrows(AssertionError.class, () -> dispatcher.dispatch(request)); + } + + @Test + void dispatchNotificationPropagatesErrorFromHandler() throws Exception { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); + dispatcher.register("fatal", params -> { + throw new AssertionError("fatal"); + }); + + assertThrows(AssertionError.class, () -> dispatcher.dispatch(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"fatal"} + """))); + } + private static final class RecordingInterceptor implements JsonRpcInterceptor { private final List events = new ArrayList<>(); diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseEnvelopeTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseEnvelopeTest.java new file mode 100644 index 0000000..22d2737 --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseEnvelopeTest.java @@ -0,0 +1,76 @@ +package com.limehee.jsonrpc.core; + +import tools.jackson.databind.node.IntNode; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsonRpcIncomingResponseEnvelopeTest { + + @Test + void singleResponseReturnsEntryForSingleEnvelope() { + JsonRpcIncomingResponse response = new JsonRpcIncomingResponse( + IntNode.valueOf(1), + "2.0", + IntNode.valueOf(1), + true, + IntNode.valueOf(7), + true, + null, + false + ); + + JsonRpcIncomingResponseEnvelope envelope = JsonRpcIncomingResponseEnvelope.single(response); + + assertFalse(envelope.isBatch()); + assertEquals(response, envelope.singleResponse().orElseThrow()); + assertEquals(1, envelope.responses().size()); + } + + @Test + void singleResponseReturnsEmptyForBatchEnvelope() { + JsonRpcIncomingResponse response = new JsonRpcIncomingResponse( + IntNode.valueOf(1), + "2.0", + IntNode.valueOf(1), + true, + IntNode.valueOf(7), + true, + null, + false + ); + + JsonRpcIncomingResponseEnvelope envelope = JsonRpcIncomingResponseEnvelope.batch(List.of(response)); + + assertTrue(envelope.isBatch()); + assertTrue(envelope.singleResponse().isEmpty()); + } + + @Test + void responsesListIsImmutableSnapshot() { + JsonRpcIncomingResponse response = new JsonRpcIncomingResponse( + IntNode.valueOf(1), + "2.0", + IntNode.valueOf(1), + true, + IntNode.valueOf(7), + true, + null, + false + ); + List mutable = new ArrayList<>(); + mutable.add(response); + + JsonRpcIncomingResponseEnvelope envelope = JsonRpcIncomingResponseEnvelope.batch(mutable); + mutable.clear(); + + assertEquals(1, envelope.responses().size()); + assertThrows(UnsupportedOperationException.class, () -> envelope.responses().add(response)); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptionsTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptionsTest.java new file mode 100644 index 0000000..1364d23 --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptionsTest.java @@ -0,0 +1,55 @@ +package com.limehee.jsonrpc.core; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsonRpcResponseValidationOptionsTest { + + @Test + void defaultsEnableRfcMustRules() { + JsonRpcResponseValidationOptions options = JsonRpcResponseValidationOptions.defaults(); + + assertTrue(options.requireJsonRpcVersion20()); + assertTrue(options.requireResponseIdMember()); + assertTrue(options.allowNullResponseId()); + assertTrue(options.allowStringResponseId()); + assertTrue(options.allowNumericResponseId()); + assertTrue(options.allowFractionalResponseId()); + assertTrue(options.requireExclusiveResultOrError()); + assertTrue(options.requireErrorObjectWhenPresent()); + assertTrue(options.requireIntegerErrorCode()); + assertTrue(options.requireStringErrorMessage()); + assertTrue(options.allowRequestFieldsInResponse()); + } + + @Test + void builderAllowsOverridingEachFlag() { + JsonRpcResponseValidationOptions options = JsonRpcResponseValidationOptions.builder() + .requireJsonRpcVersion20(false) + .requireResponseIdMember(false) + .allowNullResponseId(false) + .allowStringResponseId(false) + .allowNumericResponseId(false) + .allowFractionalResponseId(false) + .requireExclusiveResultOrError(false) + .requireErrorObjectWhenPresent(false) + .requireIntegerErrorCode(false) + .requireStringErrorMessage(false) + .allowRequestFieldsInResponse(false) + .build(); + + assertFalse(options.requireJsonRpcVersion20()); + assertFalse(options.requireResponseIdMember()); + assertFalse(options.allowNullResponseId()); + assertFalse(options.allowStringResponseId()); + assertFalse(options.allowNumericResponseId()); + assertFalse(options.allowFractionalResponseId()); + assertFalse(options.requireExclusiveResultOrError()); + assertFalse(options.requireErrorObjectWhenPresent()); + assertFalse(options.requireIntegerErrorCode()); + assertFalse(options.requireStringErrorMessage()); + assertFalse(options.allowRequestFieldsInResponse()); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcTypedMethodHandlerFactoryTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcTypedMethodHandlerFactoryTest.java index 8e98148..48c690e 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcTypedMethodHandlerFactoryTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcTypedMethodHandlerFactoryTest.java @@ -21,8 +21,8 @@ class JsonRpcTypedMethodHandlerFactoryTest { void noParamsBindsAndWritesResult() { JsonRpcMethodHandler handler = factory.noParams(() -> "pong"); - assertEquals("pong", handler.handle(null).asText()); - assertEquals("pong", handler.handle(OBJECT_MAPPER.createObjectNode()).asText()); + assertEquals("pong", handler.handle(null).asString()); + assertEquals("pong", handler.handle(OBJECT_MAPPER.createObjectNode()).asString()); } @Test @@ -37,7 +37,7 @@ void noParamsRejectsUnexpectedParams() { void unaryBindsObjectParams() throws Exception { JsonRpcMethodHandler handler = factory.unary(PingParams.class, params -> "hello " + params.name()); - assertEquals("hello developer", handler.handle(OBJECT_MAPPER.readTree("{\"name\":\"developer\"}")).asText()); + assertEquals("hello developer", handler.handle(OBJECT_MAPPER.readTree("{\"name\":\"developer\"}")).asString()); } @Test diff --git a/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryE2ETest.java b/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryE2ETest.java index 3a7cf2d..77bb2c7 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryE2ETest.java +++ b/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryE2ETest.java @@ -47,9 +47,9 @@ void e2eHttpRequestReturnsJsonRpcSuccessResponse() throws Exception { assertEquals(200, response.statusCode()); JsonNode body = OBJECT_MAPPER.readTree(response.body()); - assertEquals("2.0", body.get("jsonrpc").asText()); + assertEquals("2.0", body.get("jsonrpc").asString()); assertEquals(10, body.get("id").asInt()); - assertEquals("pong", body.get("result").asText()); + assertEquals("pong", body.get("result").asString()); } @Test diff --git a/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesE2ETest.java b/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesE2ETest.java index 730e556..ec1c9a7 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesE2ETest.java +++ b/jsonrpc-spring-boot-autoconfigure/src/e2eTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesE2ETest.java @@ -50,7 +50,7 @@ void e2eSupportsAnnotationWithRecordReturn() throws Exception { """); assertEquals(11, body.get("result").get("id").asInt()); - assertEquals("user-11", body.get("result").get("name").asText()); + assertEquals("user-11", body.get("result").get("name").asString()); } @Test @@ -59,7 +59,7 @@ void e2eSupportsManualRegistration() throws Exception { {"jsonrpc":"2.0","method":"manual.ping","id":2} """); - assertEquals("pong-manual", body.get("result").asText()); + assertEquals("pong-manual", body.get("result").asString()); } @Test @@ -71,10 +71,10 @@ void e2eSupportsTypedFactoryRegistrationWithClassParamAndCollectionReturn() thro {"jsonrpc":"2.0","method":"typed.tags","id":4} """); - assertEquals("RPC", upper.get("result").get("result").asText()); + assertEquals("RPC", upper.get("result").get("result").asString()); assertTrue(tags.get("result").isArray()); - assertEquals("alpha", tags.get("result").get(0).asText()); - assertEquals("beta", tags.get("result").get(1).asText()); + assertEquals("alpha", tags.get("result").get(0).asString()); + assertEquals("beta", tags.get("result").get(1).asString()); } private JsonNode call(String payload) throws Exception { diff --git a/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryIntegrationTest.java b/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryIntegrationTest.java index b271d86..fe35075 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryIntegrationTest.java +++ b/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcLibraryIntegrationTest.java @@ -64,7 +64,7 @@ void integrationWiresDispatcherAndAnnotatedMethodRegistration() throws Exception """)); JsonRpcResponse response = result.singleResponse().orElseThrow(); - assertEquals("echo:x", response.result().asText()); + assertEquals("echo:x", response.result().asString()); } @Test @@ -78,9 +78,9 @@ void integrationInvokesEndpointAndReturnsJsonRpcPayload() throws Exception { .andReturn(); JsonNode body = OBJECT_MAPPER.readTree(result.getResponse().getContentAsByteArray()); - assertEquals("2.0", body.get("jsonrpc").asText()); + assertEquals("2.0", body.get("jsonrpc").asString()); assertEquals(2, body.get("id").asInt()); - assertEquals("pong", body.get("result").asText()); + assertEquals("pong", body.get("result").asString()); } @Test diff --git a/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesIntegrationTest.java b/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesIntegrationTest.java index 45cbca7..89f5f49 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesIntegrationTest.java +++ b/jsonrpc-spring-boot-autoconfigure/src/integrationTest/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcRegistrationStylesIntegrationTest.java @@ -60,7 +60,7 @@ void supportsAnnotationRegistrationWithClassParamAndRecordReturn() throws Except """); assertEquals(7, body.get("result").get("id").asInt()); - assertEquals("user-7", body.get("result").get("name").asText()); + assertEquals("user-7", body.get("result").get("name").asString()); } @Test @@ -81,7 +81,7 @@ void supportsManualJsonRpcMethodRegistration() throws Exception { {"jsonrpc":"2.0","method":"manual.ping","id":3} """); - assertEquals("pong-manual", body.get("result").asText()); + assertEquals("pong-manual", body.get("result").asString()); } @Test @@ -90,7 +90,7 @@ void supportsTypedFactoryRegistrationWithClassParamAndClassReturn() throws Excep {"jsonrpc":"2.0","method":"typed.upper","params":{"value":"developer"},"id":4} """); - assertEquals("DEVELOPER", body.get("result").get("result").asText()); + assertEquals("DEVELOPER", body.get("result").get("result").asString()); } @Test @@ -100,8 +100,8 @@ void supportsTypedFactoryNoParamsReturningCollection() throws Exception { """); assertTrue(body.get("result").isArray()); - assertEquals("alpha", body.get("result").get(0).asText()); - assertEquals("beta", body.get("result").get(1).asText()); + assertEquals("alpha", body.get("result").get(0).asString()); + assertEquals("beta", body.get("result").get(1).asString()); } private JsonNode invoke(String payload) throws Exception { diff --git a/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfiguration.java b/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfiguration.java index ac10891..fff1bbe 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfiguration.java +++ b/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfiguration.java @@ -7,6 +7,7 @@ import com.limehee.jsonrpc.core.DefaultJsonRpcRequestParser; import com.limehee.jsonrpc.core.DefaultJsonRpcRequestValidator; import com.limehee.jsonrpc.core.DefaultJsonRpcResponseComposer; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseValidator; import com.limehee.jsonrpc.core.DefaultJsonRpcTypedMethodHandlerFactory; import com.limehee.jsonrpc.core.InMemoryJsonRpcMethodRegistry; import com.limehee.jsonrpc.core.JacksonJsonRpcParameterBinder; @@ -23,6 +24,8 @@ import com.limehee.jsonrpc.core.JsonRpcRequestValidator; import com.limehee.jsonrpc.core.JsonRpcResultWriter; import com.limehee.jsonrpc.core.JsonRpcResponseComposer; +import com.limehee.jsonrpc.core.JsonRpcResponseValidationOptions; +import com.limehee.jsonrpc.core.JsonRpcResponseValidator; import com.limehee.jsonrpc.core.JsonRpcTypedMethodHandlerFactory; import com.limehee.jsonrpc.core.DirectJsonRpcNotificationExecutor; import com.limehee.jsonrpc.core.ExecutorJsonRpcNotificationExecutor; @@ -91,12 +94,74 @@ public JsonRpcRequestParser jsonRpcRequestParser() { /** * Creates request validator for JSON-RPC structural checks. * + * @param properties bound JSON-RPC properties * @return request validator */ @Bean @ConditionalOnMissingBean - public JsonRpcRequestValidator jsonRpcRequestValidator() { - return new DefaultJsonRpcRequestValidator(); + public JsonRpcRequestValidator jsonRpcRequestValidator(JsonRpcProperties properties) { + JsonRpcProperties.Validation validation = properties.getValidation(); + if (validation == null) { + throw new IllegalArgumentException("jsonrpc.validation must not be null"); + } + JsonRpcProperties.Validation.Request request = validation.getRequest(); + if (request == null) { + throw new IllegalArgumentException("jsonrpc.validation.request must not be null"); + } + if (request.getParamsTypeViolationCodePolicy() == null) { + throw new IllegalArgumentException( + "jsonrpc.validation.request.params-type-violation-code-policy must not be null" + ); + } + return new DefaultJsonRpcRequestValidator( + request.getParamsTypeViolationCodePolicy() + ); + } + + /** + * Creates response-validation options bound from external configuration. + * + * @param properties bound JSON-RPC properties + * @return response-validation options + */ + @Bean + @ConditionalOnMissingBean + public JsonRpcResponseValidationOptions jsonRpcResponseValidationOptions(JsonRpcProperties properties) { + JsonRpcProperties.Validation validation = properties.getValidation(); + if (validation == null) { + throw new IllegalArgumentException("jsonrpc.validation must not be null"); + } + JsonRpcProperties.Validation.Response response = validation.getResponse(); + if (response == null) { + throw new IllegalArgumentException("jsonrpc.validation.response must not be null"); + } + return JsonRpcResponseValidationOptions.builder() + .requireJsonRpcVersion20(response.isRequireJsonRpcVersion20()) + .requireResponseIdMember(response.isRequireResponseIdMember()) + .allowNullResponseId(response.isAllowNullResponseId()) + .allowStringResponseId(response.isAllowStringResponseId()) + .allowNumericResponseId(response.isAllowNumericResponseId()) + .allowFractionalResponseId(response.isAllowFractionalResponseId()) + .requireExclusiveResultOrError(response.isRequireExclusiveResultOrError()) + .requireErrorObjectWhenPresent(response.isRequireErrorObjectWhenPresent()) + .requireIntegerErrorCode(response.isRequireIntegerErrorCode()) + .requireStringErrorMessage(response.isRequireStringErrorMessage()) + .allowRequestFieldsInResponse(response.isAllowRequestFieldsInResponse()) + .build(); + } + + /** + * Creates response validator for incoming JSON-RPC response envelopes. + * + * @param options response-validation options + * @return response validator + */ + @Bean + @ConditionalOnMissingBean + public JsonRpcResponseValidator jsonRpcResponseValidator(JsonRpcResponseValidationOptions options) { + return new DefaultJsonRpcResponseValidator( + options + ); } /** @@ -457,6 +522,20 @@ private void validateProperties(JsonRpcProperties properties) { if (properties.getNotificationExecutorBeanName() == null) { throw new IllegalArgumentException("jsonrpc.notification-executor-bean-name must not be null"); } + if (properties.getValidation() == null) { + throw new IllegalArgumentException("jsonrpc.validation must not be null"); + } + if (properties.getValidation().getRequest() == null) { + throw new IllegalArgumentException("jsonrpc.validation.request must not be null"); + } + if (properties.getValidation().getRequest().getParamsTypeViolationCodePolicy() == null) { + throw new IllegalArgumentException( + "jsonrpc.validation.request.params-type-violation-code-policy must not be null" + ); + } + if (properties.getValidation().getResponse() == null) { + throw new IllegalArgumentException("jsonrpc.validation.response must not be null"); + } validateMethodList("jsonrpc.method-allowlist", properties.getMethodAllowlist()); validateMethodList("jsonrpc.method-denylist", properties.getMethodDenylist()); diff --git a/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcProperties.java b/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcProperties.java index 4806041..3093960 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcProperties.java +++ b/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcProperties.java @@ -1,10 +1,12 @@ package com.limehee.jsonrpc.spring.boot.autoconfigure; +import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; import com.limehee.jsonrpc.core.JsonRpcMethodRegistrationConflictPolicy; import org.springframework.boot.context.properties.ConfigurationProperties; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** * Externalized Spring Boot configuration properties for JSON-RPC auto-configuration. @@ -28,6 +30,7 @@ public class JsonRpcProperties { private boolean notificationExecutorEnabled = false; private String notificationExecutorBeanName = ""; private JsonRpcMethodRegistrationConflictPolicy methodRegistrationConflictPolicy = JsonRpcMethodRegistrationConflictPolicy.REJECT; + private Validation validation = new Validation(); private List methodAllowlist = new ArrayList<>(); private List methodDenylist = new ArrayList<>(); @@ -275,6 +278,24 @@ public void setMethodRegistrationConflictPolicy( this.methodRegistrationConflictPolicy = methodRegistrationConflictPolicy; } + /** + * Returns validation-related options. + * + * @return nested validation options + */ + public Validation getValidation() { + return validation; + } + + /** + * Sets validation-related options. + * + * @param validation nested validation options; must not be {@code null} + */ + public void setValidation(Validation validation) { + this.validation = Objects.requireNonNull(validation, "validation"); + } + /** * Returns method allowlist used by access control interceptor. * @@ -310,4 +331,301 @@ public List getMethodDenylist() { public void setMethodDenylist(List methodDenylist) { this.methodDenylist = methodDenylist; } + + /** + * Nested validation configuration under {@code jsonrpc.validation.*}. + */ + public static final class Validation { + + private Request request = new Request(); + private Response response = new Response(); + + /** + * Returns request-validation options. + * + * @return request-validation options + */ + public Request getRequest() { + return request; + } + + /** + * Sets request-validation options. + * + * @param request request-validation options; must not be {@code null} + */ + public void setRequest(Request request) { + this.request = Objects.requireNonNull(request, "request"); + } + + /** + * Returns response-validation options. + * + * @return response-validation options + */ + public Response getResponse() { + return response; + } + + /** + * Sets response-validation options. + * + * @param response response-validation options; must not be {@code null} + */ + public void setResponse(Response response) { + this.response = Objects.requireNonNull(response, "response"); + } + + /** + * Request-side validation options under {@code jsonrpc.validation.request.*}. + */ + public static final class Request { + + private JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy = + JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS; + + /** + * Returns the error-code mapping policy used when request {@code params} exists but is + * neither an object nor an array. + * + * @return params-type violation error-code policy + */ + public JsonRpcParamsTypeViolationCodePolicy getParamsTypeViolationCodePolicy() { + return paramsTypeViolationCodePolicy; + } + + /** + * Sets the error-code mapping policy used when request {@code params} exists but is + * neither an object nor an array. + * + * @param paramsTypeViolationCodePolicy params-type violation error-code policy + */ + public void setParamsTypeViolationCodePolicy( + JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy + ) { + this.paramsTypeViolationCodePolicy = Objects.requireNonNull( + paramsTypeViolationCodePolicy, + "paramsTypeViolationCodePolicy" + ); + } + } + + /** + * Response-side validation options under {@code jsonrpc.validation.response.*}. + */ + public static final class Response { + + private boolean requireJsonRpcVersion20 = true; + private boolean requireResponseIdMember = true; + private boolean allowNullResponseId = true; + private boolean allowStringResponseId = true; + private boolean allowNumericResponseId = true; + private boolean allowFractionalResponseId = true; + private boolean requireExclusiveResultOrError = true; + private boolean requireErrorObjectWhenPresent = true; + private boolean requireIntegerErrorCode = true; + private boolean requireStringErrorMessage = true; + private boolean allowRequestFieldsInResponse = true; + + /** + * Indicates whether {@code jsonrpc == "2.0"} is required on incoming responses. + * + * @return {@code true} when version enforcement is enabled + */ + public boolean isRequireJsonRpcVersion20() { + return requireJsonRpcVersion20; + } + + /** + * Sets whether {@code jsonrpc == "2.0"} is required on incoming responses. + * + * @param requireJsonRpcVersion20 {@code true} to enforce version field + */ + public void setRequireJsonRpcVersion20(boolean requireJsonRpcVersion20) { + this.requireJsonRpcVersion20 = requireJsonRpcVersion20; + } + + /** + * Indicates whether incoming responses must include an {@code id} member. + * + * @return {@code true} when response {@code id} member is required + */ + public boolean isRequireResponseIdMember() { + return requireResponseIdMember; + } + + /** + * Sets whether incoming responses must include an {@code id} member. + * + * @param requireResponseIdMember {@code true} to require response {@code id} + */ + public void setRequireResponseIdMember(boolean requireResponseIdMember) { + this.requireResponseIdMember = requireResponseIdMember; + } + + /** + * Indicates whether {@code id: null} is allowed in incoming responses. + * + * @return {@code true} when null IDs are accepted + */ + public boolean isAllowNullResponseId() { + return allowNullResponseId; + } + + /** + * Sets whether {@code id: null} is allowed in incoming responses. + * + * @param allowNullResponseId {@code true} to accept null IDs + */ + public void setAllowNullResponseId(boolean allowNullResponseId) { + this.allowNullResponseId = allowNullResponseId; + } + + /** + * Indicates whether textual response IDs are allowed. + * + * @return {@code true} when string IDs are accepted + */ + public boolean isAllowStringResponseId() { + return allowStringResponseId; + } + + /** + * Sets whether textual response IDs are allowed. + * + * @param allowStringResponseId {@code true} to accept string IDs + */ + public void setAllowStringResponseId(boolean allowStringResponseId) { + this.allowStringResponseId = allowStringResponseId; + } + + /** + * Indicates whether numeric response IDs are allowed. + * + * @return {@code true} when numeric IDs are accepted + */ + public boolean isAllowNumericResponseId() { + return allowNumericResponseId; + } + + /** + * Sets whether numeric response IDs are allowed. + * + * @param allowNumericResponseId {@code true} to accept numeric IDs + */ + public void setAllowNumericResponseId(boolean allowNumericResponseId) { + this.allowNumericResponseId = allowNumericResponseId; + } + + /** + * Indicates whether fractional numeric response IDs are allowed. + * + * @return {@code true} when fractional numeric IDs are accepted + */ + public boolean isAllowFractionalResponseId() { + return allowFractionalResponseId; + } + + /** + * Sets whether fractional numeric response IDs are allowed. + * + * @param allowFractionalResponseId {@code true} to accept fractional numeric IDs + */ + public void setAllowFractionalResponseId(boolean allowFractionalResponseId) { + this.allowFractionalResponseId = allowFractionalResponseId; + } + + /** + * Indicates whether exactly one of {@code result}/{@code error} must exist. + * + * @return {@code true} when exclusive result/error enforcement is enabled + */ + public boolean isRequireExclusiveResultOrError() { + return requireExclusiveResultOrError; + } + + /** + * Sets whether exactly one of {@code result}/{@code error} must exist. + * + * @param requireExclusiveResultOrError {@code true} to enforce exclusivity + */ + public void setRequireExclusiveResultOrError(boolean requireExclusiveResultOrError) { + this.requireExclusiveResultOrError = requireExclusiveResultOrError; + } + + /** + * Indicates whether {@code error} must be an object when present. + * + * @return {@code true} when error object enforcement is enabled + */ + public boolean isRequireErrorObjectWhenPresent() { + return requireErrorObjectWhenPresent; + } + + /** + * Sets whether {@code error} must be an object when present. + * + * @param requireErrorObjectWhenPresent {@code true} to enforce error object shape + */ + public void setRequireErrorObjectWhenPresent(boolean requireErrorObjectWhenPresent) { + this.requireErrorObjectWhenPresent = requireErrorObjectWhenPresent; + } + + /** + * Indicates whether {@code error.code} must be an integer number. + * + * @return {@code true} when integer error-code enforcement is enabled + */ + public boolean isRequireIntegerErrorCode() { + return requireIntegerErrorCode; + } + + /** + * Sets whether {@code error.code} must be an integer number. + * + * @param requireIntegerErrorCode {@code true} to enforce integer error code + */ + public void setRequireIntegerErrorCode(boolean requireIntegerErrorCode) { + this.requireIntegerErrorCode = requireIntegerErrorCode; + } + + /** + * Indicates whether {@code error.message} must be a string. + * + * @return {@code true} when string error-message enforcement is enabled + */ + public boolean isRequireStringErrorMessage() { + return requireStringErrorMessage; + } + + /** + * Sets whether {@code error.message} must be a string. + * + * @param requireStringErrorMessage {@code true} to enforce string error message + */ + public void setRequireStringErrorMessage(boolean requireStringErrorMessage) { + this.requireStringErrorMessage = requireStringErrorMessage; + } + + /** + * Indicates whether request-only fields like {@code method}/{@code params} are + * allowed in response objects. + * + * @return {@code true} when request fields are tolerated in responses + */ + public boolean isAllowRequestFieldsInResponse() { + return allowRequestFieldsInResponse; + } + + /** + * Sets whether request-only fields like {@code method}/{@code params} are allowed in + * response objects. + * + * @param allowRequestFieldsInResponse {@code true} to allow request fields in response + */ + public void setAllowRequestFieldsInResponse(boolean allowRequestFieldsInResponse) { + this.allowRequestFieldsInResponse = allowRequestFieldsInResponse; + } + } + } } diff --git a/jsonrpc-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/jsonrpc-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 9259adf..d962287 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/jsonrpc-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -77,6 +77,78 @@ "defaultValue": "REJECT", "description": "Policy used when a method name is registered more than once." }, + { + "name": "jsonrpc.validation.request.params-type-violation-code-policy", + "type": "com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy", + "defaultValue": "INVALID_PARAMS", + "description": "Error-code policy when request params is present but neither object nor array." + }, + { + "name": "jsonrpc.validation.response.require-json-rpc-version-20", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require incoming response jsonrpc to equal \"2.0\"." + }, + { + "name": "jsonrpc.validation.response.require-response-id-member", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require incoming responses to include an id member." + }, + { + "name": "jsonrpc.validation.response.allow-null-response-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow id:null in incoming responses." + }, + { + "name": "jsonrpc.validation.response.allow-string-response-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow string ids in incoming responses." + }, + { + "name": "jsonrpc.validation.response.allow-numeric-response-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow numeric ids in incoming responses." + }, + { + "name": "jsonrpc.validation.response.allow-fractional-response-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow fractional numeric ids in incoming responses." + }, + { + "name": "jsonrpc.validation.response.require-exclusive-result-or-error", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require exactly one of result or error in incoming responses." + }, + { + "name": "jsonrpc.validation.response.require-error-object-when-present", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require error to be an object when present in incoming responses." + }, + { + "name": "jsonrpc.validation.response.require-integer-error-code", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require error.code to be an integer when validated." + }, + { + "name": "jsonrpc.validation.response.require-string-error-message", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require error.message to be a string when validated." + }, + { + "name": "jsonrpc.validation.response.allow-request-fields-in-response", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow request-only fields (method/params) in incoming responses." + }, { "name": "jsonrpc.method-allowlist", "type": "java.util.List", @@ -103,6 +175,13 @@ { "value": "REPLACE" } ] }, + { + "name": "jsonrpc.validation.request.params-type-violation-code-policy", + "values": [ + { "value": "INVALID_PARAMS" }, + { "value": "INVALID_REQUEST" } + ] + }, { "name": "jsonrpc.notification-executor-bean-name", "values": [ diff --git a/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfigurationTest.java b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfigurationTest.java index 6a3ada2..691e802 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfigurationTest.java +++ b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAutoConfigurationTest.java @@ -12,6 +12,8 @@ import com.limehee.jsonrpc.core.JsonRpcParam; import com.limehee.jsonrpc.core.JsonRpcRequest; import com.limehee.jsonrpc.core.JsonRpcResponse; +import com.limehee.jsonrpc.core.JsonRpcResponseValidationOptions; +import com.limehee.jsonrpc.core.JsonRpcResponseValidator; import com.limehee.jsonrpc.core.JsonRpcTypedMethodHandlerFactory; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -49,7 +51,7 @@ void createsDispatcherAndRegistersMethods() { JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(1), "ping", null, true); JsonRpcResponse response = dispatcher.dispatch(request); assertNotNull(response); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); JsonRpcTypedMethodHandlerFactory typedFactory = context.getBean(JsonRpcTypedMethodHandlerFactory.class); assertNotNull(typedFactory); @@ -73,7 +75,7 @@ void registersAnnotatedMethods() throws Exception { )); assertNotNull(response); - assertEquals("hello developer", response.result().asText()); + assertEquals("hello developer", response.result().asString()); }); } @@ -155,7 +157,7 @@ void registersAnnotatedMethodsWithNamedParamsObject() throws Exception { )); assertNotNull(response); - assertEquals("ab", response.result().asText()); + assertEquals("ab", response.result().asString()); }); } @@ -193,7 +195,7 @@ void namedAnnotatedMethodCanUseJavaParameterNamesWhenAvailable() throws Exceptio )); assertNotNull(response); - assertEquals("xy", response.result().asText()); + assertEquals("xy", response.result().asString()); }); } @@ -215,7 +217,7 @@ void wiresInterceptorsIntoDispatcher() { true )); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); assertTrue(interceptor.beforeInvokeCount > 0); assertTrue(interceptor.afterInvokeCount > 0); }); @@ -265,7 +267,7 @@ void includesErrorDataWhenConfigured() { assertNotNull(response.error()); assertEquals(-32001, response.error().code()); - assertEquals("secret", response.error().data().asText()); + assertEquals("secret", response.error().data().asString()); }); } @@ -557,7 +559,7 @@ void normalizesAllowlistValuesByTrimmingWhitespace() { true )); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); }); } @@ -571,6 +573,93 @@ void rejectsBlankMethodEntriesInAllowAndDenyLists() { .run(context -> assertNotNull(context.getStartupFailure())); } + @Test + void mapsParamsTypeViolationToInvalidParamsByDefault() { + contextRunner + .withBean("ping", JsonRpcMethodRegistration.class, + () -> JsonRpcMethodRegistration.of("ping", params -> StringNode.valueOf("pong"))) + .run(context -> { + JsonRpcDispatcher dispatcher = context.getBean(JsonRpcDispatcher.class); + JsonRpcResponse response = dispatcher.dispatch(new JsonRpcRequest( + "2.0", + IntNode.valueOf(35), + "ping", + StringNode.valueOf("invalid-shape"), + true + )); + + assertNotNull(response.error()); + assertEquals(-32602, response.error().code()); + }); + } + + @Test + void mapsParamsTypeViolationToInvalidRequestWhenConfiguredViaProperty() { + contextRunner + .withPropertyValues("jsonrpc.validation.request.params-type-violation-code-policy=INVALID_REQUEST") + .withBean("ping", JsonRpcMethodRegistration.class, + () -> JsonRpcMethodRegistration.of("ping", params -> StringNode.valueOf("pong"))) + .run(context -> { + JsonRpcDispatcher dispatcher = context.getBean(JsonRpcDispatcher.class); + JsonRpcResponse response = dispatcher.dispatch(new JsonRpcRequest( + "2.0", + IntNode.valueOf(36), + "ping", + StringNode.valueOf("invalid-shape"), + true + )); + + assertNotNull(response.error()); + assertEquals(-32600, response.error().code()); + }); + } + + @Test + void rejectsUnknownParamsTypeViolationCodePolicyValue() { + contextRunner + .withPropertyValues("jsonrpc.validation.request.params-type-violation-code-policy=NOT_A_POLICY") + .run(context -> assertNotNull(context.getStartupFailure())); + } + + @Test + void bindsDefaultResponseValidationOptions() { + contextRunner.run(context -> { + JsonRpcResponseValidationOptions options = context.getBean(JsonRpcResponseValidationOptions.class); + JsonRpcResponseValidator responseValidator = context.getBean(JsonRpcResponseValidator.class); + JsonRpcResponseValidationOptions coreDefaults = JsonRpcResponseValidationOptions.defaults(); + + assertEquals(coreDefaults.requireJsonRpcVersion20(), options.requireJsonRpcVersion20()); + assertEquals(coreDefaults.requireResponseIdMember(), options.requireResponseIdMember()); + assertEquals(coreDefaults.allowNullResponseId(), options.allowNullResponseId()); + assertEquals(coreDefaults.allowStringResponseId(), options.allowStringResponseId()); + assertEquals(coreDefaults.allowNumericResponseId(), options.allowNumericResponseId()); + assertEquals(coreDefaults.allowFractionalResponseId(), options.allowFractionalResponseId()); + assertEquals(coreDefaults.requireExclusiveResultOrError(), options.requireExclusiveResultOrError()); + assertEquals(coreDefaults.requireErrorObjectWhenPresent(), options.requireErrorObjectWhenPresent()); + assertEquals(coreDefaults.requireIntegerErrorCode(), options.requireIntegerErrorCode()); + assertEquals(coreDefaults.requireStringErrorMessage(), options.requireStringErrorMessage()); + assertEquals(coreDefaults.allowRequestFieldsInResponse(), options.allowRequestFieldsInResponse()); + assertNotNull(responseValidator); + }); + } + + @Test + void appliesConfiguredResponseValidationOptions() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.response.require-response-id-member=false", + "jsonrpc.validation.response.allow-fractional-response-id=false", + "jsonrpc.validation.response.allow-request-fields-in-response=false" + ) + .run(context -> { + JsonRpcResponseValidationOptions options = context.getBean(JsonRpcResponseValidationOptions.class); + + assertFalse(options.requireResponseIdMember()); + assertFalse(options.allowFractionalResponseId()); + assertFalse(options.allowRequestFieldsInResponse()); + }); + } + @Test void rejectsMaxBatchSizeLessThanOne() { contextRunner diff --git a/jsonrpc-spring-webmvc/src/test/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpointTest.java b/jsonrpc-spring-webmvc/src/test/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpointTest.java index 62b9ff8..ff5ef01 100644 --- a/jsonrpc-spring-webmvc/src/test/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpointTest.java +++ b/jsonrpc-spring-webmvc/src/test/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpointTest.java @@ -88,7 +88,7 @@ void returnsSingleSuccessResponseForRequest() throws Exception { .andReturn(); JsonRpcResponse response = OBJECT_MAPPER.readValue(result.getResponse().getContentAsByteArray(), JsonRpcResponse.class); - assertEquals("pong", response.result().asText()); + assertEquals("pong", response.result().asString()); assertEquals(1, response.id().asInt()); } @@ -130,7 +130,7 @@ void returnsBatchResponseWithoutNotifications() throws Exception { JsonNode response = OBJECT_MAPPER.readTree(result.getResponse().getContentAsByteArray()); assertTrue(response.isArray()); assertEquals(2, response.size()); - assertEquals("pong", response.get(0).get("result").asText()); + assertEquals("pong", response.get(0).get("result").asString()); assertEquals(JsonRpcErrorCode.METHOD_NOT_FOUND, response.get(1).get("error").get("code").asInt()); } diff --git a/samples/pure-java-demo/README.md b/samples/pure-java-demo/README.md new file mode 100644 index 0000000..5c6fdb0 --- /dev/null +++ b/samples/pure-java-demo/README.md @@ -0,0 +1,31 @@ +# pure-java-demo + +Runnable Pure Java JSON-RPC 2.0 sample without Spring. + +## Run + +From repository root: + +```bash +./gradlew -p samples/pure-java-demo run +``` + +## What This Demo Covers + +- Single request success flow +- Notification flow (no response body) +- Mixed batch flow (request + notification + error) +- Parse error flow +- Typed handler registration (`JsonRpcTypedMethodHandlerFactory`) +- Manual handler registration (`dispatcher.register`) +- Request-validator policy switch with `JsonRpcParamsTypeViolationCodePolicy` +- Incoming response-side flow using classifier/parser/validator utilities +- Interceptor lifecycle flow (`beforeValidate`, `beforeInvoke`, `afterInvoke`, `onError`) + +## Key Class + +- `src/main/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplication.java` +- `src/main/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExample.java` +- `src/main/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExample.java` + +The `main` method prints one output payload per scenario so you can follow request -> dispatch -> response flow. diff --git a/samples/pure-java-demo/build.gradle b/samples/pure-java-demo/build.gradle new file mode 100644 index 0000000..2e18fae --- /dev/null +++ b/samples/pure-java-demo/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'application' + id 'java' +} + +group = 'com.limehee.jsonrpc' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +dependencies { + implementation 'io.github.limehee:jsonrpc-core:0.1.2' + implementation 'tools.jackson.core:jackson-databind:3.1.0' + testImplementation 'org.junit.jupiter:junit-jupiter:6.0.3' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.0.3' +} + +application { + mainClass = 'com.limehee.jsonrpc.sample.purejava.PureJavaDemoApplication' +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() +} diff --git a/samples/pure-java-demo/settings.gradle b/samples/pure-java-demo/settings.gradle new file mode 100644 index 0000000..1486c5c --- /dev/null +++ b/samples/pure-java-demo/settings.gradle @@ -0,0 +1,14 @@ +rootProject.name = "jsonrpc-pure-java-demo" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +includeBuild("../..") { + dependencySubstitution { + substitute(module("io.github.limehee:jsonrpc-core")) + .using(project(":jsonrpc-core")) + } +} diff --git a/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExample.java b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExample.java new file mode 100644 index 0000000..7780927 --- /dev/null +++ b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExample.java @@ -0,0 +1,83 @@ +package com.limehee.jsonrpc.sample.purejava; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import com.limehee.jsonrpc.core.DefaultJsonRpcExceptionResolver; +import com.limehee.jsonrpc.core.DefaultJsonRpcMethodInvoker; +import com.limehee.jsonrpc.core.DefaultJsonRpcRequestParser; +import com.limehee.jsonrpc.core.DefaultJsonRpcRequestValidator; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseComposer; +import com.limehee.jsonrpc.core.InMemoryJsonRpcMethodRegistry; +import com.limehee.jsonrpc.core.JsonRpcDispatchResult; +import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcError; +import com.limehee.jsonrpc.core.JsonRpcInterceptor; +import com.limehee.jsonrpc.core.JsonRpcRequest; +import tools.jackson.databind.node.StringNode; + +import java.util.ArrayList; +import java.util.List; + +public final class InterceptorFlowExample { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private InterceptorFlowExample() { + } + + public static Result execute(String rawRequest) throws JacksonException { + List events = new ArrayList<>(); + JsonRpcInterceptor recording = new JsonRpcInterceptor() { + @Override + public void beforeValidate(JsonNode rawRequestNode) { + events.add("beforeValidate"); + } + + @Override + public void beforeInvoke(JsonRpcRequest request) { + events.add("beforeInvoke:" + request.method()); + } + + @Override + public void afterInvoke(JsonRpcRequest request, JsonNode result) { + events.add("afterInvoke:" + result.asString()); + } + + @Override + public void onError(JsonRpcRequest request, Throwable throwable, JsonRpcError mappedError) { + events.add("onError:" + mappedError.code()); + } + }; + JsonRpcInterceptor noisyOnError = new JsonRpcInterceptor() { + @Override + public void onError(JsonRpcRequest request, Throwable throwable, JsonRpcError mappedError) { + throw new IllegalStateException("ignored-on-error-failure"); + } + }; + + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 100, + List.of(recording, noisyOnError) + ); + + dispatcher.register("ping", params -> StringNode.valueOf("pong")); + dispatcher.register("explode", params -> { + throw new RuntimeException("boom"); + }); + + JsonNode payload = OBJECT_MAPPER.readTree(rawRequest); + JsonRpcDispatchResult dispatchResult = dispatcher.dispatch(payload); + return new Result(events, dispatchResult); + } + + public record Result(List events, JsonRpcDispatchResult dispatchResult) { + } +} diff --git a/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplication.java b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplication.java new file mode 100644 index 0000000..aa2d44b --- /dev/null +++ b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplication.java @@ -0,0 +1,109 @@ +package com.limehee.jsonrpc.sample.purejava; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import com.limehee.jsonrpc.core.DefaultJsonRpcExceptionResolver; +import com.limehee.jsonrpc.core.DefaultJsonRpcMethodInvoker; +import com.limehee.jsonrpc.core.DefaultJsonRpcRequestParser; +import com.limehee.jsonrpc.core.DefaultJsonRpcRequestValidator; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseComposer; +import com.limehee.jsonrpc.core.DefaultJsonRpcTypedMethodHandlerFactory; +import com.limehee.jsonrpc.core.DirectJsonRpcNotificationExecutor; +import com.limehee.jsonrpc.core.InMemoryJsonRpcMethodRegistry; +import com.limehee.jsonrpc.core.JacksonJsonRpcParameterBinder; +import com.limehee.jsonrpc.core.JacksonJsonRpcResultWriter; +import com.limehee.jsonrpc.core.JsonRpcDispatchResult; +import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcMethodRegistrationConflictPolicy; +import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; +import com.limehee.jsonrpc.core.JsonRpcTypedMethodHandlerFactory; +import tools.jackson.databind.node.StringNode; + +import java.util.List; + +public final class PureJavaDemoApplication { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private PureJavaDemoApplication() { + } + + public static void main(String[] args) throws JacksonException { + JsonRpcDispatcher dispatcher = createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + + print("single success", handle(dispatcher, """ + {"jsonrpc":"2.0","method":"ping","id":1} + """)); + print("notification", handle(dispatcher, """ + {"jsonrpc":"2.0","method":"ping"} + """)); + print("mixed batch", handle(dispatcher, """ + [ + {"jsonrpc":"2.0","method":"typed.upper","params":{"value":"core"},"id":2}, + {"jsonrpc":"2.0","method":"typed.tags"}, + {"jsonrpc":"2.0","method":"missing","id":3} + ] + """)); + print("parse error", handle(dispatcher, "{")); + + JsonRpcDispatcher strictDispatcher = createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST); + print("strict params shape policy", handle(strictDispatcher, """ + {"jsonrpc":"2.0","method":"typed.upper","params":"invalid-shape","id":9} + """)); + } + + static String handle(JsonRpcDispatcher dispatcher, String rawJson) throws JacksonException { + try { + JsonNode payload = OBJECT_MAPPER.readTree(rawJson); + JsonRpcDispatchResult result = dispatcher.dispatch(payload); + if (!result.hasResponse()) { + return ""; + } + if (result.isBatch()) { + return OBJECT_MAPPER.writeValueAsString(result.responses()); + } + return OBJECT_MAPPER.writeValueAsString(result.singleResponse().orElseThrow()); + } catch (JacksonException ex) { + return OBJECT_MAPPER.writeValueAsString(dispatcher.parseErrorResponse()); + } + } + + static JsonRpcDispatcher createDispatcher(JsonRpcParamsTypeViolationCodePolicy policy) { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(JsonRpcMethodRegistrationConflictPolicy.REJECT), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(policy), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(false), + new DefaultJsonRpcResponseComposer(), + 100, + List.of(), + new DirectJsonRpcNotificationExecutor() + ); + + JsonRpcTypedMethodHandlerFactory typedFactory = new DefaultJsonRpcTypedMethodHandlerFactory( + new JacksonJsonRpcParameterBinder(OBJECT_MAPPER), + new JacksonJsonRpcResultWriter(OBJECT_MAPPER) + ); + + dispatcher.register("ping", params -> StringNode.valueOf("pong")); + dispatcher.register("typed.upper", typedFactory.unary(UpperInput.class, + input -> new UpperOutput(input.value() == null ? "" : input.value().toUpperCase()))); + dispatcher.register("typed.tags", typedFactory.noParams(() -> List.of("alpha", "beta"))); + return dispatcher; + } + + private static void print(String title, String payload) { + System.out.println("[" + title + "]"); + System.out.println(payload.isEmpty() ? "(no response)" : payload); + System.out.println(); + } + + public record UpperInput(String value) { + } + + public record UpperOutput(String value) { + } +} diff --git a/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExample.java b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExample.java new file mode 100644 index 0000000..fd32311 --- /dev/null +++ b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExample.java @@ -0,0 +1,53 @@ +package com.limehee.jsonrpc.sample.purejava; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import com.limehee.jsonrpc.core.DefaultJsonRpcEnvelopeClassifier; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseParser; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseValidator; +import com.limehee.jsonrpc.core.JsonRpcEnvelopeClassifier; +import com.limehee.jsonrpc.core.JsonRpcEnvelopeType; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponseEnvelope; +import com.limehee.jsonrpc.core.JsonRpcResponseParser; +import com.limehee.jsonrpc.core.JsonRpcResponseValidationOptions; +import com.limehee.jsonrpc.core.JsonRpcResponseValidator; + +import java.util.ArrayList; +import java.util.List; + +public final class ResponseSideUtilitiesExample { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private final JsonRpcEnvelopeClassifier classifier; + private final JsonRpcResponseParser parser; + private final JsonRpcResponseValidator validator; + + public ResponseSideUtilitiesExample(JsonRpcResponseValidationOptions options) { + this.classifier = new DefaultJsonRpcEnvelopeClassifier(); + this.parser = new DefaultJsonRpcResponseParser(); + this.validator = new DefaultJsonRpcResponseValidator(options); + } + + public Result inspect(String rawMessage) throws JacksonException { + JsonNode payload = OBJECT_MAPPER.readTree(rawMessage); + JsonRpcEnvelopeType envelopeType = classifier.classify(payload); + if (envelopeType != JsonRpcEnvelopeType.RESPONSE) { + return new Result(envelopeType, List.of()); + } + + JsonRpcIncomingResponseEnvelope envelope = parser.parse(payload); + List validated = new ArrayList<>(envelope.responses().size()); + for (JsonRpcIncomingResponse response : envelope.responses()) { + validator.validate(response); + validated.add(response); + } + return new Result(envelopeType, validated); + } + + public record Result(JsonRpcEnvelopeType envelopeType, List responses) { + } +} diff --git a/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExampleTest.java b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExampleTest.java new file mode 100644 index 0000000..76ccde8 --- /dev/null +++ b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/InterceptorFlowExampleTest.java @@ -0,0 +1,42 @@ +package com.limehee.jsonrpc.sample.purejava; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InterceptorFlowExampleTest { + + @Test + void recordsExpectedInterceptorOrderForSuccess() throws Exception { + InterceptorFlowExample.Result result = InterceptorFlowExample.execute(""" + {"jsonrpc":"2.0","method":"ping","id":1} + """); + + assertEquals(3, result.events().size()); + assertEquals("beforeValidate", result.events().get(0)); + assertEquals("beforeInvoke:ping", result.events().get(1)); + assertEquals("afterInvoke:pong", result.events().get(2)); + assertTrue(result.dispatchResult().hasResponse()); + } + + @Test + void recordsOnErrorAndKeepsResponseWhenHandlerFails() throws Exception { + InterceptorFlowExample.Result result = InterceptorFlowExample.execute(""" + {"jsonrpc":"2.0","method":"explode","id":2} + """); + + assertTrue(result.events().contains("onError:-32603")); + assertEquals(-32603, result.dispatchResult().singleResponse().orElseThrow().error().code()); + } + + @Test + void recordsOnErrorForMethodResolutionFailure() throws Exception { + InterceptorFlowExample.Result result = InterceptorFlowExample.execute(""" + {"jsonrpc":"2.0","method":"missing","id":3} + """); + + assertTrue(result.events().contains("onError:-32601")); + assertEquals(-32601, result.dispatchResult().singleResponse().orElseThrow().error().code()); + } +} diff --git a/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplicationTest.java b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplicationTest.java new file mode 100644 index 0000000..150f04c --- /dev/null +++ b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/PureJavaDemoApplicationTest.java @@ -0,0 +1,83 @@ +package com.limehee.jsonrpc.sample.purejava; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PureJavaDemoApplicationTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + @Test + void returnsExpectedResultForSingleRequest() throws JacksonException { + JsonRpcDispatcher dispatcher = PureJavaDemoApplication.createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + + JsonNode response = parse(PureJavaDemoApplication.handle(dispatcher, """ + {"jsonrpc":"2.0","method":"ping","id":1} + """)); + + assertEquals("pong", response.get("result").asString()); + assertEquals(1, response.get("id").asInt()); + } + + @Test + void returnsNoBodyForNotificationRequest() throws JacksonException { + JsonRpcDispatcher dispatcher = PureJavaDemoApplication.createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + + String responseBody = PureJavaDemoApplication.handle(dispatcher, """ + {"jsonrpc":"2.0","method":"ping"} + """); + + assertTrue(responseBody.isEmpty()); + } + + @Test + void returnsBatchWithOnlyNonNotificationResponses() throws JacksonException { + JsonRpcDispatcher dispatcher = PureJavaDemoApplication.createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + + JsonNode batch = parse(PureJavaDemoApplication.handle(dispatcher, """ + [ + {"jsonrpc":"2.0","method":"typed.upper","params":{"value":"demo"},"id":1}, + {"jsonrpc":"2.0","method":"typed.tags"}, + {"jsonrpc":"2.0","method":"missing","id":2} + ] + """)); + + assertTrue(batch.isArray()); + assertEquals(2, batch.size()); + assertEquals("DEMO", batch.get(0).get("result").get("value").asString()); + assertEquals(-32601, batch.get(1).get("error").get("code").asInt()); + } + + @Test + void returnsParseErrorForInvalidJson() throws JacksonException { + JsonRpcDispatcher dispatcher = PureJavaDemoApplication.createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + + JsonNode response = parse(PureJavaDemoApplication.handle(dispatcher, "{")); + + assertEquals(-32700, response.get("error").get("code").asInt()); + assertTrue(response.get("id").isNull()); + } + + @Test + void appliesConfigurableParamsTypeViolationCodePolicy() throws JacksonException { + JsonRpcDispatcher strict = PureJavaDemoApplication.createDispatcher(JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST); + + JsonNode response = parse(PureJavaDemoApplication.handle(strict, """ + {"jsonrpc":"2.0","method":"typed.upper","params":"invalid-shape","id":9} + """)); + + assertEquals(-32600, response.get("error").get("code").asInt()); + } + + private JsonNode parse(String json) throws JacksonException { + return OBJECT_MAPPER.readTree(json); + } +} diff --git a/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExampleTest.java b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExampleTest.java new file mode 100644 index 0000000..d2efada --- /dev/null +++ b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/ResponseSideUtilitiesExampleTest.java @@ -0,0 +1,72 @@ +package com.limehee.jsonrpc.sample.purejava; + +import com.limehee.jsonrpc.core.JsonRpcEnvelopeType; +import com.limehee.jsonrpc.core.JsonRpcException; +import com.limehee.jsonrpc.core.JsonRpcResponseValidationOptions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResponseSideUtilitiesExampleTest { + + @Test + void classifiesAndValidatesSingleResponse() throws Exception { + ResponseSideUtilitiesExample example = new ResponseSideUtilitiesExample( + JsonRpcResponseValidationOptions.defaults() + ); + + ResponseSideUtilitiesExample.Result result = example.inspect(""" + {"jsonrpc":"2.0","id":1,"result":"pong"} + """); + + assertEquals(JsonRpcEnvelopeType.RESPONSE, result.envelopeType()); + assertEquals(1, result.responses().size()); + assertEquals("pong", result.responses().get(0).result().asString()); + } + + @Test + void classifiesRequestWithoutParsingAsResponse() throws Exception { + ResponseSideUtilitiesExample example = new ResponseSideUtilitiesExample( + JsonRpcResponseValidationOptions.defaults() + ); + + ResponseSideUtilitiesExample.Result result = example.inspect(""" + {"jsonrpc":"2.0","method":"ping","id":1} + """); + + assertEquals(JsonRpcEnvelopeType.REQUEST, result.envelopeType()); + assertTrue(result.responses().isEmpty()); + } + + @Test + void validatesBatchResponses() throws Exception { + ResponseSideUtilitiesExample example = new ResponseSideUtilitiesExample( + JsonRpcResponseValidationOptions.defaults() + ); + + ResponseSideUtilitiesExample.Result result = example.inspect(""" + [ + {"jsonrpc":"2.0","id":1,"result":"one"}, + {"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Method not found"}} + ] + """); + + assertEquals(JsonRpcEnvelopeType.RESPONSE, result.envelopeType()); + assertEquals(2, result.responses().size()); + assertEquals("one", result.responses().get(0).result().asString()); + assertEquals(-32601, result.responses().get(1).error().get("code").asInt()); + } + + @Test + void failsValidationForMalformedErrorObject() { + ResponseSideUtilitiesExample example = new ResponseSideUtilitiesExample( + JsonRpcResponseValidationOptions.defaults() + ); + + assertThrows(JsonRpcException.class, () -> example.inspect(""" + {"jsonrpc":"2.0","id":1,"error":{"code":"bad","message":1}} + """)); + } +} diff --git a/samples/spring-boot-demo/README.md b/samples/spring-boot-demo/README.md index 86edc5a..94edbcf 100644 --- a/samples/spring-boot-demo/README.md +++ b/samples/spring-boot-demo/README.md @@ -1,6 +1,6 @@ # spring-boot-demo -Minimal JSON-RPC 2.0 demo application using `jsonrpc-spring-boot-starter`. +Spring Boot JSON-RPC 2.0 sample using `jsonrpc-spring-boot-starter`. ## Run @@ -10,11 +10,26 @@ From repository root: ./gradlew -p samples/spring-boot-demo bootRun ``` -The app starts on `http://localhost:8080` with endpoint `POST /jsonrpc`. +Endpoint: -## Try Requests +- URL: `http://localhost:8080/jsonrpc` +- method: `POST` +- content type: `application/json` -Ping: +## Request Flow + +```mermaid +flowchart LR + A["HTTP JSON body"] --> B["JsonRpcWebMvcEndpoint"] + B --> C["JsonRpcDispatcher"] + C --> D["Parser + Validator"] + D --> E["Registry + Handler invocation"] + E --> F["Result/Error response"] +``` + +## Scenario Requests + +### 1. Annotation method (`ping`) ```bash curl -s http://localhost:8080/jsonrpc \ @@ -22,7 +37,7 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"ping","id":1}' ``` -Typed object params: +### 2. Single-parameter DTO binding (`greet`) ```bash curl -s http://localhost:8080/jsonrpc \ @@ -30,10 +45,98 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"greet","params":{"name":"developer"},"id":2}' ``` -Named params with `@JsonRpcParam`: +### 3. Named params with `@JsonRpcParam` (`sum`) ```bash curl -s http://localhost:8080/jsonrpc \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","method":"sum","params":{"left":2,"right":3},"id":3}' ``` + +### 4. Positional params (`sum`) + +```bash +curl -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"sum","params":[2,3],"id":4}' +``` + +### 5. Manual registration (`manual.echo`) + +```bash +curl -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"manual.echo","id":5}' +``` + +### 6. Typed registration (`typed.upper`, `typed.tags`) + +```bash +curl -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"typed.upper","params":{"value":"spring"},"id":6}' +``` + +```bash +curl -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"typed.tags","id":7}' +``` + +### 7. Notification (no response body) + +```bash +curl -i -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","method":"ping"}' +``` + +### 8. Mixed batch (success + notification + error) + +```bash +curl -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '[ + {"jsonrpc":"2.0","method":"manual.echo","id":8}, + {"jsonrpc":"2.0","method":"typed.tags"}, + {"jsonrpc":"2.0","method":"missing","id":9} + ]' +``` + +### 9. Parse error + +```bash +curl -s http://localhost:8080/jsonrpc \ + -H 'content-type: application/json' \ + -d '{' +``` + +## Notification Executor Scenarios + +- Configured executor path is covered by + `GreetingRpcServiceNotificationExecutorIntegrationTest`. +- Misconfiguration failure path (missing named executor bean) is covered by + `GreetingRpcServiceNotificationExecutorConfigurationFailureTest`. + +## Registration Conflict Policy Scenarios + +- `REJECT` startup-failure path and `REPLACE` overwrite path are covered by + `GreetingRpcServiceConflictPolicyIntegrationTest`. + +## Exception Resolver and Error Data Scenarios + +- Error-data exposure path (`jsonrpc.include-error-data=true`) is covered by + `GreetingRpcServiceErrorDataExposureIntegrationTest`. +- Custom exception mapping path (`JsonRpcExceptionResolver` override) is covered by + `GreetingRpcServiceCustomExceptionResolverIntegrationTest`. + +## Test Coverage Entry Points + +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceIntegrationTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceScenarioCoverageIntegrationTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceParamsPolicyIntegrationTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorIntegrationTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorConfigurationFailureTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceConflictPolicyIntegrationTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceErrorDataExposureIntegrationTest.java` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomExceptionResolverIntegrationTest.java` diff --git a/samples/spring-boot-demo/src/main/java/com/limehee/jsonrpc/sample/SampleRegistrationConfig.java b/samples/spring-boot-demo/src/main/java/com/limehee/jsonrpc/sample/SampleRegistrationConfig.java new file mode 100644 index 0000000..ef7dc1a --- /dev/null +++ b/samples/spring-boot-demo/src/main/java/com/limehee/jsonrpc/sample/SampleRegistrationConfig.java @@ -0,0 +1,40 @@ +package com.limehee.jsonrpc.sample; + +import com.limehee.jsonrpc.core.JsonRpcMethodRegistration; +import com.limehee.jsonrpc.core.JsonRpcTypedMethodHandlerFactory; +import tools.jackson.databind.node.StringNode; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration(proxyBeanMethods = false) +public class SampleRegistrationConfig { + + @Bean + JsonRpcMethodRegistration manualEchoRegistration() { + return JsonRpcMethodRegistration.of("manual.echo", params -> StringNode.valueOf("echo")); + } + + @Bean + JsonRpcMethodRegistration typedUpperRegistration(JsonRpcTypedMethodHandlerFactory factory) { + return JsonRpcMethodRegistration.of( + "typed.upper", + factory.unary(UpperInput.class, input -> new UpperOutput(input.value().toUpperCase())) + ); + } + + @Bean + JsonRpcMethodRegistration typedTagsRegistration(JsonRpcTypedMethodHandlerFactory factory) { + return JsonRpcMethodRegistration.of( + "typed.tags", + factory.noParams(() -> List.of("alpha", "beta")) + ); + } + + record UpperInput(String value) { + } + + record UpperOutput(String value) { + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceAllowlistIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceAllowlistIntegrationTest.java index 546fa24..254fd4f 100644 --- a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceAllowlistIntegrationTest.java +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceAllowlistIntegrationTest.java @@ -18,7 +18,7 @@ void allowlistPermitsConfiguredMethod() throws Exception { {"jsonrpc":"2.0","method":"ping","id":1} """); - assertEquals("pong", body.get("result").asText()); + assertEquals("pong", body.get("result").asString()); } @Test diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceConflictPolicyIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceConflictPolicyIntegrationTest.java new file mode 100644 index 0000000..364fa87 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceConflictPolicyIntegrationTest.java @@ -0,0 +1,74 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import com.limehee.jsonrpc.core.JsonRpcDispatchResult; +import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcMethodRegistration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.node.StringNode; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class GreetingRpcServiceConflictPolicyIntegrationTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + @Test + void failsStartupWhenConflictPolicyIsReject() { + try { + runContext("REJECT").close(); + fail("Expected startup failure"); + } catch (Exception ex) { + Throwable root = rootCause(ex); + assertTrue(root.getMessage().contains("method is already registered: ping")); + } + } + + @Test + void usesLastRegistrationWhenConflictPolicyIsReplace() throws Exception { + try (ConfigurableApplicationContext context = runContext("REPLACE")) { + JsonRpcDispatcher dispatcher = context.getBean(JsonRpcDispatcher.class); + JsonRpcDispatchResult result = dispatcher.dispatch(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1} + """)); + + assertEquals("pong", result.singleResponse().orElseThrow().result().asString()); + } + } + + private ConfigurableApplicationContext runContext(String conflictPolicy) { + return new SpringApplicationBuilder(ConflictPolicyTestApplication.class) + .properties( + "spring.main.web-application-type=none", + "jsonrpc.method-registration-conflict-policy=" + conflictPolicy + ) + .run(); + } + + private Throwable rootCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + @ComponentScan(basePackageClasses = GreetingRpcService.class) + static class ConflictPolicyTestApplication { + @Bean + JsonRpcMethodRegistration conflictingPingRegistration() { + return JsonRpcMethodRegistration.of("ping", params -> StringNode.valueOf("manual-ping")); + } + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomExceptionResolverIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomExceptionResolverIntegrationTest.java new file mode 100644 index 0000000..98a962d --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomExceptionResolverIntegrationTest.java @@ -0,0 +1,48 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.JsonNode; +import com.limehee.jsonrpc.core.JsonRpcError; +import com.limehee.jsonrpc.core.JsonRpcExceptionResolver; +import com.limehee.jsonrpc.core.JsonRpcMethodRegistration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@Import(GreetingRpcServiceCustomExceptionResolverIntegrationTest.CustomResolverConfig.class) +class GreetingRpcServiceCustomExceptionResolverIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Test + void mapsDomainExceptionWithCustomResolver() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"domain.fail","id":91} + """); + + assertEquals(-32051, body.get("error").get("code").asInt()); + assertEquals("custom-domain-error", body.get("error").get("message").asString()); + } + + @TestConfiguration(proxyBeanMethods = false) + static class CustomResolverConfig { + @Bean + JsonRpcExceptionResolver jsonRpcExceptionResolver() { + return throwable -> { + if (throwable instanceof IllegalArgumentException) { + return JsonRpcError.of(-32051, "custom-domain-error"); + } + return JsonRpcError.of(-32603, "fallback"); + }; + } + + @Bean + JsonRpcMethodRegistration domainFailMethod() { + return JsonRpcMethodRegistration.of("domain.fail", params -> { + throw new IllegalArgumentException("invalid-domain-state"); + }); + } + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomPathIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomPathIntegrationTest.java index 29a3991..864b833 100644 --- a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomPathIntegrationTest.java +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceCustomPathIntegrationTest.java @@ -20,7 +20,7 @@ void servesJsonRpcOnConfiguredPath() throws Exception { {"jsonrpc":"2.0","method":"ping","id":1} """, 200); - assertEquals("pong", body.get("result").asText()); + assertEquals("pong", body.get("result").asString()); } @Test diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDefaultParamsPolicyIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDefaultParamsPolicyIntegrationTest.java new file mode 100644 index 0000000..d781052 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDefaultParamsPolicyIntegrationTest.java @@ -0,0 +1,21 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.JsonNode; +import com.limehee.jsonrpc.core.JsonRpcErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class GreetingRpcServiceDefaultParamsPolicyIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Test + void mapsParamsTypeViolationToInvalidParamsByDefault() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"sum","params":"invalid-shape","id":42} + """); + + assertEquals(JsonRpcErrorCode.INVALID_PARAMS, body.get("error").get("code").asInt()); + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDenylistIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDenylistIntegrationTest.java index e2cbbc8..3cd2f55 100644 --- a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDenylistIntegrationTest.java +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceDenylistIntegrationTest.java @@ -27,6 +27,6 @@ void denylistStillAllowsOtherMethods() throws Exception { {"jsonrpc":"2.0","method":"greet","params":{"name":"developer"},"id":2} """); - assertEquals("hello developer", body.get("result").asText()); + assertEquals("hello developer", body.get("result").asString()); } } diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceErrorDataExposureIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceErrorDataExposureIntegrationTest.java new file mode 100644 index 0000000..e6837c9 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceErrorDataExposureIntegrationTest.java @@ -0,0 +1,38 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.JsonNode; +import com.limehee.jsonrpc.core.JsonRpcException; +import com.limehee.jsonrpc.core.JsonRpcMethodRegistration; +import tools.jackson.databind.node.StringNode; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = "jsonrpc.include-error-data=true") +@Import(GreetingRpcServiceErrorDataExposureIntegrationTest.BoomMethodConfig.class) +class GreetingRpcServiceErrorDataExposureIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Test + void includesErrorDataWhenConfigured() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"boom.with-data","id":81} + """); + + assertEquals(-32011, body.get("error").get("code").asInt()); + assertEquals("sensitive-context", body.get("error").get("data").asString()); + } + + @TestConfiguration(proxyBeanMethods = false) + static class BoomMethodConfig { + @Bean + JsonRpcMethodRegistration boomWithDataMethod() { + return JsonRpcMethodRegistration.of("boom.with-data", params -> { + throw new JsonRpcException(-32011, "domain-with-data", StringNode.valueOf("sensitive-context")); + }); + } + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceExplicitInvalidParamsPolicyIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceExplicitInvalidParamsPolicyIntegrationTest.java new file mode 100644 index 0000000..e610944 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceExplicitInvalidParamsPolicyIntegrationTest.java @@ -0,0 +1,21 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.JsonNode; +import com.limehee.jsonrpc.core.JsonRpcErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = "jsonrpc.validation.request.params-type-violation-code-policy=INVALID_PARAMS") +class GreetingRpcServiceExplicitInvalidParamsPolicyIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Test + void mapsParamsTypeViolationToInvalidParamsWhenConfigured() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"sum","params":"invalid-shape","id":43} + """); + + assertEquals(JsonRpcErrorCode.INVALID_PARAMS, body.get("error").get("code").asInt()); + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceIntegrationTest.java index a79fcad..87f03cb 100644 --- a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceIntegrationTest.java +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceIntegrationTest.java @@ -49,8 +49,8 @@ void registersGreetingRpcServiceBeanAndAnnotatedMethods() throws Exception { {"jsonrpc":"2.0","method":"sum","params":{"left":2,"right":3},"id":3} """); - assertEquals("pong", ping.result().asText()); - assertEquals("hello developer", greet.result().asText()); + assertEquals("pong", ping.result().asString()); + assertEquals("hello developer", greet.result().asString()); assertEquals(5, sum.result().asInt()); } @@ -60,9 +60,9 @@ void returnsExpectedSuccessJsonForPingRequest() throws Exception { {"jsonrpc":"2.0","method":"ping","id":10} """); - assertEquals("2.0", body.get("jsonrpc").asText()); + assertEquals("2.0", body.get("jsonrpc").asString()); assertEquals(10, body.get("id").asInt()); - assertEquals("pong", body.get("result").asText()); + assertEquals("pong", body.get("result").asString()); assertFalse(body.has("error")); } @@ -75,7 +75,7 @@ void bindsObjectAndNamedParamsAndReturnsExpectedJson() throws Exception { {"jsonrpc":"2.0","method":"sum","params":{"left":7,"right":5},"id":12} """); - assertEquals("hello spring", greetBody.get("result").asText()); + assertEquals("hello spring", greetBody.get("result").asString()); assertEquals(12, sumBody.get("result").asInt()); } @@ -85,7 +85,7 @@ void returnsJsonRpcErrorForUnknownMethod() throws Exception { {"jsonrpc":"2.0","method":"unknown","id":99} """); - assertEquals("2.0", body.get("jsonrpc").asText()); + assertEquals("2.0", body.get("jsonrpc").asString()); assertEquals(99, body.get("id").asInt()); assertTrue(body.has("error")); assertEquals(JsonRpcErrorCode.METHOD_NOT_FOUND, body.get("error").get("code").asInt()); @@ -96,7 +96,7 @@ void returnsJsonRpcErrorForUnknownMethod() throws Exception { void returnsParseErrorForMalformedJson() throws Exception { JsonNode body = invokeJsonRpc("/jsonrpc", "{", 200); - assertEquals("2.0", body.get("jsonrpc").asText()); + assertEquals("2.0", body.get("jsonrpc").asString()); assertTrue(body.get("id").isNull()); assertEquals(JsonRpcErrorCode.PARSE_ERROR, body.get("error").get("code").asInt()); } @@ -144,7 +144,7 @@ void returnsBatchResponseForMixedBatch() throws Exception { assertTrue(body.isArray()); assertEquals(3, body.size()); - assertEquals("pong", body.get(0).get("result").asText()); + assertEquals("pong", body.get(0).get("result").asString()); assertEquals(1, body.get(0).get("id").asInt()); assertEquals(JsonRpcErrorCode.METHOD_NOT_FOUND, body.get(1).get("error").get("code").asInt()); assertEquals(2, body.get(1).get("id").asInt()); diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorConfigurationFailureTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorConfigurationFailureTest.java new file mode 100644 index 0000000..3aec2eb --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorConfigurationFailureTest.java @@ -0,0 +1,37 @@ +package com.limehee.jsonrpc.sample; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.builder.SpringApplicationBuilder; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class GreetingRpcServiceNotificationExecutorConfigurationFailureTest { + + @Test + void failsStartupWhenConfiguredNotificationExecutorBeanIsMissing() { + try { + new SpringApplicationBuilder(DemoApplication.class) + .properties( + "spring.main.web-application-type=none", + "jsonrpc.notification-executor-enabled=true", + "jsonrpc.notification-executor-bean-name=missingExecutor" + ) + .run() + .close(); + fail("Expected startup failure"); + } catch (Exception ex) { + Throwable root = rootCause(ex); + assertTrue(root.getMessage().contains( + "jsonrpc.notification-executor-bean-name points to missing Executor bean: missingExecutor")); + } + } + + private Throwable rootCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorIntegrationTest.java new file mode 100644 index 0000000..11f9034 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceNotificationExecutorIntegrationTest.java @@ -0,0 +1,79 @@ +package com.limehee.jsonrpc.sample; + +import com.limehee.jsonrpc.core.JsonRpcMethodRegistration; +import tools.jackson.databind.node.StringNode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "jsonrpc.notification-executor-enabled=true", + "jsonrpc.notification-executor-bean-name=sampleNotificationExecutor" +}) +@Import(GreetingRpcServiceNotificationExecutorIntegrationTest.NotificationExecutorTestConfig.class) +class GreetingRpcServiceNotificationExecutorIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Autowired + private NotificationProbe probe; + + @Test + void usesConfiguredExecutorForNotificationRequests() throws Exception { + mockMvc.perform(post("/jsonrpc") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"jsonrpc":"2.0","method":"notify.mark"} + """)) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + assertTrue(probe.latch.await(1, TimeUnit.SECONDS)); + assertEquals(1, probe.executorDispatchCount.get()); + assertEquals(1, probe.handlerInvocationCount.get()); + } + + static final class NotificationProbe { + private final AtomicInteger executorDispatchCount = new AtomicInteger(); + private final AtomicInteger handlerInvocationCount = new AtomicInteger(); + private final CountDownLatch latch = new CountDownLatch(1); + } + + @TestConfiguration(proxyBeanMethods = false) + static class NotificationExecutorTestConfig { + @Bean("sampleNotificationExecutor") + Executor sampleNotificationExecutor(NotificationProbe probe) { + return command -> { + probe.executorDispatchCount.incrementAndGet(); + command.run(); + }; + } + + @Bean + NotificationProbe notificationProbe() { + return new NotificationProbe(); + } + + @Bean + JsonRpcMethodRegistration notificationMethod(NotificationProbe probe) { + return JsonRpcMethodRegistration.of("notify.mark", params -> { + probe.handlerInvocationCount.incrementAndGet(); + probe.latch.countDown(); + return StringNode.valueOf("ok"); + }); + } + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceParamsPolicyIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceParamsPolicyIntegrationTest.java new file mode 100644 index 0000000..93776c5 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceParamsPolicyIntegrationTest.java @@ -0,0 +1,21 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.JsonNode; +import com.limehee.jsonrpc.core.JsonRpcErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(properties = "jsonrpc.validation.request.params-type-violation-code-policy=INVALID_REQUEST") +class GreetingRpcServiceParamsPolicyIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Test + void mapsParamsTypeViolationToInvalidRequestWhenConfigured() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"sum","params":"invalid-shape","id":41} + """); + + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, body.get("error").get("code").asInt()); + } +} diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceScenarioCoverageIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceScenarioCoverageIntegrationTest.java new file mode 100644 index 0000000..32bf266 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceScenarioCoverageIntegrationTest.java @@ -0,0 +1,57 @@ +package com.limehee.jsonrpc.sample; + +import tools.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +class GreetingRpcServiceScenarioCoverageIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Test + void supportsManualRegistrationScenario() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"manual.echo","id":31} + """); + + assertEquals("echo", body.get("result").asString()); + assertEquals(31, body.get("id").asInt()); + } + + @Test + void supportsTypedRegistrationScenarios() throws Exception { + JsonNode upper = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"typed.upper","params":{"value":"spring"},"id":32} + """); + JsonNode tags = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"typed.tags","id":33} + """); + + assertEquals("SPRING", upper.get("result").get("value").asString()); + assertTrue(tags.get("result").isArray()); + assertEquals("alpha", tags.get("result").get(0).asString()); + assertEquals("beta", tags.get("result").get(1).asString()); + } + + @Test + void supportsPositionalParamsAndMixedBatchFlow() throws Exception { + JsonNode sum = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"sum","params":[4,5],"id":34} + """); + JsonNode batch = invokeJsonRpc(""" + [ + {"jsonrpc":"2.0","method":"manual.echo","id":35}, + {"jsonrpc":"2.0","method":"typed.tags"}, + {"jsonrpc":"2.0","method":"missing","id":36} + ] + """); + + assertEquals(9, sum.get("result").asInt()); + assertTrue(batch.isArray()); + assertEquals(2, batch.size()); + assertEquals("echo", batch.get(0).get("result").asString()); + assertEquals(-32601, batch.get(1).get("error").get("code").asInt()); + } +} diff --git a/scripts/verify-consumer-smoke.sh b/scripts/verify-consumer-smoke.sh index fefbe0e..f5b6439 100755 --- a/scripts/verify-consumer-smoke.sh +++ b/scripts/verify-consumer-smoke.sh @@ -82,7 +82,7 @@ class CoreConsumerSmokeTest { JsonNode payload = mapper.readTree("{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}"); JsonRpcDispatchResult result = dispatcher.dispatch(payload); - assertEquals("pong", result.singleResponse().orElseThrow().result().asText()); + assertEquals("pong", result.singleResponse().orElseThrow().result().asString()); } } EOF @@ -174,7 +174,7 @@ class StarterConsumerSmokeTest { ObjectMapper mapper = JsonMapper.builder().build(); JsonRpcDispatchResult result = dispatcher.dispatch(mapper.readTree( "{\"jsonrpc\":\"2.0\",\"method\":\"greet\",\"params\":{\"name\":\"developer\"},\"id\":1}")); - assertEquals("hello developer", result.singleResponse().orElseThrow().result().asText()); + assertEquals("hello developer", result.singleResponse().orElseThrow().result().asString()); } } EOF