diff --git a/README.md b/README.md index e71fd2e..037463b 100644 --- a/README.md +++ b/README.md @@ -183,11 +183,16 @@ Use `jsonrpc.validation.request.*` and `jsonrpc.validation.response.*` for fine- jsonrpc: validation: request: + require-id-member: false + allow-fractional-id: false + reject-response-fields: true params-type-violation-code-policy: INVALID_REQUEST response: - require-response-id-member: true - allow-fractional-response-id: false - allow-request-fields-in-response: false + require-id-member: true + allow-fractional-id: false + reject-request-fields: true + error-code: + policy: STANDARD_OR_SERVER_ERROR_RANGE ``` For the full list of validation keys and defaults, see diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 6923e72..e53debc 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -12,18 +12,30 @@ All properties are under `jsonrpc.*` and are bound to `JsonRpcProperties`. | `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.require-json-rpc-version-20` | `boolean` | `true` | Require incoming request `jsonrpc` to equal `"2.0"` | +| `jsonrpc.validation.request.require-id-member` | `boolean` | `false` | Require incoming requests to include an `id` member | +| `jsonrpc.validation.request.allow-null-id` | `boolean` | `true` | Allow `id: null` in incoming requests | +| `jsonrpc.validation.request.allow-string-id` | `boolean` | `true` | Allow string IDs in incoming requests | +| `jsonrpc.validation.request.allow-numeric-id` | `boolean` | `true` | Allow numeric IDs in incoming requests | +| `jsonrpc.validation.request.allow-fractional-id` | `boolean` | `true` | Allow fractional numeric IDs in incoming requests | +| `jsonrpc.validation.request.reject-response-fields` | `boolean` | `false` | Reject request objects containing `result`/`error` | +| `jsonrpc.validation.request.reject-duplicate-members` | `boolean` | `false` | Reject duplicate members while parsing raw request JSON | | `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-id-member` | `boolean` | `true` | Require incoming responses to include an `id` member | +| `jsonrpc.validation.response.allow-null-id` | `boolean` | `true` | Allow `id: null` in incoming responses | +| `jsonrpc.validation.response.allow-string-id` | `boolean` | `true` | Allow string IDs in incoming responses | +| `jsonrpc.validation.response.allow-numeric-id` | `boolean` | `true` | Allow numeric IDs in incoming responses | +| `jsonrpc.validation.response.allow-fractional-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.validation.response.reject-request-fields` | `boolean` | `false` | Reject response objects containing `method`/`params` | +| `jsonrpc.validation.response.reject-duplicate-members` | `boolean` | `false` | Reject duplicate members while parsing raw response JSON | +| `jsonrpc.validation.response.error-code.policy` | `JsonRpcResponseErrorCodePolicy` | `ANY_INTEGER` | Accepted integer range policy for response `error.code` | +| `jsonrpc.validation.response.error-code.range.min` | `Integer` | `null` | Inclusive minimum for `CUSTOM_RANGE` | +| `jsonrpc.validation.response.error-code.range.max` | `Integer` | `null` | Inclusive maximum for `CUSTOM_RANGE` | | `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) | @@ -34,6 +46,12 @@ All properties are under `jsonrpc.*` and are bound to `JsonRpcProperties`. | `jsonrpc.notification-executor-enabled` | `boolean` | `false` | Enable executor-backed notification dispatch | | `jsonrpc.notification-executor-bean-name` | `String` | `""` | Preferred executor bean name for notifications | +`JsonRpcResponseErrorCodePolicy` values: +- `ANY_INTEGER` +- `STANDARD_ONLY` +- `STANDARD_OR_SERVER_ERROR_RANGE` +- `CUSTOM_RANGE` + ## 2. Validation Rules (Fail Fast) Startup fails with `IllegalArgumentException` when any of these conditions occur: @@ -52,12 +70,18 @@ Startup fails with `IllegalArgumentException` when any of these conditions occur - `jsonrpc.validation.request` is null - `jsonrpc.validation.request.params-type-violation-code-policy` is null - `jsonrpc.validation.response` is null +- `jsonrpc.validation.response.error-code` is null +- `jsonrpc.validation.response.error-code.policy` is null +- `jsonrpc.validation.response.error-code.range` is null +- `jsonrpc.validation.response.require-integer-error-code=false` with `jsonrpc.validation.response.error-code.policy != ANY_INTEGER` +- `jsonrpc.validation.response.error-code.policy=CUSTOM_RANGE` and either range bound is missing +- `jsonrpc.validation.response.error-code.policy=CUSTOM_RANGE` and `range.min > range.max` - allowlist/denylist list itself is null - allowlist/denylist contains null or blank values ## 3. Runtime Behavior Priority -## 3.1 Method access filtering +### 3.1 Method access filtering Priority: @@ -69,9 +93,10 @@ Rules: - denylist always overrides allowlist - if allowlist is non-empty, methods not in allowlist are denied -- `rpc.*` methods are blocked by registry independently of allow/deny lists +- `rpc.*` methods are blocked by request validation, and also by the default registry independently of allow/deny + lists -## 3.2 Notification executor resolution +### 3.2 Notification executor resolution When `jsonrpc.notification-executor-enabled=true`, resolution order is: @@ -82,7 +107,7 @@ When `jsonrpc.notification-executor-enabled=true`, resolution order is: If a configured bean name is missing, startup fails. -## 3.3 Method registration conflict handling +### 3.3 Method registration conflict handling - `REJECT`: first duplicate fails registration. - `REPLACE`: later registration wins. @@ -108,7 +133,7 @@ Example environment variable mapping: ## 5. Example Configurations -## 5.1 Baseline production profile +### 5.1 Baseline production profile ```yaml jsonrpc: @@ -120,32 +145,49 @@ jsonrpc: include-error-data: false validation: request: + require-json-rpc-version-20: true + require-id-member: false + allow-null-id: true + allow-string-id: true + allow-numeric-id: true + allow-fractional-id: true + reject-response-fields: false + reject-duplicate-members: false 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-id-member: true + allow-null-id: true + allow-string-id: true + allow-numeric-id: true + allow-fractional-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 + reject-request-fields: false + reject-duplicate-members: false + error-code: + policy: ANY_INTEGER + range: + min: null + max: null method-allowlist: [ ] method-denylist: [ ] ``` -## 5.2 Strict access profile +### 5.2 Strict response error-code profile ```yaml jsonrpc: - method-allowlist: [ user.find, user.update ] - method-denylist: [ user.delete ] + validation: + response: + reject-request-fields: true + error-code: + policy: STANDARD_OR_SERVER_ERROR_RANGE ``` -## 5.3 Async notification profile +### 5.3 Async notification profile ```yaml jsonrpc: @@ -153,7 +195,7 @@ jsonrpc: notification-executor-bean-name: applicationTaskExecutor ``` -## 5.4 Metrics-rich profile +### 5.4 Metrics-rich profile ```yaml jsonrpc: @@ -163,7 +205,26 @@ jsonrpc: metrics-max-method-tag-values: 200 ``` -## 6. IDE Auto-completion and Metadata +## 6. Migration Notes (Response Validation Key Rename) + +The old keys below are migration references only. +They are not bound by current auto-configuration and should not be used in new setups. +Use the canonical symmetric keys in the right column. + +| Old key | New key | +|----------------------------------------------------------------|-----------------------------------------------------| +| `jsonrpc.validation.response.require-response-id-member` | `jsonrpc.validation.response.require-id-member` | +| `jsonrpc.validation.response.allow-null-response-id` | `jsonrpc.validation.response.allow-null-id` | +| `jsonrpc.validation.response.allow-string-response-id` | `jsonrpc.validation.response.allow-string-id` | +| `jsonrpc.validation.response.allow-numeric-response-id` | `jsonrpc.validation.response.allow-numeric-id` | +| `jsonrpc.validation.response.allow-fractional-response-id` | `jsonrpc.validation.response.allow-fractional-id` | +| `jsonrpc.validation.response.allow-request-fields-in-response` | `jsonrpc.validation.response.reject-request-fields` | + +Inversion rule: + +- `reject-request-fields = !allow-request-fields-in-response` + +## 7. IDE Auto-completion and Metadata The project ships Spring Boot configuration metadata via: @@ -173,10 +234,14 @@ The project ships Spring Boot configuration metadata via: This enables: - property key completion -- enum value suggestions (`REJECT`, `REPLACE`, `INVALID_PARAMS`, `INVALID_REQUEST`) +- enum value suggestions (`REJECT`, `REPLACE`, `INVALID_PARAMS`, `INVALID_REQUEST`, error-code policy values) +- example numeric suggestions for `jsonrpc.validation.response.error-code.range.min/max` - metadata hints in IntelliJ and Spring-aware tooling -## 7. Related References +Hints are suggestions for IDE completion only. They do not restrict allowed runtime values unless validation rules +explicitly enforce constraints. + +## 8. Related References - Spring setup details: [`spring-boot-guide.md`](spring-boot-guide.md) - Binding and registration details: [`registration-and-binding.md`](registration-and-binding.md) diff --git a/docs/index.md b/docs/index.md index 4ffbc97..8070b87 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,12 @@ - Failure diagnosis and fixes: [`troubleshooting.md`](troubleshooting.md) - Release and publish flow: [`release-checklist.md`](release-checklist.md) -## 5. Repository-Level Docs +## 5. Samples + +- Spring Boot sample: [`../samples/spring-boot-demo/README.md`](../samples/spring-boot-demo/README.md) +- Pure Java sample: [`../samples/pure-java-demo/README.md`](../samples/pure-java-demo/README.md) + +## 6. Repository-Level Docs - Project overview: [`../README.md`](../README.md) - Contribution workflow: [`../CONTRIBUTING.md`](../CONTRIBUTING.md) diff --git a/docs/protocol-and-compliance.md b/docs/protocol-and-compliance.md index 2158c53..62a8deb 100644 --- a/docs/protocol-and-compliance.md +++ b/docs/protocol-and-compliance.md @@ -55,6 +55,9 @@ Implementation constants are in `JsonRpcErrorCode` and messages in `JsonRpcConst - `JsonRpcResponseValidator` - `JsonRpcResponseValidationOptions` +`DefaultJsonRpcResponseParser` can parse from `JsonNode`, `String`, or `byte[]`, and can optionally reject duplicate +members during raw JSON parsing. + These APIs are transport-agnostic and useful for bidirectional channels (for example WebSocket) where request/response envelopes may arrive on the same connection. @@ -79,18 +82,21 @@ This library does not expose predefined strict/lenient modes; policy is controll `JsonRpcResponseValidationOptions` exposes per-rule switches: - `requireJsonRpcVersion20` (default: `true`) -- `requireResponseIdMember` (default: `true`) -- `allowNullResponseId` (default: `true`) -- `allowStringResponseId` (default: `true`) -- `allowNumericResponseId` (default: `true`) -- `allowFractionalResponseId` (default: `true`) +- `requireIdMember` (default: `true`) +- `allowNullId` (default: `true`) +- `allowStringId` (default: `true`) +- `allowNumericId` (default: `true`) +- `allowFractionalId` (default: `true`) - `requireExclusiveResultOrError` (default: `true`) - `requireErrorObjectWhenPresent` (default: `true`) - `requireIntegerErrorCode` (default: `true`) - `requireStringErrorMessage` (default: `true`) -- `allowRequestFieldsInResponse` (default: `true`) +- `rejectRequestFields` (default: `false`) +- `rejectDuplicateMembers` (default: `false`) +- `errorCodePolicy` (default: `ANY_INTEGER`) +- `errorCodeRangeMin` / `errorCodeRangeMax` (default: `null`, used with `CUSTOM_RANGE`) -`allowRequestFieldsInResponse=true` is a compatibility default and is not an RFC MUST rule. +`rejectRequestFields=false` is a compatibility default and is not an RFC MUST rule. ## `id` Handling Details @@ -108,8 +114,9 @@ This library does not expose predefined strict/lenient modes; policy is controll ## Reserved Method Namespace -Methods starting with `rpc.` are rejected at registration (`IllegalArgumentException`) to preserve reserved namespace -semantics. +Methods starting with `rpc.` are treated as invalid requests during request validation (`-32600`). +Default in-memory registration also rejects `rpc.*` method names (`IllegalArgumentException`), so both registration and +dispatch paths preserve reserved namespace semantics. ## HTTP Mapping Notes diff --git a/docs/pure-java-guide.md b/docs/pure-java-guide.md index c3cc9d3..a38a63b 100644 --- a/docs/pure-java-guide.md +++ b/docs/pure-java-guide.md @@ -207,6 +207,7 @@ JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( ``` This keeps protocol behavior while letting you customize policy and implementation. +`maxBatchSize` must be greater than `0` (fail-fast `IllegalArgumentException` otherwise). `JsonRpcParamsTypeViolationCodePolicy` controls which code is used when request `params` is present but not an object/array: diff --git a/docs/spring-boot-guide.md b/docs/spring-boot-guide.md index bc885c7..82f1d3a 100644 --- a/docs/spring-boot-guide.md +++ b/docs/spring-boot-guide.md @@ -10,14 +10,15 @@ Replace `latest-version` with the release you want to use. Maven: ```xml + latest-version - io.github.limehee - jsonrpc-spring-boot-starter - ${jsonrpc.version} +io.github.limehee +jsonrpc-spring-boot-starter +${jsonrpc.version} ``` @@ -133,14 +134,19 @@ import org.springframework.context.annotation.Configuration; @Configuration class TypedRpcConfig { - record UpperIn(String value) {} - record UpperOut(String value) {} + record UpperIn(String value) { + + } + + record UpperOut(String value) { + + } @Bean JsonRpcMethodRegistration typedUpperRegistration(JsonRpcTypedMethodHandlerFactory factory) { return JsonRpcMethodRegistration.of( - "typed.upper", - factory.unary(UpperIn.class, in -> new UpperOut(in.value().toUpperCase())) + "typed.upper", + factory.unary(UpperIn.class, in -> new UpperOut(in.value().toUpperCase())) ); } } @@ -182,12 +188,15 @@ jsonrpc: `params` is mapped as a whole to the single declared parameter type. ```java + @JsonRpcMethod("greet") public String greet(GreetParams params) { return "hello " + params.name(); } -record GreetParams(String name) {} +record GreetParams(String name) { + +} ``` ### 5.2 Multi parameter methods @@ -205,6 +214,7 @@ Named binding name resolution order: Example: ```java + @JsonRpcMethod("sum") public int sum(@JsonRpcParam("left") int left, @JsonRpcParam("right") int right) { return left + right; @@ -214,12 +224,21 @@ public int sum(@JsonRpcParam("left") int left, @JsonRpcParam("right") int right) Request: ```json -{"jsonrpc":"2.0","method":"sum","params":{"left":1,"right":2},"id":1} +{ + "jsonrpc": "2.0", + "method": "sum", + "params": { + "left": 1, + "right": 2 + }, + "id": 1 +} ``` Positional example: ```java + @JsonRpcMethod("sum") public int sum(int left, int right) { return left + right; @@ -229,7 +248,15 @@ public int sum(int left, int right) { Request: ```json -{"jsonrpc":"2.0","method":"sum","params":[1,2],"id":1} +{ + "jsonrpc": "2.0", + "method": "sum", + "params": [ + 1, + 2 + ], + "id": 1 +} ``` ### 5.3 Return mapping @@ -255,6 +282,9 @@ Use configuration to change this behavior: jsonrpc: validation: request: + require-id-member: false + allow-fractional-id: false + reject-response-fields: true params-type-violation-code-policy: INVALID_REQUEST ``` @@ -269,16 +299,21 @@ 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-id-member: true + allow-null-id: true + allow-string-id: true + allow-numeric-id: true + allow-fractional-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 + reject-request-fields: false + error-code: + policy: ANY_INTEGER + range: + min: null + max: null ``` You can override only the options you need. Example: @@ -287,13 +322,16 @@ You can override only the options you need. Example: jsonrpc: validation: response: - allow-fractional-response-id: false - allow-request-fields-in-response: false + allow-fractional-id: false + reject-request-fields: true + error-code: + policy: STANDARD_OR_SERVER_ERROR_RANGE ``` 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. +`JsonRpcResponseValidator` beans, and also a `JsonRpcResponseParser` bean configured with +`jsonrpc.validation.response.reject-duplicate-members`. These components are intended for client/bidirectional +integrations and are not part of the default HTTP request dispatch path. ## 6. Control Scanning Scope @@ -310,8 +348,8 @@ Properties: ```yaml jsonrpc: - method-allowlist: [math.sum, ping] - method-denylist: [admin.reset] + method-allowlist: [ math.sum, ping ] + method-denylist: [ admin.reset ] ``` Rules: @@ -319,7 +357,8 @@ Rules: 1. Empty allowlist means all methods are allowed unless denied. 2. Non-empty allowlist means only listed methods are allowed. 3. Denylist always wins over allowlist. -4. `rpc.*` methods are blocked by registry regardless of allow/deny lists. +4. `rpc.*` methods are blocked by JSON-RPC request validation and by the default registry regardless of + allow/deny lists. ## 8. Notification Execution Strategy @@ -363,7 +402,7 @@ Configuration: jsonrpc: metrics-enabled: true metrics-latency-histogram-enabled: true - metrics-latency-percentiles: [0.9, 0.95, 0.99] + metrics-latency-percentiles: [ 0.9, 0.95, 0.99 ] metrics-max-method-tag-values: 100 ``` @@ -409,11 +448,25 @@ class RpcHttpConfig { @Bean JsonRpcHttpStatusStrategy jsonRpcHttpStatusStrategy() { return new JsonRpcHttpStatusStrategy() { - public HttpStatus statusForSingle(JsonRpcResponse response) { return HttpStatus.OK; } - public HttpStatus statusForBatch(List responses) { return HttpStatus.OK; } - public HttpStatus statusForNotificationOnly() { return HttpStatus.NO_CONTENT; } - public HttpStatus statusForParseError() { return HttpStatus.BAD_REQUEST; } - public HttpStatus statusForRequestTooLarge() { return HttpStatus.PAYLOAD_TOO_LARGE; } + public HttpStatus statusForSingle(JsonRpcResponse response) { + return HttpStatus.OK; + } + + public HttpStatus statusForBatch(List responses) { + return HttpStatus.OK; + } + + public HttpStatus statusForNotificationOnly() { + return HttpStatus.NO_CONTENT; + } + + public HttpStatus statusForParseError() { + return HttpStatus.BAD_REQUEST; + } + + public HttpStatus statusForRequestTooLarge() { + return HttpStatus.PAYLOAD_TOO_LARGE; + } }; } } 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 39de595..b74d74e 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 @@ -31,6 +31,6 @@ public JsonRpcRequest parse(JsonNode node) { JsonNode params = node.get("params"); - return new JsonRpcRequest(jsonrpc, id, method, params, idPresent); + return new JsonRpcRequest(jsonrpc, id, method, params, idPresent, node); } } 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 0ef1823..fcee9bc 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 @@ -1,6 +1,7 @@ package com.limehee.jsonrpc.core; import java.util.Objects; +import org.jspecify.annotations.Nullable; import tools.jackson.databind.JsonNode; /** @@ -8,14 +9,22 @@ */ public class DefaultJsonRpcRequestValidator implements JsonRpcRequestValidator { - private final JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy; + private final JsonRpcRequestValidationOptions options; /** - * Creates a validator using {@link JsonRpcParamsTypeViolationCodePolicy#INVALID_PARAMS} for invalid {@code params} - * type violations. + * Creates a validator with default request-validation options. */ public DefaultJsonRpcRequestValidator() { - this(JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + this(JsonRpcRequestValidationOptions.defaults()); + } + + /** + * Creates a validator with explicit request-validation options. + * + * @param options request-validation options + */ + public DefaultJsonRpcRequestValidator(JsonRpcRequestValidationOptions options) { + this.options = Objects.requireNonNull(options, "options"); } /** @@ -24,14 +33,15 @@ public DefaultJsonRpcRequestValidator() { * @param paramsTypeViolationCodePolicy policy selecting the error code for invalid {@code params} type violations */ public DefaultJsonRpcRequestValidator(JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy) { - this.paramsTypeViolationCodePolicy = Objects.requireNonNull( - paramsTypeViolationCodePolicy, - "paramsTypeViolationCodePolicy" + this( + JsonRpcRequestValidationOptions.builder() + .paramsTypeViolationCodePolicy(paramsTypeViolationCodePolicy) + .build() ); } /** - * Validates protocol version, method presence, id shape, and params type. + * Validates protocol version, method presence, reserved method namespace, id shape, and params type. * * @param request parsed request model * @throws JsonRpcException when request violates JSON-RPC 2.0 constraints @@ -39,26 +49,92 @@ public DefaultJsonRpcRequestValidator(JsonRpcParamsTypeViolationCodePolicy param @Override public void validate(JsonRpcRequest request) { if (request == null) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + throw invalidRequest(); } - if (!JsonRpcConstants.VERSION.equals(request.jsonrpc())) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + + if (options.requireJsonRpcVersion20() && !JsonRpcConstants.VERSION.equals(request.jsonrpc())) { + throw invalidRequest(); } + if (request.method() == null || request.method().isBlank()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + throw invalidRequest(); + } + if (request.method().startsWith(JsonRpcConstants.RESERVED_METHOD_PREFIX)) { + throw invalidRequest(); + } + + if (options.requireIdMember() && !request.idPresent()) { + throw invalidRequest(); } - JsonNode id = request.id(); - if (request.idPresent() && id != null && !id.isNull() && !id.isString() && !id.isNumber()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + if (request.idPresent()) { + validateId(request.id()); + } + + if (options.rejectResponseFields()) { + JsonNode source = request.source(); + if (source != null && (source.has("result") || source.has("error"))) { + throw invalidRequest(); + } } 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); + if (options.paramsTypeViolationCodePolicy() == JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST) { + throw invalidRequest(); + } + throw invalidParams(); + } + } + + /** + * Validates request {@code id} against configured ID rules. + * + * @param id request id node + */ + private void validateId(@Nullable JsonNode id) { + if (id == null || id.isNull()) { + if (!options.allowNullId()) { + throw invalidRequest(); + } + return; + } + + if (id.isString()) { + if (!options.allowStringId()) { + throw invalidRequest(); + } + return; + } + + if (id.isNumber()) { + if (!options.allowNumericId()) { + throw invalidRequest(); + } + if (!options.allowFractionalId() && id.isFloatingPointNumber()) { + throw invalidRequest(); } - throw new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, JsonRpcConstants.MESSAGE_INVALID_PARAMS); + return; } + + throw invalidRequest(); + } + + /** + * Creates a standardized invalid-request exception. + * + * @return invalid-request exception + */ + private JsonRpcException invalidRequest() { + return new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + } + + /** + * Creates a standardized invalid-params exception. + * + * @return invalid-params exception + */ + private JsonRpcException invalidParams() { + return 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 index 78e5e6c..ba7c144 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParser.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParser.java @@ -2,14 +2,87 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; /** * Default parser for incoming JSON-RPC response payloads. */ public class DefaultJsonRpcResponseParser implements JsonRpcResponseParser { + private final JsonRpcPayloadReader payloadReader; + + /** + * Creates a parser with a default ObjectMapper and duplicate-member rejection disabled. + */ + public DefaultJsonRpcResponseParser() { + this(JsonMapper.builder().build(), false); + } + + /** + * Creates a parser with a default ObjectMapper and explicit duplicate-member policy. + * + * @param rejectDuplicateMembers {@code true} to reject duplicate members while parsing raw JSON input + */ + public DefaultJsonRpcResponseParser(boolean rejectDuplicateMembers) { + this(JsonMapper.builder().build(), rejectDuplicateMembers); + } + + /** + * Creates a parser with explicit mapper and duplicate-member policy. + * + * @param objectMapper mapper used to parse raw JSON input + * @param rejectDuplicateMembers {@code true} to reject duplicate members while parsing raw JSON input + */ + public DefaultJsonRpcResponseParser(ObjectMapper objectMapper, boolean rejectDuplicateMembers) { + this.payloadReader = new JsonRpcPayloadReader( + Objects.requireNonNull(objectMapper, "objectMapper"), + rejectDuplicateMembers + ); + } + + /** + * Parses a raw JSON string response payload. + * + * @param payload raw JSON string + * @return parsed incoming response envelope + * @throws JsonRpcException when JSON parsing fails or payload shape is invalid + */ + public JsonRpcIncomingResponseEnvelope parse(String payload) { + if (payload == null) { + throw invalidResponseEnvelope(); + } + try { + JsonNode node = payloadReader.readTree(payload); + return parse(node); + } catch (JacksonException ex) { + throw invalidResponseEnvelope(); + } + } + + /** + * Parses raw JSON bytes response payload. + * + * @param payload raw JSON bytes + * @return parsed incoming response envelope + * @throws JsonRpcException when JSON parsing fails or payload shape is invalid + */ + public JsonRpcIncomingResponseEnvelope parse(byte[] payload) { + if (payload == null) { + throw invalidResponseEnvelope(); + } + try { + JsonNode node = payloadReader.readTree(payload); + return parse(node); + } catch (JacksonException ex) { + throw invalidResponseEnvelope(); + } + } + /** * {@inheritDoc} */ @@ -75,4 +148,5 @@ private JsonRpcIncomingResponse parseObject(JsonNode node) { 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 index d55b1ce..96cf6a3 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidator.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidator.java @@ -33,15 +33,15 @@ public DefaultJsonRpcResponseValidator(JsonRpcResponseValidationOptions options) @Override public void validate(JsonRpcIncomingResponse response) { if (response == null) { - throw invalid("response must not be null"); + throw invalidRequest(); } if (options.requireJsonRpcVersion20() && !JsonRpcConstants.VERSION.equals(response.jsonrpc())) { - throw invalid("response jsonrpc must be \"2.0\""); + throw invalidRequest(); } - if (options.requireResponseIdMember() && !response.idPresent()) { - throw invalid("response id must be present"); + if (options.requireIdMember() && !response.idPresent()) { + throw invalidRequest(); } if (response.idPresent()) { @@ -52,14 +52,14 @@ public void validate(JsonRpcIncomingResponse response) { boolean hasResult = response.resultPresent(); boolean hasError = response.errorPresent(); if (hasResult == hasError) { - throw invalid("response must contain exactly one of result or error"); + throw invalidRequest(); } } - if (!options.allowRequestFieldsInResponse()) { + if (options.rejectRequestFields()) { JsonNode source = response.source(); - if (source == null || source.has("method") || source.has("params")) { - throw invalid("response must not contain request fields method/params"); + if (source != null && (source.has("method") || source.has("params"))) { + throw invalidRequest(); } } @@ -75,30 +75,30 @@ public void validate(JsonRpcIncomingResponse response) { */ private void validateId(@Nullable JsonNode id) { if (id == null || id.isNull()) { - if (!options.allowNullResponseId()) { - throw invalid("response id must not be null"); + if (!options.allowNullId()) { + throw invalidRequest(); } return; } if (id.isString()) { - if (!options.allowStringResponseId()) { - throw invalid("response string id is not allowed"); + if (!options.allowStringId()) { + throw invalidRequest(); } return; } if (id.isNumber()) { - if (!options.allowNumericResponseId()) { - throw invalid("response numeric id is not allowed"); + if (!options.allowNumericId()) { + throw invalidRequest(); } - if (!options.allowFractionalResponseId() && id.isFloatingPointNumber()) { - throw invalid("response fractional numeric id is not allowed"); + if (!options.allowFractionalId() && id.isFloatingPointNumber()) { + throw invalidRequest(); } return; } - throw invalid("response id must be string, number, or null"); + throw invalidRequest(); } /** @@ -108,7 +108,7 @@ private void validateId(@Nullable JsonNode id) { */ private void validateError(@Nullable JsonNode error) { if (options.requireErrorObjectWhenPresent() && (error == null || !error.isObject())) { - throw invalid("response error must be an object"); + throw invalidRequest(); } if (error == null || !error.isObject()) { @@ -118,25 +118,85 @@ private void validateError(@Nullable JsonNode error) { JsonNode code = error.get("code"); if (options.requireIntegerErrorCode()) { if (code == null || !code.isNumber() || code.isFloatingPointNumber()) { - throw invalid("response error.code must be an integer"); + throw invalidRequest(); } } + if (code != null && code.isNumber() && !code.isFloatingPointNumber()) { + validateErrorCodePolicy(code.intValue()); + } JsonNode message = error.get("message"); if (options.requireStringErrorMessage()) { if (message == null || !message.isString()) { - throw invalid("response error.message must be a string"); + throw invalidRequest(); } } } /** - * Creates a standardized invalid-response exception. + * Creates a standardized invalid-request exception. * - * @param message detail message * @return invalid-request exception */ - private JsonRpcException invalid(String message) { - return new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, message); + private JsonRpcException invalidRequest() { + return new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, JsonRpcConstants.MESSAGE_INVALID_REQUEST); + } + + /** + * Validates integer {@code error.code} value against configured range policy. + * + * @param code integer error code + */ + private void validateErrorCodePolicy(int code) { + JsonRpcResponseErrorCodePolicy policy = options.errorCodePolicy(); + if (policy == JsonRpcResponseErrorCodePolicy.ANY_INTEGER) { + return; + } + if (policy == JsonRpcResponseErrorCodePolicy.STANDARD_ONLY) { + if (!isStandardErrorCode(code)) { + throw invalidRequest(); + } + return; + } + if (policy == JsonRpcResponseErrorCodePolicy.STANDARD_OR_SERVER_ERROR_RANGE) { + if (!isStandardErrorCode(code) && !isServerErrorRangeCode(code)) { + throw invalidRequest(); + } + return; + } + if (policy == JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) { + Integer min = options.errorCodeRangeMin(); + Integer max = options.errorCodeRangeMax(); + if (min == null || max == null) { + throw invalidRequest(); + } + if (code < min || code > max) { + throw invalidRequest(); + } + } + } + + /** + * Checks whether a code is one of the JSON-RPC standard error codes. + * + * @param code integer error code + * @return {@code true} when code is standard + */ + private boolean isStandardErrorCode(int code) { + return code == JsonRpcErrorCode.PARSE_ERROR + || code == JsonRpcErrorCode.INVALID_REQUEST + || code == JsonRpcErrorCode.METHOD_NOT_FOUND + || code == JsonRpcErrorCode.INVALID_PARAMS + || code == JsonRpcErrorCode.INTERNAL_ERROR; + } + + /** + * Checks whether a code belongs to the JSON-RPC server-error reserved range. + * + * @param code integer error code + * @return {@code true} when code is within {@code -32099..-32000} + */ + private boolean isServerErrorRangeCode(int code) { + return code >= -32099 && code <= -32000; } } 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 998eca0..82cf0a6 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 @@ -65,6 +65,7 @@ public JsonRpcDispatcher() { * @param exceptionResolver exception resolver * @param responseComposer response composer * @param maxBatchSize maximum number of elements allowed in batch payloads + * @throws IllegalArgumentException if {@code maxBatchSize <= 0} */ public JsonRpcDispatcher( JsonRpcMethodRegistry methodRegistry, @@ -99,6 +100,7 @@ public JsonRpcDispatcher( * @param responseComposer response composer * @param maxBatchSize maximum number of elements allowed in batch payloads * @param interceptors interceptor chain executed around request handling + * @throws IllegalArgumentException if {@code maxBatchSize <= 0} */ public JsonRpcDispatcher( JsonRpcMethodRegistry methodRegistry, @@ -135,6 +137,7 @@ public JsonRpcDispatcher( * @param maxBatchSize maximum number of elements allowed in batch payloads * @param interceptors interceptor chain executed around request handling * @param notificationExecutor executor used for notification invocations + * @throws IllegalArgumentException if {@code maxBatchSize <= 0} */ public JsonRpcDispatcher( JsonRpcMethodRegistry methodRegistry, @@ -153,6 +156,9 @@ public JsonRpcDispatcher( this.methodInvoker = Objects.requireNonNull(methodInvoker, "methodInvoker"); this.exceptionResolver = Objects.requireNonNull(exceptionResolver, "exceptionResolver"); this.responseComposer = Objects.requireNonNull(responseComposer, "responseComposer"); + if (maxBatchSize <= 0) { + throw new IllegalArgumentException("maxBatchSize must be greater than 0"); + } this.maxBatchSize = maxBatchSize; this.interceptors = List.copyOf(Objects.requireNonNull(interceptors, "interceptors")); this.hasInterceptors = !this.interceptors.isEmpty(); 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 index b2dfe03..5943a6f 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponse.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponse.java @@ -6,7 +6,7 @@ /** * Parsed incoming JSON-RPC response model preserving field presence semantics. * - * @param source original response object node + * @param source original response object node; may be {@code null} * @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 @@ -16,7 +16,7 @@ * @param errorPresent whether the response explicitly contained an {@code error} member */ public record JsonRpcIncomingResponse( - JsonNode source, + @Nullable JsonNode source, @Nullable String jsonrpc, @Nullable JsonNode id, boolean idPresent, @@ -26,4 +26,27 @@ public record JsonRpcIncomingResponse( boolean errorPresent ) { + /** + * Creates a response model without the original source 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 JsonRpcIncomingResponse( + @Nullable String jsonrpc, + @Nullable JsonNode id, + boolean idPresent, + @Nullable JsonNode result, + boolean resultPresent, + @Nullable JsonNode error, + boolean errorPresent + ) { + this(null, jsonrpc, id, idPresent, result, resultPresent, error, errorPresent); + } + } diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcPayloadReader.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcPayloadReader.java new file mode 100644 index 0000000..f9e8967 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcPayloadReader.java @@ -0,0 +1,57 @@ +package com.limehee.jsonrpc.core; + +import java.util.Objects; +import tools.jackson.core.JacksonException; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Reads raw JSON payloads with optional duplicate-member rejection. + */ +public final class JsonRpcPayloadReader { + + private final ObjectMapper objectMapper; + private final ObjectMapper strictObjectMapper; + private final boolean rejectDuplicateMembers; + + /** + * Creates a reader bound to a mapper and duplicate-member policy. + * + * @param objectMapper mapper used for JSON parsing + * @param rejectDuplicateMembers {@code true} to reject duplicate object members + */ + public JsonRpcPayloadReader(ObjectMapper objectMapper, boolean rejectDuplicateMembers) { + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + this.strictObjectMapper = objectMapper.rebuild() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build(); + this.rejectDuplicateMembers = rejectDuplicateMembers; + } + + /** + * Reads JSON from text. + * + * @param payload raw JSON text + * @return parsed JSON node + * @throws JacksonException when payload cannot be parsed as JSON + */ + public JsonNode readTree(String payload) throws JacksonException { + return parserMapper().readTree(payload); + } + + /** + * Reads JSON from bytes. + * + * @param payload raw JSON bytes + * @return parsed JSON node + * @throws JacksonException when payload cannot be parsed as JSON + */ + public JsonNode readTree(byte[] payload) throws JacksonException { + return parserMapper().readTree(payload); + } + + private ObjectMapper parserMapper() { + return rejectDuplicateMembers ? strictObjectMapper : objectMapper; + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequest.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequest.java index 209d311..5b3ebac 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequest.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequest.java @@ -11,15 +11,36 @@ * @param method method name; may be {@code null} * @param params raw params payload; may be {@code null} * @param idPresent whether the original payload explicitly contained an {@code id} field + * @param source original request object node; may be {@code null} */ public record JsonRpcRequest( @Nullable String jsonrpc, @Nullable JsonNode id, @Nullable String method, @Nullable JsonNode params, - boolean idPresent + boolean idPresent, + @Nullable JsonNode source ) { + /** + * Creates a request without storing the original source node. + * + * @param jsonrpc protocol version string from payload; may be {@code null} + * @param id request id value; may be {@code null} + * @param method method name; may be {@code null} + * @param params raw params payload; may be {@code null} + * @param idPresent whether the original payload explicitly contained an {@code id} field + */ + public JsonRpcRequest( + @Nullable String jsonrpc, + @Nullable JsonNode id, + @Nullable String method, + @Nullable JsonNode params, + boolean idPresent + ) { + this(jsonrpc, id, method, params, idPresent, null); + } + /** * Determines whether this request is a notification. * diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequestValidationOptions.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequestValidationOptions.java new file mode 100644 index 0000000..3f9e21a --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcRequestValidationOptions.java @@ -0,0 +1,240 @@ +package com.limehee.jsonrpc.core; + +import java.util.Objects; + +/** + * Fine-grained validation switches for incoming JSON-RPC request validation. + */ +public final class JsonRpcRequestValidationOptions { + + private final boolean requireJsonRpcVersion20; + private final boolean requireIdMember; + private final boolean allowNullId; + private final boolean allowStringId; + private final boolean allowNumericId; + private final boolean allowFractionalId; + private final boolean rejectResponseFields; + private final boolean rejectDuplicateMembers; + private final JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy; + + private JsonRpcRequestValidationOptions(Builder builder) { + this.requireJsonRpcVersion20 = builder.requireJsonRpcVersion20; + this.requireIdMember = builder.requireIdMember; + this.allowNullId = builder.allowNullId; + this.allowStringId = builder.allowStringId; + this.allowNumericId = builder.allowNumericId; + this.allowFractionalId = builder.allowFractionalId; + this.rejectResponseFields = builder.rejectResponseFields; + this.rejectDuplicateMembers = builder.rejectDuplicateMembers; + this.paramsTypeViolationCodePolicy = builder.paramsTypeViolationCodePolicy; + } + + /** + * Returns default validation options aligned with JSON-RPC 2.0 request semantics. + * + * @return default request-validation options + */ + public static JsonRpcRequestValidationOptions 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 on requests + */ + public boolean requireIdMember() { + return requireIdMember; + } + + /** + * @return whether {@code id:null} is allowed + */ + public boolean allowNullId() { + return allowNullId; + } + + /** + * @return whether string IDs are allowed + */ + public boolean allowStringId() { + return allowStringId; + } + + /** + * @return whether numeric IDs are allowed + */ + public boolean allowNumericId() { + return allowNumericId; + } + + /** + * @return whether fractional numeric IDs are allowed + */ + public boolean allowFractionalId() { + return allowFractionalId; + } + + /** + * @return whether requests containing response-only fields ({@code result}/{@code error}) are rejected + */ + public boolean rejectResponseFields() { + return rejectResponseFields; + } + + /** + * @return whether duplicate members should be rejected during raw request payload parsing + */ + public boolean rejectDuplicateMembers() { + return rejectDuplicateMembers; + } + + /** + * @return error-code policy used when {@code params} exists but is neither object nor array + */ + public JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy() { + return paramsTypeViolationCodePolicy; + } + + /** + * Builder for request validation options. + */ + public static final class Builder { + + private boolean requireJsonRpcVersion20 = true; + private boolean requireIdMember = false; + private boolean allowNullId = true; + private boolean allowStringId = true; + private boolean allowNumericId = true; + private boolean allowFractionalId = true; + private boolean rejectResponseFields = false; + private boolean rejectDuplicateMembers = false; + private JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy = + JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS; + + 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 requireIdMember(boolean enabled) { + this.requireIdMember = 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 allowNullId(boolean enabled) { + this.allowNullId = enabled; + return this; + } + + /** + * Enables or disables support for textual IDs. + * + * @param enabled {@code true} to accept string IDs + * @return this builder + */ + public Builder allowStringId(boolean enabled) { + this.allowStringId = enabled; + return this; + } + + /** + * Enables or disables support for numeric IDs. + * + * @param enabled {@code true} to accept numeric IDs + * @return this builder + */ + public Builder allowNumericId(boolean enabled) { + this.allowNumericId = enabled; + return this; + } + + /** + * Enables or disables support for fractional numeric IDs. + * + * @param enabled {@code true} to accept fractional numbers (for example {@code 1.5}) + * @return this builder + */ + public Builder allowFractionalId(boolean enabled) { + this.allowFractionalId = enabled; + return this; + } + + /** + * Enables or disables request rejection when response-only fields are present. + * + * @param enabled {@code true} to reject requests containing {@code result}/{@code error} + * @return this builder + */ + public Builder rejectResponseFields(boolean enabled) { + this.rejectResponseFields = enabled; + return this; + } + + /** + * Enables or disables duplicate member rejection while parsing raw request payloads. + * + * @param enabled {@code true} to reject duplicate members + * @return this builder + */ + public Builder rejectDuplicateMembers(boolean enabled) { + this.rejectDuplicateMembers = enabled; + return this; + } + + /** + * Sets the error-code mapping policy used when {@code params} exists but is neither object nor array. + * + * @param policy params-type violation error-code policy + * @return this builder + */ + public Builder paramsTypeViolationCodePolicy(JsonRpcParamsTypeViolationCodePolicy policy) { + this.paramsTypeViolationCodePolicy = Objects.requireNonNull(policy, "policy"); + return this; + } + + /** + * Builds immutable validation options. + * + * @return immutable request validation options + */ + public JsonRpcRequestValidationOptions build() { + return new JsonRpcRequestValidationOptions(this); + } + } +} diff --git a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseErrorCodePolicy.java b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseErrorCodePolicy.java new file mode 100644 index 0000000..00764f9 --- /dev/null +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseErrorCodePolicy.java @@ -0,0 +1,27 @@ +package com.limehee.jsonrpc.core; + +/** + * Policy controlling accepted integer ranges for incoming response {@code error.code}. + */ +public enum JsonRpcResponseErrorCodePolicy { + + /** + * Accept any integer. + */ + ANY_INTEGER, + + /** + * Accept only JSON-RPC standard error codes. + */ + STANDARD_ONLY, + + /** + * Accept standard error codes and server-error reserved range ({@code -32099..-32000}). + */ + STANDARD_OR_SERVER_ERROR_RANGE, + + /** + * Accept only values inside a user-defined custom range. + */ + CUSTOM_RANGE +} 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 index 1e48b2f..ea38b98 100644 --- a/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptions.java +++ b/jsonrpc-core/src/main/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptions.java @@ -1,41 +1,52 @@ package com.limehee.jsonrpc.core; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + /** * 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 requireIdMember; + private final boolean allowNullId; + private final boolean allowStringId; + private final boolean allowNumericId; + private final boolean allowFractionalId; private final boolean requireExclusiveResultOrError; private final boolean requireErrorObjectWhenPresent; private final boolean requireIntegerErrorCode; private final boolean requireStringErrorMessage; - private final boolean allowRequestFieldsInResponse; + private final boolean rejectRequestFields; + private final boolean rejectDuplicateMembers; + private final JsonRpcResponseErrorCodePolicy errorCodePolicy; + private final @Nullable Integer errorCodeRangeMin; + private final @Nullable Integer errorCodeRangeMax; 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.requireIdMember = builder.requireIdMember; + this.allowNullId = builder.allowNullId; + this.allowStringId = builder.allowStringId; + this.allowNumericId = builder.allowNumericId; + this.allowFractionalId = builder.allowFractionalId; this.requireExclusiveResultOrError = builder.requireExclusiveResultOrError; this.requireErrorObjectWhenPresent = builder.requireErrorObjectWhenPresent; this.requireIntegerErrorCode = builder.requireIntegerErrorCode; this.requireStringErrorMessage = builder.requireStringErrorMessage; - this.allowRequestFieldsInResponse = builder.allowRequestFieldsInResponse; + this.rejectRequestFields = builder.rejectRequestFields; + this.rejectDuplicateMembers = builder.rejectDuplicateMembers; + this.errorCodePolicy = builder.errorCodePolicy; + this.errorCodeRangeMin = builder.errorCodeRangeMin; + this.errorCodeRangeMax = builder.errorCodeRangeMax; } /** * 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. + * RFC MUST rules are enabled by default. Optional interoperability-related rules stay permissive unless explicitly + * restricted via builder switches. * * @return default options */ @@ -62,36 +73,36 @@ public boolean requireJsonRpcVersion20() { /** * @return whether the {@code id} member must exist */ - public boolean requireResponseIdMember() { - return requireResponseIdMember; + public boolean requireIdMember() { + return requireIdMember; } /** * @return whether {@code id:null} is allowed */ - public boolean allowNullResponseId() { - return allowNullResponseId; + public boolean allowNullId() { + return allowNullId; } /** * @return whether string IDs are allowed */ - public boolean allowStringResponseId() { - return allowStringResponseId; + public boolean allowStringId() { + return allowStringId; } /** * @return whether numeric IDs are allowed */ - public boolean allowNumericResponseId() { - return allowNumericResponseId; + public boolean allowNumericId() { + return allowNumericId; } /** * @return whether fractional numeric IDs are allowed */ - public boolean allowFractionalResponseId() { - return allowFractionalResponseId; + public boolean allowFractionalId() { + return allowFractionalId; } /** @@ -123,11 +134,38 @@ public boolean 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 + * @return whether response objects containing request-only fields ({@code method}/{@code params}) are rejected + */ + public boolean rejectRequestFields() { + return rejectRequestFields; + } + + /** + * @return whether response duplicate object members should be rejected during raw payload parsing + */ + public boolean rejectDuplicateMembers() { + return rejectDuplicateMembers; + } + + /** + * @return policy restricting accepted {@code error.code} integers */ - public boolean allowRequestFieldsInResponse() { - return allowRequestFieldsInResponse; + public JsonRpcResponseErrorCodePolicy errorCodePolicy() { + return errorCodePolicy; + } + + /** + * @return lower bound for {@code error.code} when {@link #errorCodePolicy()} is {@code CUSTOM_RANGE} + */ + public @Nullable Integer errorCodeRangeMin() { + return errorCodeRangeMin; + } + + /** + * @return upper bound for {@code error.code} when {@link #errorCodePolicy()} is {@code CUSTOM_RANGE} + */ + public @Nullable Integer errorCodeRangeMax() { + return errorCodeRangeMax; } /** @@ -136,16 +174,20 @@ public boolean allowRequestFieldsInResponse() { 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 requireIdMember = true; + private boolean allowNullId = true; + private boolean allowStringId = true; + private boolean allowNumericId = true; + private boolean allowFractionalId = true; private boolean requireExclusiveResultOrError = true; private boolean requireErrorObjectWhenPresent = true; private boolean requireIntegerErrorCode = true; private boolean requireStringErrorMessage = true; - private boolean allowRequestFieldsInResponse = true; + private boolean rejectRequestFields = false; + private boolean rejectDuplicateMembers = false; + private JsonRpcResponseErrorCodePolicy errorCodePolicy = JsonRpcResponseErrorCodePolicy.ANY_INTEGER; + private @Nullable Integer errorCodeRangeMin; + private @Nullable Integer errorCodeRangeMax; private Builder() { } @@ -167,8 +209,8 @@ public Builder requireJsonRpcVersion20(boolean enabled) { * @param enabled {@code true} to require an {@code id} member * @return this builder */ - public Builder requireResponseIdMember(boolean enabled) { - this.requireResponseIdMember = enabled; + public Builder requireIdMember(boolean enabled) { + this.requireIdMember = enabled; return this; } @@ -178,8 +220,8 @@ public Builder requireResponseIdMember(boolean enabled) { * @param enabled {@code true} to accept null IDs * @return this builder */ - public Builder allowNullResponseId(boolean enabled) { - this.allowNullResponseId = enabled; + public Builder allowNullId(boolean enabled) { + this.allowNullId = enabled; return this; } @@ -189,8 +231,8 @@ public Builder allowNullResponseId(boolean enabled) { * @param enabled {@code true} to accept string IDs * @return this builder */ - public Builder allowStringResponseId(boolean enabled) { - this.allowStringResponseId = enabled; + public Builder allowStringId(boolean enabled) { + this.allowStringId = enabled; return this; } @@ -200,8 +242,8 @@ public Builder allowStringResponseId(boolean enabled) { * @param enabled {@code true} to accept numeric IDs * @return this builder */ - public Builder allowNumericResponseId(boolean enabled) { - this.allowNumericResponseId = enabled; + public Builder allowNumericId(boolean enabled) { + this.allowNumericId = enabled; return this; } @@ -211,8 +253,8 @@ public Builder allowNumericResponseId(boolean enabled) { * @param enabled {@code true} to accept fractional numbers (for example {@code 1.5}) * @return this builder */ - public Builder allowFractionalResponseId(boolean enabled) { - this.allowFractionalResponseId = enabled; + public Builder allowFractionalId(boolean enabled) { + this.allowFractionalId = enabled; return this; } @@ -261,13 +303,57 @@ public Builder requireStringErrorMessage(boolean enabled) { } /** - * Enables or disables tolerance for request-only fields in response objects. + * Enables or disables rejection for request-only fields in response objects. + * + * @param enabled {@code true} to reject response objects containing {@code method}/{@code params} + * @return this builder + */ + public Builder rejectRequestFields(boolean enabled) { + this.rejectRequestFields = enabled; + return this; + } + + /** + * Enables or disables duplicate member rejection in response payload parsing. + * + * @param enabled {@code true} to reject duplicate members while parsing raw response JSON + * @return this builder + */ + public Builder rejectDuplicateMembers(boolean enabled) { + this.rejectDuplicateMembers = enabled; + return this; + } + + /** + * Sets the accepted range policy for integer response {@code error.code} values. + * + * @param policy error-code policy + * @return this builder + */ + public Builder errorCodePolicy(JsonRpcResponseErrorCodePolicy policy) { + this.errorCodePolicy = Objects.requireNonNull(policy, "policy"); + return this; + } + + /** + * Sets the lower bound for {@code error.code} when using {@code CUSTOM_RANGE}. + * + * @param min inclusive lower bound + * @return this builder + */ + public Builder errorCodeRangeMin(@Nullable Integer min) { + this.errorCodeRangeMin = min; + return this; + } + + /** + * Sets the upper bound for {@code error.code} when using {@code CUSTOM_RANGE}. * - * @param enabled {@code true} to allow response objects containing {@code method}/{@code params} + * @param max inclusive upper bound * @return this builder */ - public Builder allowRequestFieldsInResponse(boolean enabled) { - this.allowRequestFieldsInResponse = enabled; + public Builder errorCodeRangeMax(@Nullable Integer max) { + this.errorCodeRangeMax = max; return this; } @@ -277,6 +363,22 @@ public Builder allowRequestFieldsInResponse(boolean enabled) { * @return immutable response validation options */ public JsonRpcResponseValidationOptions build() { + if (!requireIntegerErrorCode && errorCodePolicy != JsonRpcResponseErrorCodePolicy.ANY_INTEGER) { + throw new IllegalArgumentException( + "errorCodePolicy requires requireIntegerErrorCode=true when policy is not ANY_INTEGER" + ); + } + if (errorCodePolicy == JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) { + if (errorCodeRangeMin == null || errorCodeRangeMax == null) { + throw new IllegalArgumentException( + "CUSTOM_RANGE requires both errorCodeRangeMin and errorCodeRangeMax" + ); + } + if (errorCodeRangeMin > errorCodeRangeMax) { + throw new IllegalArgumentException( + "errorCodeRangeMin must be less than or equal to errorCodeRangeMax"); + } + } return new JsonRpcResponseValidationOptions(this); } } 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 0375c19..b4ad765 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 @@ -14,6 +14,7 @@ class DefaultJsonRpcRequestValidatorTest { private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + private static final JsonRpcRequestParser REQUEST_PARSER = new DefaultJsonRpcRequestParser(); private final DefaultJsonRpcRequestValidator validator = new DefaultJsonRpcRequestValidator(); @@ -30,6 +31,19 @@ void validateRejectsWrongProtocolVersion() { JsonRpcException ex = assertThrows(JsonRpcException.class, () -> validator.validate(request)); assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + assertEquals(JsonRpcConstants.MESSAGE_INVALID_REQUEST, ex.getMessage()); + } + + @Test + void validateAllowsWrongProtocolVersionWhenDisabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .requireJsonRpcVersion20(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("1.0", IntNode.valueOf(1), "ping", null, true); + + assertDoesNotThrow(() -> custom.validate(request)); } @Test @@ -40,6 +54,14 @@ void validateRejectsMissingMethod() { assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); } + @Test + void validateRejectsReservedMethodNamespace() { + JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(1), "rpc.system", null, true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> validator.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + @Test void validateRejectsInvalidIdType() { JsonRpcRequest request = new JsonRpcRequest( @@ -54,6 +76,133 @@ void validateRejectsInvalidIdType() { assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); } + @Test + void validateRejectsMissingIdWhenConfigured() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .requireIdMember(true) + .build() + ); + + JsonRpcRequest request = new JsonRpcRequest("2.0", null, "ping", null, false); + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateRejectsNullIdWhenDisabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowNullId(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", NullNode.getInstance(), "ping", null, true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateRejectsStringIdWhenDisabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowStringId(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", StringNode.valueOf("abc"), "ping", null, true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateRejectsNumericIdWhenDisabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowNumericId(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(7), "ping", null, true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateRejectsFractionalIdWhenDisabled() throws Exception { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowFractionalId(false) + .build() + ); + JsonRpcRequest request = REQUEST_PARSER.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1.5} + """)); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateAllowsNullIdWhenOptionIsExplicitlyEnabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowNullId(true) + .allowStringId(false) + .allowNumericId(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", NullNode.getInstance(), "ping", null, true); + + assertDoesNotThrow(() -> custom.validate(request)); + } + + @Test + void validateAllowsStringIdWhenOptionIsExplicitlyEnabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowNullId(false) + .allowStringId(true) + .allowNumericId(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", StringNode.valueOf("abc"), "ping", null, true); + + assertDoesNotThrow(() -> custom.validate(request)); + } + + @Test + void validateAllowsNumericIdWhenOptionIsExplicitlyEnabled() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowNullId(false) + .allowStringId(false) + .allowNumericId(true) + .allowFractionalId(false) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(7), "ping", null, true); + + assertDoesNotThrow(() -> custom.validate(request)); + } + + @Test + void validateAllowsFractionalIdWhenOptionIsExplicitlyEnabled() throws Exception { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .allowNullId(false) + .allowStringId(false) + .allowNumericId(true) + .allowFractionalId(true) + .build() + ); + JsonRpcRequest request = REQUEST_PARSER.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1.5} + """)); + + assertDoesNotThrow(() -> custom.validate(request)); + } + @Test void validateRejectsPrimitiveParams() { JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(1), "ping", IntNode.valueOf(3), true); @@ -77,7 +226,15 @@ void validateRejectsPrimitiveParamsAsInvalidRequestWhenPolicyIsConfigured() { void constructorRejectsNullParamsTypeViolationPolicy() { assertThrows( NullPointerException.class, - () -> new DefaultJsonRpcRequestValidator(null) + () -> new DefaultJsonRpcRequestValidator((JsonRpcParamsTypeViolationCodePolicy) null) + ); + } + + @Test + void constructorRejectsNullOptions() { + assertThrows( + NullPointerException.class, + () -> new DefaultJsonRpcRequestValidator((JsonRpcRequestValidationOptions) null) ); } @@ -115,4 +272,39 @@ void validateAllowsNotificationWithoutId() { JsonRpcRequest request = new JsonRpcRequest("2.0", null, "ping", null, false); assertDoesNotThrow(() -> validator.validate(request)); } + + @Test + void validateRejectsRequestContainingResponseFieldsWhenConfigured() throws Exception { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .rejectResponseFields(true) + .build() + ); + JsonRpcRequest request = REQUEST_PARSER.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1,"result":1} + """)); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(request)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateSkipsResponseFieldRejectionWhenSourceIsUnavailable() { + DefaultJsonRpcRequestValidator custom = new DefaultJsonRpcRequestValidator( + JsonRpcRequestValidationOptions.builder() + .rejectResponseFields(true) + .build() + ); + JsonRpcRequest request = new JsonRpcRequest("2.0", IntNode.valueOf(1), "ping", null, true); + + assertDoesNotThrow(() -> custom.validate(request)); + } + + @Test + void validateAllowsRequestContainingResponseFieldsByDefault() throws Exception { + JsonRpcRequest request = REQUEST_PARSER.parse(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1,"result":1} + """)); + assertDoesNotThrow(() -> validator.validate(request)); + } } 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 index 63113b9..13bf65e 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParserTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseParserTest.java @@ -6,7 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -14,7 +16,7 @@ class DefaultJsonRpcResponseParserTest { private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); - private final JsonRpcResponseParser parser = new DefaultJsonRpcResponseParser(); + private final DefaultJsonRpcResponseParser parser = new DefaultJsonRpcResponseParser(); @Test void parseParsesSingleResponseObject() throws Exception { @@ -67,7 +69,7 @@ void parsePreservesFieldPresenceForNullValues() throws Exception { @Test void parseRejectsNullPayload() { - JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse(null)); + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse((JsonNode) null)); assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); } @@ -79,4 +81,89 @@ void parseRejectsPrimitiveOrEmptyArrayOrNonObjectBatchEntries() throws Exception [{"jsonrpc":"2.0","id":1,"result":1}, 2] """))); } + + @Test + void parseStringParsesSingleResponseObject() { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(""" + {"jsonrpc":"2.0","id":1,"result":{"ok":true}} + """); + + assertFalse(envelope.isBatch()); + assertTrue(envelope.singleResponse().isPresent()); + } + + @Test + void parseStringRejectsMalformedJson() { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse("{")); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void parseStringRejectsNullPayload() { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse((String) null)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void parseStringRejectsDuplicateMembersWhenEnabled() { + DefaultJsonRpcResponseParser strictParser = new DefaultJsonRpcResponseParser(true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> strictParser.parse(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":1} + """)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void parseStringAllowsDuplicateMembersWhenDisabled() { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":1} + """); + + JsonRpcIncomingResponse response = envelope.singleResponse().orElseThrow(); + assertEquals(2, response.id().asInt()); + } + + @Test + void parseBytesRejectsMalformedJson() { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse("{".getBytes())); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void parseBytesParsesSingleResponseObject() { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(""" + {"jsonrpc":"2.0","id":1,"result":{"ok":true}} + """.getBytes(StandardCharsets.UTF_8)); + + assertFalse(envelope.isBatch()); + JsonRpcIncomingResponse response = envelope.singleResponse().orElseThrow(); + assertEquals(1, response.id().asInt()); + } + + @Test + void parseBytesRejectsDuplicateMembersWhenEnabled() { + DefaultJsonRpcResponseParser strictParser = new DefaultJsonRpcResponseParser(true); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> strictParser.parse(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":1} + """.getBytes(StandardCharsets.UTF_8))); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void parseBytesAllowsDuplicateMembersWhenDisabled() { + JsonRpcIncomingResponseEnvelope envelope = parser.parse(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":1} + """.getBytes(StandardCharsets.UTF_8)); + + JsonRpcIncomingResponse response = envelope.singleResponse().orElseThrow(); + assertEquals(2, response.id().asInt()); + } + + @Test + void parseBytesRejectsNullPayload() { + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> parser.parse((byte[]) null)); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } } 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 index 77db095..b3557fe 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidatorTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/DefaultJsonRpcResponseValidatorTest.java @@ -42,6 +42,7 @@ void validateRejectsWrongProtocolVersionByDefault() throws Exception { {"jsonrpc":"1.0","id":1,"result":1} """))); assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + assertEquals(JsonRpcConstants.MESSAGE_INVALID_REQUEST, ex.getMessage()); } @Test @@ -69,7 +70,7 @@ void validateRejectsMissingIdByDefault() throws Exception { void validateAllowsMissingIdWhenRuleDisabled() throws Exception { JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( JsonRpcResponseValidationOptions.builder() - .requireResponseIdMember(false) + .requireIdMember(false) .build() ); @@ -89,21 +90,21 @@ void validateRejectsInvalidIdTypes() throws Exception { @Test void validateRespectsIdTypeOptions() throws Exception { JsonRpcResponseValidator noNull = new DefaultJsonRpcResponseValidator( - JsonRpcResponseValidationOptions.builder().allowNullResponseId(false).build() + JsonRpcResponseValidationOptions.builder().allowNullId(false).build() ); assertThrows(JsonRpcException.class, () -> noNull.validate(incoming(""" {"jsonrpc":"2.0","id":null,"result":1} """))); JsonRpcResponseValidator noString = new DefaultJsonRpcResponseValidator( - JsonRpcResponseValidationOptions.builder().allowStringResponseId(false).build() + JsonRpcResponseValidationOptions.builder().allowStringId(false).build() ); assertThrows(JsonRpcException.class, () -> noString.validate(incoming(""" {"jsonrpc":"2.0","id":"x","result":1} """))); JsonRpcResponseValidator noNumeric = new DefaultJsonRpcResponseValidator( - JsonRpcResponseValidationOptions.builder().allowNumericResponseId(false).build() + JsonRpcResponseValidationOptions.builder().allowNumericId(false).build() ); assertThrows(JsonRpcException.class, () -> noNumeric.validate(incoming(""" {"jsonrpc":"2.0","id":1,"result":1} @@ -114,7 +115,7 @@ void validateRespectsIdTypeOptions() throws Exception { void validateRejectsFractionalIdWhenDisabled() throws Exception { JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( JsonRpcResponseValidationOptions.builder() - .allowFractionalResponseId(false) + .allowFractionalId(false) .build() ); @@ -123,6 +124,68 @@ void validateRejectsFractionalIdWhenDisabled() throws Exception { """))); } + @Test + void validateAllowsNullIdWhenOptionIsExplicitlyEnabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .allowNullId(true) + .allowStringId(false) + .allowNumericId(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":null,"result":1} + """))); + } + + @Test + void validateAllowsStringIdWhenOptionIsExplicitlyEnabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .allowNullId(false) + .allowStringId(true) + .allowNumericId(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":"abc","result":1} + """))); + } + + @Test + void validateAllowsNumericIdWhenOptionIsExplicitlyEnabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .allowNullId(false) + .allowStringId(false) + .allowNumericId(true) + .allowFractionalId(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"result":1} + """))); + } + + @Test + void validateAllowsFractionalIdWhenOptionIsExplicitlyEnabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .allowNullId(false) + .allowStringId(false) + .allowNumericId(true) + .allowFractionalId(true) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1.5,"result":1} + """))); + } + @Test void validateRejectsInvalidResultAndErrorCombination() throws Exception { assertThrows(JsonRpcException.class, () -> validator.validate(incoming(""" @@ -210,10 +273,36 @@ void validateAllowsMissingErrorCodeAndMessageWhenRulesAreDisabled() throws Excep } @Test - void validateCanRejectRequestFieldsWhenOptionDisabled() throws Exception { + void validateAllowsNonIntegerErrorCodeWhenIntegerRuleIsDisabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .requireIntegerErrorCode(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":"x","message":"err"}} + """))); + } + + @Test + void validateAllowsNonStringErrorMessageWhenStringRuleIsDisabled() throws Exception { JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( JsonRpcResponseValidationOptions.builder() - .allowRequestFieldsInResponse(false) + .requireStringErrorMessage(false) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":3}} + """))); + } + + @Test + void validateRejectsRequestFieldsWhenOptionEnabled() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .rejectRequestFields(true) .build() ); @@ -222,6 +311,26 @@ void validateCanRejectRequestFieldsWhenOptionDisabled() throws Exception { """))); } + @Test + void validateSkipsRequestFieldRejectionWhenSourceIsUnavailable() { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .rejectRequestFields(true) + .build() + ); + JsonRpcIncomingResponse response = new JsonRpcIncomingResponse( + "2.0", + OBJECT_MAPPER.getNodeFactory().numberNode(1), + true, + OBJECT_MAPPER.getNodeFactory().numberNode(1), + true, + null, + false + ); + + assertDoesNotThrow(() -> custom.validate(response)); + } + @Test void validateAllowsRequestFieldsByDefault() throws Exception { assertDoesNotThrow(() -> validator.validate(incoming(""" @@ -241,6 +350,93 @@ void constructorRejectsNullOptions() { assertThrows(NullPointerException.class, () -> new DefaultJsonRpcResponseValidator(null)); } + @Test + void validateRejectsNonStandardErrorCodeWhenPolicyIsStandardOnly() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.STANDARD_ONLY) + .build() + ); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"server"}} + """))); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateAllowsServerErrorRangeWhenPolicyConfigured() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.STANDARD_OR_SERVER_ERROR_RANGE) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32050,"message":"server"}} + """))); + } + + @Test + void validateAllowsServerErrorRangeBoundariesWhenPolicyConfigured() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.STANDARD_OR_SERVER_ERROR_RANGE) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32099,"message":"server"}} + """))); + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"server"}} + """))); + } + + @Test + void validateRejectsOutsideServerErrorRangeWhenPolicyConfigured() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.STANDARD_OR_SERVER_ERROR_RANGE) + .build() + ); + + assertThrows(JsonRpcException.class, () -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32100,"message":"server"}} + """))); + } + + @Test + void validateRejectsOutOfRangeCustomErrorCode() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) + .errorCodeRangeMin(-45000) + .errorCodeRangeMax(-44000) + .build() + ); + + JsonRpcException ex = assertThrows(JsonRpcException.class, () -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32050,"message":"server"}} + """))); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, ex.getCode()); + } + + @Test + void validateAllowsInRangeCustomErrorCode() throws Exception { + JsonRpcResponseValidator custom = new DefaultJsonRpcResponseValidator( + JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) + .errorCodeRangeMin(-45000) + .errorCodeRangeMax(-44000) + .build() + ); + + assertDoesNotThrow(() -> custom.validate(incoming(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-44500,"message":"custom"}} + """))); + } + 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/JsonRpcDispatcherTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcDispatcherTest.java index c158f1e..63db40b 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 @@ -8,7 +8,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import tools.jackson.databind.JsonNode; @@ -306,6 +309,28 @@ void registeringReservedMethodThrowsByDefault() { () -> dispatcher.register("rpc.system", params -> StringNode.valueOf("ok"))); } + @Test + void dispatchRejectsReservedMethodNamespaceEvenWhenCustomRegistryAllowsIt() throws Exception { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new PermissiveMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 100, + List.of() + ); + dispatcher.register("rpc.system", params -> StringNode.valueOf("ok")); + + JsonRpcDispatchResult result = dispatcher.dispatch(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"rpc.system","id":1} + """)); + + JsonRpcResponse response = result.singleResponse().orElseThrow(); + assertEquals(JsonRpcErrorCode.INVALID_REQUEST, response.error().code()); + } + @Test void legacyDispatchMethodSupportsSingleRequest() { JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); @@ -449,6 +474,64 @@ public void onError(JsonRpcRequest request, Throwable throwable, JsonRpcError ma assertTrue(interceptor.events.contains("onError:-32601")); } + @Test + void beforeValidateRuntimeExceptionIsMappedToInternalError() throws Exception { + JsonRpcInterceptor throwingInterceptor = new JsonRpcInterceptor() { + @Override + public void beforeValidate(JsonNode rawRequest) { + throw new IllegalStateException("beforeValidate boom"); + } + }; + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 100, + List.of(throwingInterceptor) + ); + dispatcher.register("ping", params -> StringNode.valueOf("pong")); + + JsonRpcDispatchResult result = dispatcher.dispatch(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":1} + """)); + + JsonRpcResponse response = result.singleResponse().orElseThrow(); + assertEquals(JsonRpcErrorCode.INTERNAL_ERROR, response.error().code()); + assertEquals(1, response.id().asInt()); + } + + @Test + void afterInvokeRuntimeExceptionIsMappedToInternalError() throws Exception { + JsonRpcInterceptor throwingInterceptor = new JsonRpcInterceptor() { + @Override + public void afterInvoke(JsonRpcRequest request, JsonNode result) { + throw new IllegalStateException("afterInvoke boom"); + } + }; + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 100, + List.of(throwingInterceptor) + ); + dispatcher.register("ping", params -> StringNode.valueOf("pong")); + + JsonRpcDispatchResult result = dispatcher.dispatch(OBJECT_MAPPER.readTree(""" + {"jsonrpc":"2.0","method":"ping","id":2} + """)); + + JsonRpcResponse response = result.singleResponse().orElseThrow(); + assertEquals(JsonRpcErrorCode.INTERNAL_ERROR, response.error().code()); + assertEquals(2, response.id().asInt()); + } + @Test void dispatchRequestPropagatesErrorFromHandler() throws Exception { JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); @@ -485,6 +568,33 @@ void dispatchNotificationPropagatesErrorFromHandler() throws Exception { """))); } + @Test + void constructorRejectsNonPositiveMaxBatchSize() { + assertThrows(IllegalArgumentException.class, () -> new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + 0, + List.of(), + new DirectJsonRpcNotificationExecutor() + )); + + assertThrows(IllegalArgumentException.class, () -> new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(), + new DefaultJsonRpcResponseComposer(), + -1, + List.of(), + new DirectJsonRpcNotificationExecutor() + )); + } + private static final class RecordingInterceptor implements JsonRpcInterceptor { private final List events = new ArrayList<>(); @@ -520,4 +630,19 @@ public void execute(Runnable task) { task.run(); } } + + private static final class PermissiveMethodRegistry implements JsonRpcMethodRegistry { + + private final Map handlers = new HashMap<>(); + + @Override + public void register(String method, JsonRpcMethodHandler handler) { + handlers.put(method, handler); + } + + @Override + public Optional find(String method) { + return Optional.ofNullable(handlers.get(method)); + } + } } diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseTest.java new file mode 100644 index 0000000..4e2b639 --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcIncomingResponseTest.java @@ -0,0 +1,51 @@ +package com.limehee.jsonrpc.core; + +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.assertTrue; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.IntNode; + +class JsonRpcIncomingResponseTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + @Test + void convenienceConstructorCreatesResponseWithoutSourceNode() { + JsonRpcIncomingResponse response = new JsonRpcIncomingResponse( + "2.0", + IntNode.valueOf(1), + true, + IntNode.valueOf(7), + true, + null, + false + ); + + assertNull(response.source()); + assertTrue(response.idPresent()); + assertTrue(response.resultPresent()); + assertFalse(response.errorPresent()); + } + + @Test + void canonicalConstructorPreservesProvidedSourceNode() { + var source = OBJECT_MAPPER.createObjectNode().put("id", 1); + JsonRpcIncomingResponse response = new JsonRpcIncomingResponse( + source, + "2.0", + IntNode.valueOf(1), + true, + IntNode.valueOf(7), + true, + null, + false + ); + + assertEquals(1, response.source().get("id").asInt()); + } +} diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcPayloadReaderTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcPayloadReaderTest.java new file mode 100644 index 0000000..4b2f920 --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcPayloadReaderTest.java @@ -0,0 +1,56 @@ +package com.limehee.jsonrpc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +class JsonRpcPayloadReaderTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + @Test + void readTreeFromStringRejectsDuplicateMembersWhenEnabled() { + JsonRpcPayloadReader reader = new JsonRpcPayloadReader(OBJECT_MAPPER, true); + + assertThrows( + JacksonException.class, + () -> reader.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}") + ); + } + + @Test + void readTreeFromStringAcceptsDuplicateMembersWhenDisabled() throws Exception { + JsonRpcPayloadReader reader = new JsonRpcPayloadReader(OBJECT_MAPPER, false); + + assertEquals(2, reader.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}").get("id").asInt()); + } + + @Test + void readTreeFromBytesRejectsDuplicateMembersWhenEnabled() { + JsonRpcPayloadReader reader = new JsonRpcPayloadReader(OBJECT_MAPPER, true); + + assertThrows( + JacksonException.class, + () -> reader.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}".getBytes(StandardCharsets.UTF_8)) + ); + } + + @Test + void readTreeFromBytesAcceptsDuplicateMembersWhenDisabled() throws Exception { + JsonRpcPayloadReader reader = new JsonRpcPayloadReader(OBJECT_MAPPER, false); + + assertEquals( + 2, + reader.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}".getBytes(StandardCharsets.UTF_8)) + .get("id") + .asInt() + ); + } +} + diff --git a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcRequestValidationOptionsTest.java b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcRequestValidationOptionsTest.java new file mode 100644 index 0000000..80e958d --- /dev/null +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcRequestValidationOptionsTest.java @@ -0,0 +1,58 @@ +package com.limehee.jsonrpc.core; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class JsonRpcRequestValidationOptionsTest { + + @Test + void defaultsAlignWithJsonRpcRequestSemantics() { + JsonRpcRequestValidationOptions options = JsonRpcRequestValidationOptions.defaults(); + + assertTrue(options.requireJsonRpcVersion20()); + assertFalse(options.requireIdMember()); + assertTrue(options.allowNullId()); + assertTrue(options.allowStringId()); + assertTrue(options.allowNumericId()); + assertTrue(options.allowFractionalId()); + assertFalse(options.rejectResponseFields()); + assertFalse(options.rejectDuplicateMembers()); + assertTrue(options.paramsTypeViolationCodePolicy() == JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS); + } + + @Test + void builderAllowsExplicitOverrides() { + JsonRpcRequestValidationOptions options = JsonRpcRequestValidationOptions.builder() + .requireJsonRpcVersion20(false) + .requireIdMember(true) + .allowNullId(false) + .allowStringId(false) + .allowNumericId(false) + .allowFractionalId(false) + .rejectResponseFields(true) + .rejectDuplicateMembers(true) + .paramsTypeViolationCodePolicy(JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST) + .build(); + + assertFalse(options.requireJsonRpcVersion20()); + assertTrue(options.requireIdMember()); + assertFalse(options.allowNullId()); + assertFalse(options.allowStringId()); + assertFalse(options.allowNumericId()); + assertFalse(options.allowFractionalId()); + assertTrue(options.rejectResponseFields()); + assertTrue(options.rejectDuplicateMembers()); + assertTrue(options.paramsTypeViolationCodePolicy() == JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST); + } + + @Test + void builderRejectsNullParamsTypeViolationPolicy() { + assertThrows( + NullPointerException.class, + () -> JsonRpcRequestValidationOptions.builder().paramsTypeViolationCodePolicy(null) + ); + } +} 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 index d88c520..4fe3ed7 100644 --- a/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptionsTest.java +++ b/jsonrpc-core/src/test/java/com/limehee/jsonrpc/core/JsonRpcResponseValidationOptionsTest.java @@ -1,6 +1,7 @@ package com.limehee.jsonrpc.core; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -12,44 +13,95 @@ 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.requireIdMember()); + assertTrue(options.allowNullId()); + assertTrue(options.allowStringId()); + assertTrue(options.allowNumericId()); + assertTrue(options.allowFractionalId()); assertTrue(options.requireExclusiveResultOrError()); assertTrue(options.requireErrorObjectWhenPresent()); assertTrue(options.requireIntegerErrorCode()); assertTrue(options.requireStringErrorMessage()); - assertTrue(options.allowRequestFieldsInResponse()); + assertFalse(options.rejectRequestFields()); + assertFalse(options.rejectDuplicateMembers()); + assertTrue(options.errorCodePolicy() == JsonRpcResponseErrorCodePolicy.ANY_INTEGER); } @Test void builderAllowsOverridingEachFlag() { JsonRpcResponseValidationOptions options = JsonRpcResponseValidationOptions.builder() .requireJsonRpcVersion20(false) - .requireResponseIdMember(false) - .allowNullResponseId(false) - .allowStringResponseId(false) - .allowNumericResponseId(false) - .allowFractionalResponseId(false) + .requireIdMember(false) + .allowNullId(false) + .allowStringId(false) + .allowNumericId(false) + .allowFractionalId(false) .requireExclusiveResultOrError(false) .requireErrorObjectWhenPresent(false) .requireIntegerErrorCode(false) .requireStringErrorMessage(false) - .allowRequestFieldsInResponse(false) + .rejectRequestFields(true) + .rejectDuplicateMembers(true) .build(); assertFalse(options.requireJsonRpcVersion20()); - assertFalse(options.requireResponseIdMember()); - assertFalse(options.allowNullResponseId()); - assertFalse(options.allowStringResponseId()); - assertFalse(options.allowNumericResponseId()); - assertFalse(options.allowFractionalResponseId()); + assertFalse(options.requireIdMember()); + assertFalse(options.allowNullId()); + assertFalse(options.allowStringId()); + assertFalse(options.allowNumericId()); + assertFalse(options.allowFractionalId()); assertFalse(options.requireExclusiveResultOrError()); assertFalse(options.requireErrorObjectWhenPresent()); assertFalse(options.requireIntegerErrorCode()); assertFalse(options.requireStringErrorMessage()); - assertFalse(options.allowRequestFieldsInResponse()); + assertTrue(options.rejectRequestFields()); + assertTrue(options.rejectDuplicateMembers()); + } + + @Test + void builderRejectsIncompatibleErrorCodePolicyWhenIntegerCheckDisabled() { + assertThrows( + IllegalArgumentException.class, + () -> JsonRpcResponseValidationOptions.builder() + .requireIntegerErrorCode(false) + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.STANDARD_ONLY) + .build() + ); + } + + @Test + void builderRejectsIncompleteCustomRange() { + assertThrows( + IllegalArgumentException.class, + () -> JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) + .errorCodeRangeMin(-32603) + .build() + ); + } + + @Test + void builderRejectsInvertedCustomRange() { + assertThrows( + IllegalArgumentException.class, + () -> JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) + .errorCodeRangeMin(-32000) + .errorCodeRangeMax(-32099) + .build() + ); + } + + @Test + void builderAcceptsCustomRangePolicy() { + JsonRpcResponseValidationOptions options = JsonRpcResponseValidationOptions.builder() + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) + .errorCodeRangeMin(-45000) + .errorCodeRangeMax(-44000) + .build(); + + assertTrue(options.errorCodePolicy() == JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE); + assertTrue(options.errorCodeRangeMin() == -45000); + assertTrue(options.errorCodeRangeMax() == -44000); } } 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 aa23515..2f7c56d 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 @@ -5,6 +5,7 @@ import com.limehee.jsonrpc.core.DefaultJsonRpcRequestParser; import com.limehee.jsonrpc.core.DefaultJsonRpcRequestValidator; import com.limehee.jsonrpc.core.DefaultJsonRpcResponseComposer; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseParser; import com.limehee.jsonrpc.core.DefaultJsonRpcResponseValidator; import com.limehee.jsonrpc.core.DefaultJsonRpcTypedMethodHandlerFactory; import com.limehee.jsonrpc.core.DirectJsonRpcNotificationExecutor; @@ -21,8 +22,11 @@ import com.limehee.jsonrpc.core.JsonRpcNotificationExecutor; import com.limehee.jsonrpc.core.JsonRpcParameterBinder; import com.limehee.jsonrpc.core.JsonRpcRequestParser; +import com.limehee.jsonrpc.core.JsonRpcRequestValidationOptions; import com.limehee.jsonrpc.core.JsonRpcRequestValidator; import com.limehee.jsonrpc.core.JsonRpcResponseComposer; +import com.limehee.jsonrpc.core.JsonRpcResponseErrorCodePolicy; +import com.limehee.jsonrpc.core.JsonRpcResponseParser; import com.limehee.jsonrpc.core.JsonRpcResponseValidationOptions; import com.limehee.jsonrpc.core.JsonRpcResponseValidator; import com.limehee.jsonrpc.core.JsonRpcResultWriter; @@ -92,14 +96,14 @@ public JsonRpcRequestParser jsonRpcRequestParser() { } /** - * Creates request validator for JSON-RPC structural checks. + * Creates request-validation options bound from external configuration. * * @param properties bound JSON-RPC properties - * @return request validator + * @return request-validation options */ @Bean @ConditionalOnMissingBean - public JsonRpcRequestValidator jsonRpcRequestValidator(JsonRpcProperties properties) { + public JsonRpcRequestValidationOptions jsonRpcRequestValidationOptions(JsonRpcProperties properties) { JsonRpcProperties.Validation validation = properties.getValidation(); if (validation == null) { throw new IllegalArgumentException("jsonrpc.validation must not be null"); @@ -113,9 +117,29 @@ public JsonRpcRequestValidator jsonRpcRequestValidator(JsonRpcProperties propert "jsonrpc.validation.request.params-type-violation-code-policy must not be null" ); } - return new DefaultJsonRpcRequestValidator( - request.getParamsTypeViolationCodePolicy() - ); + return JsonRpcRequestValidationOptions.builder() + .requireJsonRpcVersion20(request.isRequireJsonRpcVersion20()) + .requireIdMember(request.isRequireIdMember()) + .allowNullId(request.isAllowNullId()) + .allowStringId(request.isAllowStringId()) + .allowNumericId(request.isAllowNumericId()) + .allowFractionalId(request.isAllowFractionalId()) + .rejectResponseFields(request.isRejectResponseFields()) + .rejectDuplicateMembers(request.isRejectDuplicateMembers()) + .paramsTypeViolationCodePolicy(request.getParamsTypeViolationCodePolicy()) + .build(); + } + + /** + * Creates request validator for JSON-RPC structural checks. + * + * @param options request-validation options + * @return request validator + */ + @Bean + @ConditionalOnMissingBean + public JsonRpcRequestValidator jsonRpcRequestValidator(JsonRpcRequestValidationOptions options) { + return new DefaultJsonRpcRequestValidator(options); } /** @@ -135,21 +159,55 @@ public JsonRpcResponseValidationOptions jsonRpcResponseValidationOptions(JsonRpc if (response == null) { throw new IllegalArgumentException("jsonrpc.validation.response must not be null"); } + JsonRpcProperties.Validation.Response.ErrorCode errorCode = response.getErrorCode(); + if (errorCode == null) { + throw new IllegalArgumentException("jsonrpc.validation.response.error-code must not be null"); + } + if (errorCode.getPolicy() == null) { + throw new IllegalArgumentException("jsonrpc.validation.response.error-code.policy must not be null"); + } + JsonRpcProperties.Validation.Response.ErrorCode.Range range = errorCode.getRange(); + if (range == null) { + throw new IllegalArgumentException("jsonrpc.validation.response.error-code.range 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()) + .requireIdMember(response.isRequireIdMember()) + .allowNullId(response.isAllowNullId()) + .allowStringId(response.isAllowStringId()) + .allowNumericId(response.isAllowNumericId()) + .allowFractionalId(response.isAllowFractionalId()) .requireExclusiveResultOrError(response.isRequireExclusiveResultOrError()) .requireErrorObjectWhenPresent(response.isRequireErrorObjectWhenPresent()) .requireIntegerErrorCode(response.isRequireIntegerErrorCode()) .requireStringErrorMessage(response.isRequireStringErrorMessage()) - .allowRequestFieldsInResponse(response.isAllowRequestFieldsInResponse()) + .rejectRequestFields(response.isRejectRequestFields()) + .rejectDuplicateMembers(response.isRejectDuplicateMembers()) + .errorCodePolicy(errorCode.getPolicy()) + .errorCodeRangeMin(range.getMin()) + .errorCodeRangeMax(range.getMax()) .build(); } + /** + * Creates parser for incoming JSON-RPC response envelopes. + * + * @param objectMapperProvider provider for custom or default {@link ObjectMapper} + * @param options response-validation options + * @return response parser + */ + @Bean + @ConditionalOnMissingBean + public JsonRpcResponseParser jsonRpcResponseParser( + ObjectProvider objectMapperProvider, + JsonRpcResponseValidationOptions options + ) { + return new DefaultJsonRpcResponseParser( + objectMapperProvider.getIfAvailable(() -> JsonMapper.builder().build()), + options.rejectDuplicateMembers() + ); + } + /** * Creates response validator for incoming JSON-RPC response envelopes. * @@ -444,11 +502,12 @@ public JsonRpcHttpStatusStrategy jsonRpcHttpStatusStrategy() { /** * Creates JSON-RPC WebMVC endpoint for servlet applications. * - * @param dispatcher dispatcher handling JSON-RPC requests - * @param httpStatusStrategy strategy mapping protocol outcomes to HTTP status codes - * @param objectMapperProvider provider for custom or default {@link ObjectMapper} - * @param webMvcObserver observer for transport-level events - * @param properties bound JSON-RPC properties + * @param dispatcher dispatcher handling JSON-RPC requests + * @param httpStatusStrategy strategy mapping protocol outcomes to HTTP status codes + * @param objectMapperProvider provider for custom or default {@link ObjectMapper} + * @param webMvcObserver observer for transport-level events + * @param requestValidationOptions request-validation options + * @param properties bound JSON-RPC properties * @return WebMVC endpoint bean */ @Bean @@ -461,6 +520,7 @@ public JsonRpcWebMvcEndpoint jsonRpcWebMvcEndpoint( JsonRpcHttpStatusStrategy httpStatusStrategy, ObjectProvider objectMapperProvider, JsonRpcWebMvcObserver webMvcObserver, + JsonRpcRequestValidationOptions requestValidationOptions, JsonRpcProperties properties ) { ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(() -> JsonMapper.builder().build()); @@ -469,7 +529,8 @@ public JsonRpcWebMvcEndpoint jsonRpcWebMvcEndpoint( objectMapper, httpStatusStrategy, properties.getMaxRequestBytes(), - webMvcObserver + webMvcObserver, + requestValidationOptions.rejectDuplicateMembers() ); } @@ -538,6 +599,37 @@ private void validateProperties(JsonRpcProperties properties) { if (properties.getValidation().getResponse() == null) { throw new IllegalArgumentException("jsonrpc.validation.response must not be null"); } + if (properties.getValidation().getResponse().getErrorCode() == null) { + throw new IllegalArgumentException("jsonrpc.validation.response.error-code must not be null"); + } + if (properties.getValidation().getResponse().getErrorCode().getPolicy() == null) { + throw new IllegalArgumentException("jsonrpc.validation.response.error-code.policy must not be null"); + } + if (properties.getValidation().getResponse().getErrorCode().getRange() == null) { + throw new IllegalArgumentException("jsonrpc.validation.response.error-code.range must not be null"); + } + JsonRpcResponseErrorCodePolicy errorCodePolicy = properties.getValidation().getResponse().getErrorCode() + .getPolicy(); + Integer errorCodeMin = properties.getValidation().getResponse().getErrorCode().getRange().getMin(); + Integer errorCodeMax = properties.getValidation().getResponse().getErrorCode().getRange().getMax(); + if (!properties.getValidation().getResponse().isRequireIntegerErrorCode() + && errorCodePolicy != JsonRpcResponseErrorCodePolicy.ANY_INTEGER) { + throw new IllegalArgumentException( + "jsonrpc.validation.response.error-code.policy requires jsonrpc.validation.response.require-integer-error-code=true" + ); + } + if (errorCodePolicy == JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE) { + if (errorCodeMin == null || errorCodeMax == null) { + throw new IllegalArgumentException( + "jsonrpc.validation.response.error-code.range.min and range.max are required for CUSTOM_RANGE" + ); + } + if (errorCodeMin > errorCodeMax) { + throw new IllegalArgumentException( + "jsonrpc.validation.response.error-code.range.min must be less than or equal to range.max" + ); + } + } 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 725e568..01e5ced 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 @@ -2,9 +2,11 @@ import com.limehee.jsonrpc.core.JsonRpcMethodRegistrationConflictPolicy; import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; +import com.limehee.jsonrpc.core.JsonRpcResponseErrorCodePolicy; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import org.jspecify.annotations.Nullable; import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -378,9 +380,161 @@ public void setResponse(Response response) { */ public static final class Request { + private boolean requireJsonRpcVersion20 = true; + private boolean requireIdMember = false; + private boolean allowNullId = true; + private boolean allowStringId = true; + private boolean allowNumericId = true; + private boolean allowFractionalId = true; + private boolean rejectResponseFields = false; + private boolean rejectDuplicateMembers = false; private JsonRpcParamsTypeViolationCodePolicy paramsTypeViolationCodePolicy = JsonRpcParamsTypeViolationCodePolicy.INVALID_PARAMS; + /** + * Indicates whether {@code jsonrpc == "2.0"} is required on incoming requests. + * + * @return {@code true} when version enforcement is enabled + */ + public boolean isRequireJsonRpcVersion20() { + return requireJsonRpcVersion20; + } + + /** + * Sets whether {@code jsonrpc == "2.0"} is required on incoming requests. + * + * @param requireJsonRpcVersion20 {@code true} to enforce version field + */ + public void setRequireJsonRpcVersion20(boolean requireJsonRpcVersion20) { + this.requireJsonRpcVersion20 = requireJsonRpcVersion20; + } + + /** + * Indicates whether incoming requests must include an {@code id} member. + * + * @return {@code true} when request {@code id} member is required + */ + public boolean isRequireIdMember() { + return requireIdMember; + } + + /** + * Sets whether incoming requests must include an {@code id} member. + * + * @param requireIdMember {@code true} to require request {@code id} + */ + public void setRequireIdMember(boolean requireIdMember) { + this.requireIdMember = requireIdMember; + } + + /** + * Indicates whether {@code id: null} is allowed in incoming requests. + * + * @return {@code true} when null IDs are accepted + */ + public boolean isAllowNullId() { + return allowNullId; + } + + /** + * Sets whether {@code id: null} is allowed in incoming requests. + * + * @param allowNullId {@code true} to accept null IDs + */ + public void setAllowNullId(boolean allowNullId) { + this.allowNullId = allowNullId; + } + + /** + * Indicates whether textual request IDs are allowed. + * + * @return {@code true} when string IDs are accepted + */ + public boolean isAllowStringId() { + return allowStringId; + } + + /** + * Sets whether textual request IDs are allowed. + * + * @param allowStringId {@code true} to accept string IDs + */ + public void setAllowStringId(boolean allowStringId) { + this.allowStringId = allowStringId; + } + + /** + * Indicates whether numeric request IDs are allowed. + * + * @return {@code true} when numeric IDs are accepted + */ + public boolean isAllowNumericId() { + return allowNumericId; + } + + /** + * Sets whether numeric request IDs are allowed. + * + * @param allowNumericId {@code true} to accept numeric IDs + */ + public void setAllowNumericId(boolean allowNumericId) { + this.allowNumericId = allowNumericId; + } + + /** + * Indicates whether fractional numeric request IDs are allowed. + * + * @return {@code true} when fractional numeric IDs are accepted + */ + public boolean isAllowFractionalId() { + return allowFractionalId; + } + + /** + * Sets whether fractional numeric request IDs are allowed. + * + * @param allowFractionalId {@code true} to accept fractional numeric IDs + */ + public void setAllowFractionalId(boolean allowFractionalId) { + this.allowFractionalId = allowFractionalId; + } + + /** + * Indicates whether response-only fields ({@code result}/{@code error}) are rejected in request payloads. + * + * @return {@code true} when response fields in requests are rejected + */ + public boolean isRejectResponseFields() { + return rejectResponseFields; + } + + /** + * Sets whether response-only fields ({@code result}/{@code error}) are rejected in request payloads. + * + * @param rejectResponseFields {@code true} to reject response-only fields in requests + */ + public void setRejectResponseFields(boolean rejectResponseFields) { + this.rejectResponseFields = rejectResponseFields; + } + + /** + * Indicates whether duplicate members are rejected in request payload parsing. + * + * @return {@code true} when duplicate members are rejected + */ + public boolean isRejectDuplicateMembers() { + return rejectDuplicateMembers; + } + + /** + * Sets whether duplicate members are rejected in request payload parsing. + * + * @param rejectDuplicateMembers {@code true} to reject duplicate members + */ + public void setRejectDuplicateMembers(boolean rejectDuplicateMembers) { + this.rejectDuplicateMembers = rejectDuplicateMembers; + } + /** * Returns the error-code mapping policy used when request {@code params} exists but is neither an object * nor an array. @@ -413,16 +567,18 @@ public void setParamsTypeViolationCodePolicy( 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 requireIdMember = true; + private boolean allowNullId = true; + private boolean allowStringId = true; + private boolean allowNumericId = true; + private boolean allowFractionalId = true; private boolean requireExclusiveResultOrError = true; private boolean requireErrorObjectWhenPresent = true; private boolean requireIntegerErrorCode = true; private boolean requireStringErrorMessage = true; - private boolean allowRequestFieldsInResponse = true; + private boolean rejectRequestFields = false; + private boolean rejectDuplicateMembers = false; + private ErrorCode errorCode = new ErrorCode(); /** * Indicates whether {@code jsonrpc == "2.0"} is required on incoming responses. @@ -447,17 +603,17 @@ public void setRequireJsonRpcVersion20(boolean requireJsonRpcVersion20) { * * @return {@code true} when response {@code id} member is required */ - public boolean isRequireResponseIdMember() { - return requireResponseIdMember; + public boolean isRequireIdMember() { + return requireIdMember; } /** * Sets whether incoming responses must include an {@code id} member. * - * @param requireResponseIdMember {@code true} to require response {@code id} + * @param requireIdMember {@code true} to require response {@code id} */ - public void setRequireResponseIdMember(boolean requireResponseIdMember) { - this.requireResponseIdMember = requireResponseIdMember; + public void setRequireIdMember(boolean requireIdMember) { + this.requireIdMember = requireIdMember; } /** @@ -465,17 +621,17 @@ public void setRequireResponseIdMember(boolean requireResponseIdMember) { * * @return {@code true} when null IDs are accepted */ - public boolean isAllowNullResponseId() { - return allowNullResponseId; + public boolean isAllowNullId() { + return allowNullId; } /** * Sets whether {@code id: null} is allowed in incoming responses. * - * @param allowNullResponseId {@code true} to accept null IDs + * @param allowNullId {@code true} to accept null IDs */ - public void setAllowNullResponseId(boolean allowNullResponseId) { - this.allowNullResponseId = allowNullResponseId; + public void setAllowNullId(boolean allowNullId) { + this.allowNullId = allowNullId; } /** @@ -483,17 +639,17 @@ public void setAllowNullResponseId(boolean allowNullResponseId) { * * @return {@code true} when string IDs are accepted */ - public boolean isAllowStringResponseId() { - return allowStringResponseId; + public boolean isAllowStringId() { + return allowStringId; } /** * Sets whether textual response IDs are allowed. * - * @param allowStringResponseId {@code true} to accept string IDs + * @param allowStringId {@code true} to accept string IDs */ - public void setAllowStringResponseId(boolean allowStringResponseId) { - this.allowStringResponseId = allowStringResponseId; + public void setAllowStringId(boolean allowStringId) { + this.allowStringId = allowStringId; } /** @@ -501,17 +657,17 @@ public void setAllowStringResponseId(boolean allowStringResponseId) { * * @return {@code true} when numeric IDs are accepted */ - public boolean isAllowNumericResponseId() { - return allowNumericResponseId; + public boolean isAllowNumericId() { + return allowNumericId; } /** * Sets whether numeric response IDs are allowed. * - * @param allowNumericResponseId {@code true} to accept numeric IDs + * @param allowNumericId {@code true} to accept numeric IDs */ - public void setAllowNumericResponseId(boolean allowNumericResponseId) { - this.allowNumericResponseId = allowNumericResponseId; + public void setAllowNumericId(boolean allowNumericId) { + this.allowNumericId = allowNumericId; } /** @@ -519,17 +675,17 @@ public void setAllowNumericResponseId(boolean allowNumericResponseId) { * * @return {@code true} when fractional numeric IDs are accepted */ - public boolean isAllowFractionalResponseId() { - return allowFractionalResponseId; + public boolean isAllowFractionalId() { + return allowFractionalId; } /** * Sets whether fractional numeric response IDs are allowed. * - * @param allowFractionalResponseId {@code true} to accept fractional numeric IDs + * @param allowFractionalId {@code true} to accept fractional numeric IDs */ - public void setAllowFractionalResponseId(boolean allowFractionalResponseId) { - this.allowFractionalResponseId = allowFractionalResponseId; + public void setAllowFractionalId(boolean allowFractionalId) { + this.allowFractionalId = allowFractionalId; } /** @@ -605,22 +761,148 @@ public void setRequireStringErrorMessage(boolean requireStringErrorMessage) { } /** - * Indicates whether request-only fields like {@code method}/{@code params} are allowed in response + * Indicates whether request-only fields like {@code method}/{@code params} are rejected in response * objects. * - * @return {@code true} when request fields are tolerated in responses + * @return {@code true} when request fields are rejected in responses + */ + public boolean isRejectRequestFields() { + return rejectRequestFields; + } + + /** + * Sets whether request-only fields like {@code method}/{@code params} are rejected in response objects. + * + * @param rejectRequestFields {@code true} to reject request fields in response + */ + public void setRejectRequestFields(boolean rejectRequestFields) { + this.rejectRequestFields = rejectRequestFields; + } + + /** + * Indicates whether duplicate members are rejected in response payload parsing. + * + * @return {@code true} when duplicate members are rejected */ - public boolean isAllowRequestFieldsInResponse() { - return allowRequestFieldsInResponse; + public boolean isRejectDuplicateMembers() { + return rejectDuplicateMembers; } /** - * Sets whether request-only fields like {@code method}/{@code params} are allowed in response objects. + * Sets whether duplicate members are rejected in response payload parsing. * - * @param allowRequestFieldsInResponse {@code true} to allow request fields in response + * @param rejectDuplicateMembers {@code true} to reject duplicate members + */ + public void setRejectDuplicateMembers(boolean rejectDuplicateMembers) { + this.rejectDuplicateMembers = rejectDuplicateMembers; + } + + /** + * Returns response error-code validation settings under {@code jsonrpc.validation.response.error-code.*}. + * + * @return response error-code settings + */ + public ErrorCode getErrorCode() { + return errorCode; + } + + /** + * Sets response error-code validation settings under {@code jsonrpc.validation.response.error-code.*}. + * + * @param errorCode response error-code settings; must not be {@code null} + */ + public void setErrorCode(ErrorCode errorCode) { + this.errorCode = Objects.requireNonNull(errorCode, "errorCode"); + } + + /** + * Response error-code validation settings. */ - public void setAllowRequestFieldsInResponse(boolean allowRequestFieldsInResponse) { - this.allowRequestFieldsInResponse = allowRequestFieldsInResponse; + public static final class ErrorCode { + + private JsonRpcResponseErrorCodePolicy policy = JsonRpcResponseErrorCodePolicy.ANY_INTEGER; + private Range range = new Range(); + + /** + * Returns accepted error-code policy. + * + * @return error-code policy + */ + public JsonRpcResponseErrorCodePolicy getPolicy() { + return policy; + } + + /** + * Sets accepted error-code policy. + * + * @param policy error-code policy + */ + public void setPolicy(JsonRpcResponseErrorCodePolicy policy) { + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** + * Returns custom range configuration for {@code CUSTOM_RANGE} policy. + * + * @return custom range configuration + */ + public Range getRange() { + return range; + } + + /** + * Sets custom range configuration for {@code CUSTOM_RANGE} policy. + * + * @param range custom range configuration; must not be {@code null} + */ + public void setRange(Range range) { + this.range = Objects.requireNonNull(range, "range"); + } + + /** + * Range configuration for custom response error-code policy. + */ + public static final class Range { + + private @Nullable Integer min; + private @Nullable Integer max; + + /** + * Returns inclusive minimum of allowed error-code range. + * + * @return inclusive minimum, or {@code null} when unspecified + */ + public @Nullable Integer getMin() { + return min; + } + + /** + * Sets inclusive minimum of allowed error-code range. + * + * @param min inclusive minimum, or {@code null} when unspecified + */ + public void setMin(@Nullable Integer min) { + this.min = min; + } + + /** + * Returns inclusive maximum of allowed error-code range. + * + * @return inclusive maximum, or {@code null} when unspecified + */ + public @Nullable Integer getMax() { + return max; + } + + /** + * Sets inclusive maximum of allowed error-code range. + * + * @param max inclusive maximum, or {@code null} when unspecified + */ + public void setMax(@Nullable Integer max) { + this.max = max; + } + } } } } diff --git a/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/support/JsonRpcMetricsInterceptor.java b/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/support/JsonRpcMetricsInterceptor.java index 1625343..d0bd11c 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/support/JsonRpcMetricsInterceptor.java +++ b/jsonrpc-spring-boot-autoconfigure/src/main/java/com/limehee/jsonrpc/spring/boot/autoconfigure/support/JsonRpcMetricsInterceptor.java @@ -28,7 +28,8 @@ * *

* Method tag cardinality can be bounded by {@code maxMethodTagValues}; once the limit is reached, - * unseen methods are collapsed into the {@code other} bucket. + * unseen methods are collapsed into the {@code other} bucket. This limit is treated as a hard cap even when + * requests are processed concurrently. *

*/ public final class JsonRpcMetricsInterceptor implements JsonRpcInterceptor { @@ -44,6 +45,7 @@ public final class JsonRpcMetricsInterceptor implements JsonRpcInterceptor { private final boolean latencyHistogramEnabled; private final double[] latencyPercentiles; private final int maxMethodTagValues; + private final Object methodTagLock = new Object(); private final Set seenMethods = ConcurrentHashMap.newKeySet(); private final ConcurrentHashMap callCounters = new ConcurrentHashMap<>(); private final ConcurrentHashMap stageCounters = new ConcurrentHashMap<>(); @@ -67,7 +69,8 @@ public JsonRpcMetricsInterceptor(MeterRegistry meterRegistry) { * @param latencyHistogramEnabled whether latency histogram buckets should be emitted * @param latencyPercentiles latency percentiles to publish for timers * @param maxMethodTagValues maximum number of distinct method tag values before collapsing to {@code other}; - * values less than or equal to zero disable collapsing + * must be greater than {@code 0} + * @throws IllegalArgumentException if {@code maxMethodTagValues <= 0} */ public JsonRpcMetricsInterceptor( MeterRegistry meterRegistry, @@ -78,6 +81,9 @@ public JsonRpcMetricsInterceptor( this.meterRegistry = Objects.requireNonNull(meterRegistry, "meterRegistry"); this.latencyHistogramEnabled = latencyHistogramEnabled; this.latencyPercentiles = Objects.requireNonNull(latencyPercentiles, "latencyPercentiles").clone(); + if (maxMethodTagValues <= 0) { + throw new IllegalArgumentException("maxMethodTagValues must be greater than 0"); + } this.maxMethodTagValues = maxMethodTagValues; } @@ -259,25 +265,26 @@ private Counter counter( * Applies method tag normalization and cardinality limiting. * * @param method raw method name from request - * @return normalized method tag value + * @return normalized method tag value, with strict cap enforcement for distinct method tags */ private String normalizeMethodName(@Nullable String method) { if (method == null || method.isBlank()) { return METHOD_UNKNOWN; } - if (maxMethodTagValues <= 0) { - return method; - } if (seenMethods.contains(method)) { return method; } - if (seenMethods.size() >= maxMethodTagValues) { - return METHOD_OTHER; - } - if (seenMethods.add(method)) { + + synchronized (methodTagLock) { + if (seenMethods.contains(method)) { + return method; + } + if (seenMethods.size() >= maxMethodTagValues) { + return METHOD_OTHER; + } + seenMethods.add(method); return method; } - return method; } /** 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 8c0def4..6b5fb0e 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 @@ -51,6 +51,7 @@ { "name": "jsonrpc.metrics-latency-percentiles", "type": "java.util.List", + "defaultValue": [], "description": "Optional latency percentiles to publish (each value must be between 0.0 and 1.0, exclusive)." }, { @@ -77,6 +78,54 @@ "defaultValue": "REJECT", "description": "Policy used when a method name is registered more than once." }, + { + "name": "jsonrpc.validation.request.require-json-rpc-version-20", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Require incoming request jsonrpc to equal \"2.0\"." + }, + { + "name": "jsonrpc.validation.request.require-id-member", + "type": "java.lang.Boolean", + "defaultValue": false, + "description": "Require incoming requests to include an id member." + }, + { + "name": "jsonrpc.validation.request.allow-null-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow id:null in incoming requests." + }, + { + "name": "jsonrpc.validation.request.allow-string-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow string ids in incoming requests." + }, + { + "name": "jsonrpc.validation.request.allow-numeric-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow numeric ids in incoming requests." + }, + { + "name": "jsonrpc.validation.request.allow-fractional-id", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Allow fractional numeric ids in incoming requests." + }, + { + "name": "jsonrpc.validation.request.reject-response-fields", + "type": "java.lang.Boolean", + "defaultValue": false, + "description": "Reject request objects containing response-only fields (result/error)." + }, + { + "name": "jsonrpc.validation.request.reject-duplicate-members", + "type": "java.lang.Boolean", + "defaultValue": false, + "description": "Reject duplicate members when parsing raw JSON request payloads." + }, { "name": "jsonrpc.validation.request.params-type-violation-code-policy", "type": "com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy", @@ -90,31 +139,31 @@ "description": "Require incoming response jsonrpc to equal \"2.0\"." }, { - "name": "jsonrpc.validation.response.require-response-id-member", + "name": "jsonrpc.validation.response.require-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", + "name": "jsonrpc.validation.response.allow-null-id", "type": "java.lang.Boolean", "defaultValue": true, "description": "Allow id:null in incoming responses." }, { - "name": "jsonrpc.validation.response.allow-string-response-id", + "name": "jsonrpc.validation.response.allow-string-id", "type": "java.lang.Boolean", "defaultValue": true, "description": "Allow string ids in incoming responses." }, { - "name": "jsonrpc.validation.response.allow-numeric-response-id", + "name": "jsonrpc.validation.response.allow-numeric-id", "type": "java.lang.Boolean", "defaultValue": true, "description": "Allow numeric ids in incoming responses." }, { - "name": "jsonrpc.validation.response.allow-fractional-response-id", + "name": "jsonrpc.validation.response.allow-fractional-id", "type": "java.lang.Boolean", "defaultValue": true, "description": "Allow fractional numeric ids in incoming responses." @@ -144,19 +193,43 @@ "description": "Require error.message to be a string when validated." }, { - "name": "jsonrpc.validation.response.allow-request-fields-in-response", + "name": "jsonrpc.validation.response.reject-request-fields", "type": "java.lang.Boolean", - "defaultValue": true, - "description": "Allow request-only fields (method/params) in incoming responses." + "defaultValue": false, + "description": "Reject response objects containing request-only fields (method/params)." + }, + { + "name": "jsonrpc.validation.response.reject-duplicate-members", + "type": "java.lang.Boolean", + "defaultValue": false, + "description": "Reject duplicate members when parsing raw JSON response payloads." + }, + { + "name": "jsonrpc.validation.response.error-code.policy", + "type": "com.limehee.jsonrpc.core.JsonRpcResponseErrorCodePolicy", + "defaultValue": "ANY_INTEGER", + "description": "Accepted integer range policy for incoming response error.code values." + }, + { + "name": "jsonrpc.validation.response.error-code.range.min", + "type": "java.lang.Integer", + "description": "Inclusive minimum value for CUSTOM_RANGE error-code policy." + }, + { + "name": "jsonrpc.validation.response.error-code.range.max", + "type": "java.lang.Integer", + "description": "Inclusive maximum value for CUSTOM_RANGE error-code policy." }, { "name": "jsonrpc.method-allowlist", "type": "java.util.List", + "defaultValue": [], "description": "Allowed JSON-RPC method names. Empty list means allow all. Blank entries are invalid." }, { "name": "jsonrpc.method-denylist", "type": "java.util.List", + "defaultValue": [], "description": "Denied JSON-RPC method names. Denylist takes precedence over allowlist membership. Blank entries are invalid." } ], @@ -194,6 +267,45 @@ } ] }, + { + "name": "jsonrpc.validation.response.error-code.policy", + "values": [ + { + "value": "ANY_INTEGER" + }, + { + "value": "STANDARD_ONLY" + }, + { + "value": "STANDARD_OR_SERVER_ERROR_RANGE" + }, + { + "value": "CUSTOM_RANGE" + } + ] + }, + { + "name": "jsonrpc.validation.response.error-code.range.min", + "values": [ + { + "value": -32099 + }, + { + "value": -32603 + } + ] + }, + { + "name": "jsonrpc.validation.response.error-code.range.max", + "values": [ + { + "value": -32000 + }, + { + "value": -32001 + } + ] + }, { "name": "jsonrpc.notification-executor-bean-name", "values": [ diff --git a/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAnnotatedMethodRegistrarTest.java b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAnnotatedMethodRegistrarTest.java new file mode 100644 index 0000000..6c12038 --- /dev/null +++ b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcAnnotatedMethodRegistrarTest.java @@ -0,0 +1,128 @@ +package com.limehee.jsonrpc.spring.boot.autoconfigure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcErrorCode; +import com.limehee.jsonrpc.core.JsonRpcExceptionResolver; +import com.limehee.jsonrpc.core.JsonRpcMethod; +import com.limehee.jsonrpc.core.JsonRpcRequest; +import com.limehee.jsonrpc.core.JsonRpcResponse; +import com.limehee.jsonrpc.core.JsonRpcTypedMethodHandlerFactory; +import com.limehee.jsonrpc.spring.boot.autoconfigure.support.JsonRpcAnnotatedMethodRegistrar; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.IntNode; + +class JsonRpcAnnotatedMethodRegistrarTest { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + @Test + void failsFastWhenAnnotatedBeanCannotBeCreated() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerBeanDefinition("failingBean", new RootBeanDefinition(FailingAnnotatedBean.class)); + + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); + JsonRpcAnnotatedMethodRegistrar registrar = registrar(beanFactory, dispatcher); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + registrar::afterSingletonsInstantiated); + assertTrue(exception.getMessage().contains("failingBean")); + } + + @Test + void wrapsCheckedTargetExceptionFromAnnotatedMethodInvocation() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerBeanDefinition("checkedBean", new RootBeanDefinition(CheckedExceptionAnnotatedBean.class)); + + AtomicReference captured = new AtomicReference<>(); + JsonRpcExceptionResolver exceptionResolver = throwable -> { + captured.set(throwable); + return new DefaultJsonRpcExceptionResolver(false).resolve(throwable); + }; + + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(), + new DefaultJsonRpcMethodInvoker(), + exceptionResolver, + new DefaultJsonRpcResponseComposer(), + 100, + List.of(), + new DirectJsonRpcNotificationExecutor() + ); + + JsonRpcAnnotatedMethodRegistrar registrar = registrar(beanFactory, dispatcher); + registrar.afterSingletonsInstantiated(); + + JsonRpcResponse response = dispatcher.dispatch( + new JsonRpcRequest("2.0", IntNode.valueOf(1), "checked.fail", null, true) + ); + + assertNotNull(response); + assertNotNull(response.error()); + assertEquals(JsonRpcErrorCode.INTERNAL_ERROR, response.error().code()); + assertNotNull(captured.get()); + assertTrue(captured.get() instanceof RuntimeException); + assertNotNull(captured.get().getCause()); + assertEquals(Exception.class, captured.get().getCause().getClass()); + assertEquals("checked failure", captured.get().getCause().getMessage()); + } + + private JsonRpcAnnotatedMethodRegistrar registrar(DefaultListableBeanFactory beanFactory, + JsonRpcDispatcher dispatcher) { + JacksonJsonRpcParameterBinder parameterBinder = new JacksonJsonRpcParameterBinder(OBJECT_MAPPER); + JacksonJsonRpcResultWriter resultWriter = new JacksonJsonRpcResultWriter(OBJECT_MAPPER); + JsonRpcTypedMethodHandlerFactory typedFactory = new DefaultJsonRpcTypedMethodHandlerFactory( + parameterBinder, + resultWriter + ); + return new JsonRpcAnnotatedMethodRegistrar( + beanFactory, + dispatcher, + typedFactory, + parameterBinder, + resultWriter + ); + } + + static class FailingAnnotatedBean { + + FailingAnnotatedBean() { + throw new IllegalStateException("creation failed"); + } + + @JsonRpcMethod("never") + public String never() { + return "never"; + } + } + + static class CheckedExceptionAnnotatedBean { + + @JsonRpcMethod("checked.fail") + public String fail() throws Exception { + throw new Exception("checked failure"); + } + } +} 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 0df25e5..6a37a3f 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 @@ -4,25 +4,34 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseParser; import com.limehee.jsonrpc.core.JsonRpcDispatcher; import com.limehee.jsonrpc.core.JsonRpcException; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; import com.limehee.jsonrpc.core.JsonRpcInterceptor; import com.limehee.jsonrpc.core.JsonRpcMethod; import com.limehee.jsonrpc.core.JsonRpcMethodRegistration; import com.limehee.jsonrpc.core.JsonRpcParam; import com.limehee.jsonrpc.core.JsonRpcRequest; +import com.limehee.jsonrpc.core.JsonRpcRequestValidationOptions; import com.limehee.jsonrpc.core.JsonRpcResponse; +import com.limehee.jsonrpc.core.JsonRpcResponseErrorCodePolicy; +import com.limehee.jsonrpc.core.JsonRpcResponseParser; 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; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -620,6 +629,163 @@ void rejectsUnknownParamsTypeViolationCodePolicyValue() { .run(context -> assertNotNull(context.getStartupFailure())); } + @Test + void bindsDefaultRequestValidationOptions() { + contextRunner.run(context -> { + JsonRpcRequestValidationOptions options = context.getBean(JsonRpcRequestValidationOptions.class); + JsonRpcRequestValidationOptions coreDefaults = JsonRpcRequestValidationOptions.defaults(); + + assertEquals(coreDefaults.requireJsonRpcVersion20(), options.requireJsonRpcVersion20()); + assertEquals(coreDefaults.requireIdMember(), options.requireIdMember()); + assertEquals(coreDefaults.allowNullId(), options.allowNullId()); + assertEquals(coreDefaults.allowStringId(), options.allowStringId()); + assertEquals(coreDefaults.allowNumericId(), options.allowNumericId()); + assertEquals(coreDefaults.allowFractionalId(), options.allowFractionalId()); + assertEquals(coreDefaults.rejectResponseFields(), options.rejectResponseFields()); + assertEquals(coreDefaults.rejectDuplicateMembers(), options.rejectDuplicateMembers()); + assertEquals(coreDefaults.paramsTypeViolationCodePolicy(), options.paramsTypeViolationCodePolicy()); + }); + } + + @Test + void appliesConfiguredRequestValidationOptions() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.request.require-id-member=true", + "jsonrpc.validation.request.allow-fractional-id=false", + "jsonrpc.validation.request.reject-response-fields=true", + "jsonrpc.validation.request.reject-duplicate-members=true" + ) + .run(context -> { + JsonRpcRequestValidationOptions options = context.getBean(JsonRpcRequestValidationOptions.class); + + assertTrue(options.requireIdMember()); + assertFalse(options.allowFractionalId()); + assertTrue(options.rejectResponseFields()); + assertTrue(options.rejectDuplicateMembers()); + }); + } + + @Test + void appliesAllRequestBooleanOptionsWhenSetTrue() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.request.require-json-rpc-version-20=true", + "jsonrpc.validation.request.require-id-member=true", + "jsonrpc.validation.request.allow-null-id=true", + "jsonrpc.validation.request.allow-string-id=true", + "jsonrpc.validation.request.allow-numeric-id=true", + "jsonrpc.validation.request.allow-fractional-id=true", + "jsonrpc.validation.request.reject-response-fields=true", + "jsonrpc.validation.request.reject-duplicate-members=true" + ) + .run(context -> { + JsonRpcRequestValidationOptions options = context.getBean(JsonRpcRequestValidationOptions.class); + + assertTrue(options.requireJsonRpcVersion20()); + assertTrue(options.requireIdMember()); + assertTrue(options.allowNullId()); + assertTrue(options.allowStringId()); + assertTrue(options.allowNumericId()); + assertTrue(options.allowFractionalId()); + assertTrue(options.rejectResponseFields()); + assertTrue(options.rejectDuplicateMembers()); + }); + } + + @Test + void appliesAllRequestBooleanOptionsWhenSetFalse() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.request.require-json-rpc-version-20=false", + "jsonrpc.validation.request.require-id-member=false", + "jsonrpc.validation.request.allow-null-id=false", + "jsonrpc.validation.request.allow-string-id=false", + "jsonrpc.validation.request.allow-numeric-id=false", + "jsonrpc.validation.request.allow-fractional-id=false", + "jsonrpc.validation.request.reject-response-fields=false", + "jsonrpc.validation.request.reject-duplicate-members=false" + ) + .run(context -> { + JsonRpcRequestValidationOptions options = context.getBean(JsonRpcRequestValidationOptions.class); + + assertFalse(options.requireJsonRpcVersion20()); + assertFalse(options.requireIdMember()); + assertFalse(options.allowNullId()); + assertFalse(options.allowStringId()); + assertFalse(options.allowNumericId()); + assertFalse(options.allowFractionalId()); + assertFalse(options.rejectResponseFields()); + assertFalse(options.rejectDuplicateMembers()); + }); + } + + @Test + void bindsEachRequestBooleanOptionIndependentlyForTrueAndFalse() { + JsonRpcRequestValidationOptions defaults = JsonRpcRequestValidationOptions.defaults(); + Map> flags = new LinkedHashMap<>(); + flags.put("jsonrpc.validation.request.require-json-rpc-version-20", + JsonRpcRequestValidationOptions::requireJsonRpcVersion20); + flags.put("jsonrpc.validation.request.require-id-member", JsonRpcRequestValidationOptions::requireIdMember); + flags.put("jsonrpc.validation.request.allow-null-id", JsonRpcRequestValidationOptions::allowNullId); + flags.put("jsonrpc.validation.request.allow-string-id", JsonRpcRequestValidationOptions::allowStringId); + flags.put("jsonrpc.validation.request.allow-numeric-id", JsonRpcRequestValidationOptions::allowNumericId); + flags.put("jsonrpc.validation.request.allow-fractional-id", JsonRpcRequestValidationOptions::allowFractionalId); + flags.put("jsonrpc.validation.request.reject-response-fields", + JsonRpcRequestValidationOptions::rejectResponseFields); + flags.put("jsonrpc.validation.request.reject-duplicate-members", + JsonRpcRequestValidationOptions::rejectDuplicateMembers); + + for (Map.Entry> target : flags.entrySet()) { + assertRequestBooleanFlagBinding(flags, defaults, target.getKey(), true); + assertRequestBooleanFlagBinding(flags, defaults, target.getKey(), false); + } + } + + @Test + void enforcesRequireIdMemberForNotificationLikeRequestWhenConfigured() { + contextRunner + .withPropertyValues("jsonrpc.validation.request.require-id-member=true") + .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", + null, + "ping", + null, + false + )); + + assertNotNull(response.error()); + assertEquals(-32600, response.error().code()); + assertNull(response.id()); + }); + } + + @Test + void enforcesRejectResponseFieldsForRequestWhenConfigured() throws Exception { + contextRunner + .withPropertyValues("jsonrpc.validation.request.reject-response-fields=true") + .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(41), + "ping", + null, + true, + new ObjectMapper().readTree("{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":41,\"result\":1}") + )); + + assertNotNull(response.error()); + assertEquals(-32600, response.error().code()); + }); + } + @Test void bindsDefaultResponseValidationOptions() { contextRunner.run(context -> { @@ -628,16 +794,19 @@ void bindsDefaultResponseValidationOptions() { 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.requireIdMember(), options.requireIdMember()); + assertEquals(coreDefaults.allowNullId(), options.allowNullId()); + assertEquals(coreDefaults.allowStringId(), options.allowStringId()); + assertEquals(coreDefaults.allowNumericId(), options.allowNumericId()); + assertEquals(coreDefaults.allowFractionalId(), options.allowFractionalId()); 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()); + assertEquals(coreDefaults.rejectRequestFields(), options.rejectRequestFields()); + assertEquals(coreDefaults.errorCodePolicy(), options.errorCodePolicy()); + assertEquals(coreDefaults.errorCodeRangeMin(), options.errorCodeRangeMin()); + assertEquals(coreDefaults.errorCodeRangeMax(), options.errorCodeRangeMax()); assertNotNull(responseValidator); }); } @@ -646,16 +815,244 @@ void bindsDefaultResponseValidationOptions() { 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" + "jsonrpc.validation.response.require-id-member=false", + "jsonrpc.validation.response.allow-fractional-id=false", + "jsonrpc.validation.response.reject-request-fields=true", + "jsonrpc.validation.response.error-code.policy=CUSTOM_RANGE", + "jsonrpc.validation.response.error-code.range.min=-45000", + "jsonrpc.validation.response.error-code.range.max=-44000" + ) + .run(context -> { + JsonRpcResponseValidationOptions options = context.getBean(JsonRpcResponseValidationOptions.class); + + assertFalse(options.requireIdMember()); + assertFalse(options.allowFractionalId()); + assertTrue(options.rejectRequestFields()); + assertTrue(options.errorCodePolicy() == JsonRpcResponseErrorCodePolicy.CUSTOM_RANGE); + assertEquals(-45000, options.errorCodeRangeMin()); + assertEquals(-44000, options.errorCodeRangeMax()); + }); + } + + @Test + void appliesAllResponseBooleanOptionsWhenSetTrue() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.response.require-json-rpc-version-20=true", + "jsonrpc.validation.response.require-id-member=true", + "jsonrpc.validation.response.allow-null-id=true", + "jsonrpc.validation.response.allow-string-id=true", + "jsonrpc.validation.response.allow-numeric-id=true", + "jsonrpc.validation.response.allow-fractional-id=true", + "jsonrpc.validation.response.require-exclusive-result-or-error=true", + "jsonrpc.validation.response.require-error-object-when-present=true", + "jsonrpc.validation.response.require-integer-error-code=true", + "jsonrpc.validation.response.require-string-error-message=true", + "jsonrpc.validation.response.reject-request-fields=true", + "jsonrpc.validation.response.reject-duplicate-members=true" ) .run(context -> { JsonRpcResponseValidationOptions options = context.getBean(JsonRpcResponseValidationOptions.class); - assertFalse(options.requireResponseIdMember()); - assertFalse(options.allowFractionalResponseId()); - assertFalse(options.allowRequestFieldsInResponse()); + assertTrue(options.requireJsonRpcVersion20()); + assertTrue(options.requireIdMember()); + assertTrue(options.allowNullId()); + assertTrue(options.allowStringId()); + assertTrue(options.allowNumericId()); + assertTrue(options.allowFractionalId()); + assertTrue(options.requireExclusiveResultOrError()); + assertTrue(options.requireErrorObjectWhenPresent()); + assertTrue(options.requireIntegerErrorCode()); + assertTrue(options.requireStringErrorMessage()); + assertTrue(options.rejectRequestFields()); + assertTrue(options.rejectDuplicateMembers()); + }); + } + + @Test + void appliesAllResponseBooleanOptionsWhenSetFalse() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.response.require-json-rpc-version-20=false", + "jsonrpc.validation.response.require-id-member=false", + "jsonrpc.validation.response.allow-null-id=false", + "jsonrpc.validation.response.allow-string-id=false", + "jsonrpc.validation.response.allow-numeric-id=false", + "jsonrpc.validation.response.allow-fractional-id=false", + "jsonrpc.validation.response.require-exclusive-result-or-error=false", + "jsonrpc.validation.response.require-error-object-when-present=false", + "jsonrpc.validation.response.require-integer-error-code=false", + "jsonrpc.validation.response.require-string-error-message=false", + "jsonrpc.validation.response.reject-request-fields=false", + "jsonrpc.validation.response.reject-duplicate-members=false" + ) + .run(context -> { + JsonRpcResponseValidationOptions options = context.getBean(JsonRpcResponseValidationOptions.class); + + assertFalse(options.requireJsonRpcVersion20()); + assertFalse(options.requireIdMember()); + assertFalse(options.allowNullId()); + assertFalse(options.allowStringId()); + assertFalse(options.allowNumericId()); + assertFalse(options.allowFractionalId()); + assertFalse(options.requireExclusiveResultOrError()); + assertFalse(options.requireErrorObjectWhenPresent()); + assertFalse(options.requireIntegerErrorCode()); + assertFalse(options.requireStringErrorMessage()); + assertFalse(options.rejectRequestFields()); + assertFalse(options.rejectDuplicateMembers()); + }); + } + + @Test + void bindsEachResponseBooleanOptionIndependentlyForTrueAndFalse() { + JsonRpcResponseValidationOptions defaults = JsonRpcResponseValidationOptions.defaults(); + Map> flags = new LinkedHashMap<>(); + flags.put("jsonrpc.validation.response.require-json-rpc-version-20", + JsonRpcResponseValidationOptions::requireJsonRpcVersion20); + flags.put("jsonrpc.validation.response.require-id-member", JsonRpcResponseValidationOptions::requireIdMember); + flags.put("jsonrpc.validation.response.allow-null-id", JsonRpcResponseValidationOptions::allowNullId); + flags.put("jsonrpc.validation.response.allow-string-id", JsonRpcResponseValidationOptions::allowStringId); + flags.put("jsonrpc.validation.response.allow-numeric-id", JsonRpcResponseValidationOptions::allowNumericId); + flags.put("jsonrpc.validation.response.allow-fractional-id", + JsonRpcResponseValidationOptions::allowFractionalId); + flags.put("jsonrpc.validation.response.require-exclusive-result-or-error", + JsonRpcResponseValidationOptions::requireExclusiveResultOrError); + flags.put("jsonrpc.validation.response.require-error-object-when-present", + JsonRpcResponseValidationOptions::requireErrorObjectWhenPresent); + flags.put("jsonrpc.validation.response.require-integer-error-code", + JsonRpcResponseValidationOptions::requireIntegerErrorCode); + flags.put("jsonrpc.validation.response.require-string-error-message", + JsonRpcResponseValidationOptions::requireStringErrorMessage); + flags.put("jsonrpc.validation.response.reject-request-fields", + JsonRpcResponseValidationOptions::rejectRequestFields); + flags.put("jsonrpc.validation.response.reject-duplicate-members", + JsonRpcResponseValidationOptions::rejectDuplicateMembers); + + for (Map.Entry> target : flags.entrySet()) { + assertResponseBooleanFlagBinding(flags, defaults, target.getKey(), true); + assertResponseBooleanFlagBinding(flags, defaults, target.getKey(), false); + } + } + + @Test + void appliesStandardOnlyErrorCodePolicyToResponseValidatorBean() { + contextRunner + .withPropertyValues("jsonrpc.validation.response.error-code.policy=STANDARD_ONLY") + .run(context -> { + JsonRpcResponseValidator validator = context.getBean(JsonRpcResponseValidator.class); + DefaultJsonRpcResponseParser parser = new DefaultJsonRpcResponseParser(); + + JsonRpcIncomingResponse standard = parser + .parse("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"x\"}}") + .singleResponse() + .orElseThrow(); + JsonRpcIncomingResponse nonStandard = parser + .parse("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32000,\"message\":\"x\"}}") + .singleResponse() + .orElseThrow(); + + validator.validate(standard); + assertThrows(JsonRpcException.class, () -> validator.validate(nonStandard)); + }); + } + + @Test + void rejectsResponseErrorCodePolicyWithoutIntegerCodeValidation() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.response.require-integer-error-code=false", + "jsonrpc.validation.response.error-code.policy=STANDARD_ONLY" + ) + .run(context -> assertNotNull(context.getStartupFailure())); + } + + @Test + void rejectsCustomRangePolicyWhenBoundsAreMissing() { + contextRunner + .withPropertyValues("jsonrpc.validation.response.error-code.policy=CUSTOM_RANGE") + .run(context -> assertNotNull(context.getStartupFailure())); + } + + @Test + void rejectsCustomRangePolicyWhenBoundsAreInverted() { + contextRunner + .withPropertyValues( + "jsonrpc.validation.response.error-code.policy=CUSTOM_RANGE", + "jsonrpc.validation.response.error-code.range.min=-32000", + "jsonrpc.validation.response.error-code.range.max=-32100" + ) + .run(context -> assertNotNull(context.getStartupFailure())); + } + + @Test + void appliesResponseDuplicateMemberRejectionToResponseParserBean() { + contextRunner + .withPropertyValues("jsonrpc.validation.response.reject-duplicate-members=true") + .run(context -> { + JsonRpcResponseParser parser = context.getBean(JsonRpcResponseParser.class); + assertTrue(parser instanceof DefaultJsonRpcResponseParser); + + DefaultJsonRpcResponseParser defaultParser = (DefaultJsonRpcResponseParser) parser; + + assertThrows( + JsonRpcException.class, + () -> defaultParser.parse("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}") + ); + }); + } + + @Test + void keepsResponseDuplicateMemberAcceptanceWhenPolicyIsDisabled() { + contextRunner + .withPropertyValues("jsonrpc.validation.response.reject-duplicate-members=false") + .run(context -> { + JsonRpcResponseParser parser = context.getBean(JsonRpcResponseParser.class); + assertTrue(parser instanceof DefaultJsonRpcResponseParser); + + DefaultJsonRpcResponseParser defaultParser = (DefaultJsonRpcResponseParser) parser; + assertNotNull(defaultParser.parse("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}")); + }); + } + + @Test + void customResponseValidationOptionsCanEnableDuplicateMemberRejectionEvenWhenPropertyIsDisabled() { + contextRunner + .withPropertyValues("jsonrpc.validation.response.reject-duplicate-members=false") + .withBean( + JsonRpcResponseValidationOptions.class, + () -> JsonRpcResponseValidationOptions.builder() + .rejectDuplicateMembers(true) + .build() + ) + .run(context -> { + JsonRpcResponseParser parser = context.getBean(JsonRpcResponseParser.class); + assertTrue(parser instanceof DefaultJsonRpcResponseParser); + + DefaultJsonRpcResponseParser defaultParser = (DefaultJsonRpcResponseParser) parser; + assertThrows( + JsonRpcException.class, + () -> defaultParser.parse("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}") + ); + }); + } + + @Test + void customResponseValidationOptionsCanDisableDuplicateMemberRejectionEvenWhenPropertyIsEnabled() { + contextRunner + .withPropertyValues("jsonrpc.validation.response.reject-duplicate-members=true") + .withBean( + JsonRpcResponseValidationOptions.class, + () -> JsonRpcResponseValidationOptions.builder() + .rejectDuplicateMembers(false) + .build() + ) + .run(context -> { + JsonRpcResponseParser parser = context.getBean(JsonRpcResponseParser.class); + assertTrue(parser instanceof DefaultJsonRpcResponseParser); + + DefaultJsonRpcResponseParser defaultParser = (DefaultJsonRpcResponseParser) parser; + assertNotNull(defaultParser.parse("{\"jsonrpc\":\"2.0\",\"id\":1,\"id\":2,\"result\":1}")); }); } @@ -820,6 +1217,44 @@ void failsFastWhenSelectedNotificationExecutorBeanDoesNotExist() { .run(context -> assertNotNull(context.getStartupFailure())); } + private void assertRequestBooleanFlagBinding( + Map> flags, + JsonRpcRequestValidationOptions defaults, + String targetProperty, + boolean targetValue + ) { + contextRunner + .withPropertyValues(targetProperty + "=" + targetValue) + .run(context -> { + JsonRpcRequestValidationOptions options = context.getBean(JsonRpcRequestValidationOptions.class); + for (Map.Entry> entry : flags.entrySet()) { + boolean expected = entry.getKey().equals(targetProperty) + ? targetValue + : entry.getValue().apply(defaults); + assertEquals(expected, entry.getValue().apply(options)); + } + }); + } + + private void assertResponseBooleanFlagBinding( + Map> flags, + JsonRpcResponseValidationOptions defaults, + String targetProperty, + boolean targetValue + ) { + contextRunner + .withPropertyValues(targetProperty + "=" + targetValue) + .run(context -> { + JsonRpcResponseValidationOptions options = context.getBean(JsonRpcResponseValidationOptions.class); + for (Map.Entry> entry : flags.entrySet()) { + boolean expected = entry.getKey().equals(targetProperty) + ? targetValue + : entry.getValue().apply(defaults); + assertEquals(expected, entry.getValue().apply(options)); + } + }); + } + @Configuration(proxyBeanMethods = false) static class AnnotatedMethodConfig { diff --git a/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcMetricsInterceptorTest.java b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcMetricsInterceptorTest.java new file mode 100644 index 0000000..8a7186d --- /dev/null +++ b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcMetricsInterceptorTest.java @@ -0,0 +1,169 @@ +package com.limehee.jsonrpc.spring.boot.autoconfigure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.limehee.jsonrpc.core.JsonRpcError; +import com.limehee.jsonrpc.core.JsonRpcErrorCode; +import com.limehee.jsonrpc.core.JsonRpcRequest; +import com.limehee.jsonrpc.spring.boot.autoconfigure.support.JsonRpcMetricsInterceptor; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.IntNode; + +class JsonRpcMetricsInterceptorTest { + + @Test + void classifiesMethodNotFoundAsResolutionForNonAccessControlThrowable() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + JsonRpcMetricsInterceptor interceptor = new JsonRpcMetricsInterceptor(meterRegistry); + JsonRpcRequest request = request("lookup"); + + interceptor.beforeInvoke(request); + interceptor.onError(request, new IllegalStateException("missing"), + JsonRpcError.of(JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found")); + + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.stage.events", + "method", "lookup", + "stage", "method_not_found" + ).count()); + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.failures", + "method", "lookup", + "errorCode", "-32601", + "source", "resolution" + ).count()); + } + + @Test + void classifiesInternalErrorAsHandlerForNonInterceptorThrowable() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + JsonRpcMetricsInterceptor interceptor = new JsonRpcMetricsInterceptor(meterRegistry); + JsonRpcRequest request = request("explode"); + + interceptor.beforeInvoke(request); + interceptor.onError(request, new IllegalStateException("boom"), + JsonRpcError.of(JsonRpcErrorCode.INTERNAL_ERROR, "Internal error")); + + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.stage.events", + "method", "explode", + "stage", "internal_error" + ).count()); + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.failures", + "method", "explode", + "errorCode", "-32603", + "source", "handler" + ).count()); + } + + @Test + void classifiesCustomCodeAsCustomStageAndSource() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + JsonRpcMetricsInterceptor interceptor = new JsonRpcMetricsInterceptor(meterRegistry); + JsonRpcRequest request = request("domain.error"); + + interceptor.beforeInvoke(request); + interceptor.onError(request, new IllegalArgumentException("domain"), + JsonRpcError.of(-32010, "Domain error")); + + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.stage.events", + "method", "domain.error", + "stage", "custom_error" + ).count()); + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.failures", + "method", "domain.error", + "errorCode", "-32010", + "source", "custom" + ).count()); + } + + @Test + void usesUnknownMethodTagWhenRequestIsMissing() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + JsonRpcMetricsInterceptor interceptor = new JsonRpcMetricsInterceptor(meterRegistry); + + interceptor.onError(null, new IllegalStateException("boom"), + JsonRpcError.of(JsonRpcErrorCode.INTERNAL_ERROR, "Internal error")); + + assertEquals(1.0, meterRegistry.counter( + "jsonrpc.server.calls", + "method", "unknown", + "outcome", "error", + "errorCode", "-32603" + ).count()); + } + + @Test + void rejectsNonPositiveMaxMethodTagValues() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + assertThrows( + IllegalArgumentException.class, + () -> new JsonRpcMetricsInterceptor(meterRegistry, false, new double[0], 0) + ); + assertThrows( + IllegalArgumentException.class, + () -> new JsonRpcMetricsInterceptor(meterRegistry, false, new double[0], -1) + ); + } + + @Test + void enforcesHardCapForDistinctMethodTagsUnderConcurrency() throws Exception { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + JsonRpcMetricsInterceptor interceptor = new JsonRpcMetricsInterceptor(meterRegistry, false, new double[0], 1); + int workers = 64; + ExecutorService executor = Executors.newFixedThreadPool(workers); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(workers); + for (int i = 0; i < workers; i++) { + String methodName = "method." + i; + futures.add(executor.submit(() -> { + start.await(); + JsonRpcRequest request = request(methodName); + interceptor.beforeInvoke(request); + interceptor.afterInvoke(request, IntNode.valueOf(1)); + return null; + })); + } + + start.countDown(); + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + long concreteMethodTags = meterRegistry.getMeters().stream() + .filter(meter -> "jsonrpc.server.calls".equals(meter.getId().getName())) + .map(meter -> meter.getId().getTag("method")) + .filter(method -> method != null && !"other".equals(method) && !"unknown".equals(method)) + .distinct() + .count(); + + assertEquals(1, concreteMethodTags); + assertEquals(63, meterRegistry.counter( + "jsonrpc.server.calls", + "method", "other", + "outcome", "success", + "errorCode", "none" + ).count(), 0.0d); + } + + private JsonRpcRequest request(String method) { + return new JsonRpcRequest("2.0", IntNode.valueOf(1), method, null, true); + } +} diff --git a/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcWebAutoConfigurationTest.java b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcWebAutoConfigurationTest.java index 7b93f6a..d572e71 100644 --- a/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcWebAutoConfigurationTest.java +++ b/jsonrpc-spring-boot-autoconfigure/src/test/java/com/limehee/jsonrpc/spring/boot/autoconfigure/JsonRpcWebAutoConfigurationTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.limehee.jsonrpc.core.JsonRpcRequestValidationOptions; import com.limehee.jsonrpc.core.JsonRpcResponse; import com.limehee.jsonrpc.spring.webmvc.JsonRpcHttpStatusStrategy; import com.limehee.jsonrpc.spring.webmvc.JsonRpcWebMvcEndpoint; @@ -18,9 +19,13 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; class JsonRpcWebAutoConfigurationTest { + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + private final WebApplicationContextRunner webContextRunner = new WebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(JsonRpcAutoConfiguration.class)); @@ -61,6 +66,80 @@ void rejectsMaxRequestBytesLessThanOne() { .run(context -> assertNotNull(context.getStartupFailure())); } + @Test + void keepsRequestDuplicateMemberAcceptanceWhenRequestPolicyIsDisabled() throws Exception { + webContextRunner + .withPropertyValues("jsonrpc.validation.request.reject-duplicate-members=false") + .run(context -> { + JsonRpcWebMvcEndpoint endpoint = context.getBean(JsonRpcWebMvcEndpoint.class); + HttpStatusCode status = endpoint.invoke( + "{\"jsonrpc\":\"2.0\",\"method\":\"missing\",\"id\":1,\"id\":2}".getBytes(StandardCharsets.UTF_8) + ).getStatusCode(); + + assertEquals(HttpStatus.OK.value(), status.value()); + }); + } + + @Test + void rejectsRequestDuplicateMembersWhenRequestPolicyIsEnabled() throws Exception { + webContextRunner + .withPropertyValues("jsonrpc.validation.request.reject-duplicate-members=true") + .run(context -> { + JsonRpcWebMvcEndpoint endpoint = context.getBean(JsonRpcWebMvcEndpoint.class); + String body = endpoint.invoke( + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1,\"id\":2}".getBytes(StandardCharsets.UTF_8) + ).getBody(); + + assertNotNull(body); + int code = OBJECT_MAPPER.readTree(body).get("error").get("code").asInt(); + assertEquals(-32700, code); + }); + } + + @Test + void customRequestValidationOptionsCanEnableDuplicateMemberRejectionEvenWhenPropertyIsDisabled() throws Exception { + webContextRunner + .withPropertyValues("jsonrpc.validation.request.reject-duplicate-members=false") + .withBean( + JsonRpcRequestValidationOptions.class, + () -> JsonRpcRequestValidationOptions.builder() + .rejectDuplicateMembers(true) + .build() + ) + .run(context -> { + JsonRpcWebMvcEndpoint endpoint = context.getBean(JsonRpcWebMvcEndpoint.class); + String body = endpoint.invoke( + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1,\"id\":2}".getBytes(StandardCharsets.UTF_8) + ).getBody(); + + assertNotNull(body); + int code = OBJECT_MAPPER.readTree(body).get("error").get("code").asInt(); + assertEquals(-32700, code); + }); + } + + @Test + void customRequestValidationOptionsCanDisableDuplicateMemberRejectionEvenWhenPropertyIsEnabled() throws Exception { + webContextRunner + .withPropertyValues("jsonrpc.validation.request.reject-duplicate-members=true") + .withBean( + JsonRpcRequestValidationOptions.class, + () -> JsonRpcRequestValidationOptions.builder() + .rejectDuplicateMembers(false) + .build() + ) + .run(context -> { + JsonRpcWebMvcEndpoint endpoint = context.getBean(JsonRpcWebMvcEndpoint.class); + String body = endpoint.invoke( + "{\"jsonrpc\":\"2.0\",\"method\":\"missing\",\"id\":1,\"id\":2}".getBytes(StandardCharsets.UTF_8) + ).getBody(); + + assertNotNull(body); + int code = OBJECT_MAPPER.readTree(body).get("error").get("code").asInt(); + assertEquals(-32601, code); + }); + } + @Configuration(proxyBeanMethods = false) static class CustomHttpStatusStrategyConfig { diff --git a/jsonrpc-spring-webmvc/src/main/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpoint.java b/jsonrpc-spring-webmvc/src/main/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpoint.java index 2bf71e5..80ee5b1 100644 --- a/jsonrpc-spring-webmvc/src/main/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpoint.java +++ b/jsonrpc-spring-webmvc/src/main/java/com/limehee/jsonrpc/spring/webmvc/JsonRpcWebMvcEndpoint.java @@ -3,8 +3,10 @@ import com.limehee.jsonrpc.core.JsonRpcDispatchResult; import com.limehee.jsonrpc.core.JsonRpcDispatcher; import com.limehee.jsonrpc.core.JsonRpcErrorCode; +import com.limehee.jsonrpc.core.JsonRpcPayloadReader; import com.limehee.jsonrpc.core.JsonRpcResponse; import java.util.List; +import java.util.Objects; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -28,6 +30,7 @@ public class JsonRpcWebMvcEndpoint { private final JsonRpcDispatcher dispatcher; private final ObjectMapper objectMapper; + private final JsonRpcPayloadReader requestPayloadReader; private final JsonRpcHttpStatusStrategy httpStatusStrategy; private final int maxRequestBytes; private final JsonRpcWebMvcObserver observer; @@ -39,6 +42,7 @@ public class JsonRpcWebMvcEndpoint { * @param objectMapper mapper used to parse request payloads and serialize responses * @param httpStatusStrategy strategy that maps JSON-RPC outcomes to HTTP status codes * @param maxRequestBytes maximum accepted request payload size in bytes + * @throws IllegalArgumentException if {@code maxRequestBytes <= 0} */ public JsonRpcWebMvcEndpoint( JsonRpcDispatcher dispatcher, @@ -51,7 +55,8 @@ public JsonRpcWebMvcEndpoint( objectMapper, httpStatusStrategy, maxRequestBytes, - JsonRpcWebMvcObserver.noOp() + JsonRpcWebMvcObserver.noOp(), + false ); } @@ -63,6 +68,7 @@ public JsonRpcWebMvcEndpoint( * @param httpStatusStrategy strategy that maps JSON-RPC outcomes to HTTP status codes * @param maxRequestBytes maximum accepted request payload size in bytes * @param observer observer receiving transport-level event callbacks + * @throws IllegalArgumentException if {@code maxRequestBytes <= 0} */ public JsonRpcWebMvcEndpoint( JsonRpcDispatcher dispatcher, @@ -71,11 +77,44 @@ public JsonRpcWebMvcEndpoint( int maxRequestBytes, JsonRpcWebMvcObserver observer ) { - this.dispatcher = dispatcher; - this.objectMapper = objectMapper; - this.httpStatusStrategy = httpStatusStrategy; + this( + dispatcher, + objectMapper, + httpStatusStrategy, + maxRequestBytes, + observer, + false + ); + } + + /** + * Creates an endpoint with explicit transport observer and request duplicate-member policy. + * + * @param dispatcher dispatcher that performs JSON-RPC parsing, validation, and invocation + * @param objectMapper mapper used to parse request payloads and serialize responses + * @param httpStatusStrategy strategy that maps JSON-RPC outcomes to HTTP status codes + * @param maxRequestBytes maximum accepted request payload size in bytes + * @param observer observer receiving transport-level event callbacks + * @param rejectDuplicateMembers {@code true} to reject duplicate request members during JSON parsing + * @throws IllegalArgumentException if {@code maxRequestBytes <= 0} + */ + public JsonRpcWebMvcEndpoint( + JsonRpcDispatcher dispatcher, + ObjectMapper objectMapper, + JsonRpcHttpStatusStrategy httpStatusStrategy, + int maxRequestBytes, + JsonRpcWebMvcObserver observer, + boolean rejectDuplicateMembers + ) { + this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + if (maxRequestBytes <= 0) { + throw new IllegalArgumentException("maxRequestBytes must be greater than 0"); + } + this.requestPayloadReader = new JsonRpcPayloadReader(objectMapper, rejectDuplicateMembers); + this.httpStatusStrategy = Objects.requireNonNull(httpStatusStrategy, "httpStatusStrategy"); this.maxRequestBytes = maxRequestBytes; - this.observer = observer; + this.observer = Objects.requireNonNull(observer, "observer"); } /** @@ -114,7 +153,7 @@ public ResponseEntity invoke(@RequestBody(required = false) byte[] body) JsonNode payload; try { - payload = objectMapper.readTree(body); + payload = requestPayloadReader.readTree(body); } catch (JacksonException ex) { observer.onParseError(); return singleErrorResponse(dispatcher.parseErrorResponse(), httpStatusStrategy.statusForParseError()); 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 7643724..0a3c893 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 @@ -1,6 +1,7 @@ package com.limehee.jsonrpc.spring.webmvc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; 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.status; @@ -190,6 +191,45 @@ void rejectsNonJsonContentType() throws Exception { .andExpect(status().isUnsupportedMediaType()); } + @Test + void allowsDuplicateRequestMembersWhenDuplicateRejectionIsDisabled() throws Exception { + MvcResult result = mockMvc.perform(post("/jsonrpc") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1,\"id\":2}")) + .andExpect(status().isOk()) + .andReturn(); + + JsonRpcResponse response = OBJECT_MAPPER.readValue(result.getResponse().getContentAsByteArray(), + JsonRpcResponse.class); + assertEquals("pong", response.result().asString()); + assertEquals(2, response.id().asInt()); + } + + @Test + void rejectsDuplicateRequestMembersWhenDuplicateRejectionIsEnabled() throws Exception { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); + dispatcher.register("ping", params -> StringNode.valueOf("pong")); + JsonRpcWebMvcEndpoint endpoint = new JsonRpcWebMvcEndpoint( + dispatcher, + OBJECT_MAPPER, + new DefaultJsonRpcHttpStatusStrategy(), + 1024 * 1024, + JsonRpcWebMvcObserver.noOp(), + true + ); + MockMvc localMockMvc = MockMvcBuilders.standaloneSetup(endpoint).build(); + + MvcResult result = localMockMvc.perform(post("/jsonrpc") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1,\"id\":2}")) + .andExpect(status().isOk()) + .andReturn(); + + JsonRpcResponse response = OBJECT_MAPPER.readValue(result.getResponse().getContentAsByteArray(), + JsonRpcResponse.class); + assertEquals(JsonRpcErrorCode.PARSE_ERROR, response.error().code()); + } + @Test void usesCustomStatusStrategyForParseErrorAndPayloadLimit() throws Exception { JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); @@ -310,6 +350,25 @@ void notifiesObserverForSingleAndBatchResponses() throws Exception { assertEquals(2, observer.lastBatchResponseCount); } + @Test + void constructorRejectsNonPositiveMaxRequestBytes() { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher(); + + assertThrows(IllegalArgumentException.class, () -> new JsonRpcWebMvcEndpoint( + dispatcher, + OBJECT_MAPPER, + new DefaultJsonRpcHttpStatusStrategy(), + 0 + )); + + assertThrows(IllegalArgumentException.class, () -> new JsonRpcWebMvcEndpoint( + dispatcher, + OBJECT_MAPPER, + new DefaultJsonRpcHttpStatusStrategy(), + -1 + )); + } + private static final class RecordingObserver implements JsonRpcWebMvcObserver { int parseErrors; diff --git a/samples/pure-java-demo/README.md b/samples/pure-java-demo/README.md index 5c6fdb0..eb51870 100644 --- a/samples/pure-java-demo/README.md +++ b/samples/pure-java-demo/README.md @@ -10,6 +10,14 @@ From repository root: ./gradlew -p samples/pure-java-demo run ``` +## Run Tests + +From repository root: + +```bash +./gradlew -p samples/pure-java-demo test +``` + ## What This Demo Covers - Single request success flow @@ -19,6 +27,10 @@ From repository root: - Typed handler registration (`JsonRpcTypedMethodHandlerFactory`) - Manual handler registration (`dispatcher.register`) - Request-validator policy switch with `JsonRpcParamsTypeViolationCodePolicy` +- Request validation profile with `JsonRpcRequestValidationOptions` (`require-id-member`, `allow-fractional-id`, + `reject-response-fields`) +- Response validation profile with `JsonRpcResponseValidationOptions` (`reject-request-fields`, + `reject-duplicate-members`, `error-code.policy`) - Incoming response-side flow using classifier/parser/validator utilities - Interceptor lifecycle flow (`beforeValidate`, `beforeInvoke`, `afterInvoke`, `onError`) @@ -27,5 +39,6 @@ From repository root: - `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` +- `src/main/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExample.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 index d18a5ab..edf1c22 100644 --- a/samples/pure-java-demo/build.gradle +++ b/samples/pure-java-demo/build.gradle @@ -6,6 +6,15 @@ plugins { group = 'com.limehee.jsonrpc' version = '0.0.1-SNAPSHOT' +def jsonrpcVersion = providers.gradleProperty('jsonrpcVersion') + .orElse(providers.environmentVariable('JSONRPC_VERSION')) + .orElse(providers.provider { + def props = new Properties() + file('../../gradle.properties').withInputStream { props.load(it) } + props.getProperty('version', '0.0.0-SNAPSHOT') + }) + .get() + java { toolchain { languageVersion = JavaLanguageVersion.of(17) @@ -13,7 +22,7 @@ java { } dependencies { - implementation 'io.github.limehee:jsonrpc-core:0.2.0' + implementation "io.github.limehee:jsonrpc-core:${jsonrpcVersion}" 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' 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 index 76ef3cf..2b1eaae 100644 --- 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 @@ -16,6 +16,7 @@ import com.limehee.jsonrpc.core.JacksonJsonRpcResultWriter; import com.limehee.jsonrpc.core.JsonRpcDispatchResult; import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; import com.limehee.jsonrpc.core.JsonRpcMethodRegistrationConflictPolicy; import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; import com.limehee.jsonrpc.core.JsonRpcTypedMethodHandlerFactory; @@ -52,6 +53,18 @@ public static void main(String[] args) throws JacksonException { print("strict params shape policy", handle(strictDispatcher, """ {"jsonrpc":"2.0","method":"typed.upper","params":"invalid-shape","id":9} """)); + + JsonRpcDispatchResult strictRequestResult = ValidationProfileExample.dispatchStrict(""" + {"jsonrpc":"2.0","method":"ping"} + """); + print("strict request profile (require-id-member)", OBJECT_MAPPER.writeValueAsString( + strictRequestResult.singleResponse().orElseThrow() + )); + + List strictResponses = ValidationProfileExample.parseAndValidateStrictResponses(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"server"}} + """); + print("strict response profile", OBJECT_MAPPER.writeValueAsString(strictResponses)); } static String handle(JsonRpcDispatcher dispatcher, String rawJson) throws JacksonException { 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 index d144e48..eca02c1 100644 --- 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 @@ -11,7 +11,6 @@ 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; @@ -23,12 +22,12 @@ public final class ResponseSideUtilitiesExample { private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); private final JsonRpcEnvelopeClassifier classifier; - private final JsonRpcResponseParser parser; + private final DefaultJsonRpcResponseParser parser; private final JsonRpcResponseValidator validator; public ResponseSideUtilitiesExample(JsonRpcResponseValidationOptions options) { this.classifier = new DefaultJsonRpcEnvelopeClassifier(); - this.parser = new DefaultJsonRpcResponseParser(); + this.parser = new DefaultJsonRpcResponseParser(options.rejectDuplicateMembers()); this.validator = new DefaultJsonRpcResponseValidator(options); } @@ -39,7 +38,7 @@ public Result inspect(String rawMessage) throws JacksonException { return new Result(envelopeType, List.of()); } - JsonRpcIncomingResponseEnvelope envelope = parser.parse(payload); + JsonRpcIncomingResponseEnvelope envelope = parser.parse(rawMessage); List validated = new ArrayList<>(envelope.responses().size()); for (JsonRpcIncomingResponse response : envelope.responses()) { validator.validate(response); diff --git a/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExample.java b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExample.java new file mode 100644 index 0000000..62c65a2 --- /dev/null +++ b/samples/pure-java-demo/src/main/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExample.java @@ -0,0 +1,100 @@ +package com.limehee.jsonrpc.sample.purejava; + +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.DefaultJsonRpcResponseParser; +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseValidator; +import com.limehee.jsonrpc.core.DirectJsonRpcNotificationExecutor; +import com.limehee.jsonrpc.core.InMemoryJsonRpcMethodRegistry; +import com.limehee.jsonrpc.core.JsonRpcDispatchResult; +import com.limehee.jsonrpc.core.JsonRpcDispatcher; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponseEnvelope; +import com.limehee.jsonrpc.core.JsonRpcMethodRegistrationConflictPolicy; +import com.limehee.jsonrpc.core.JsonRpcParamsTypeViolationCodePolicy; +import com.limehee.jsonrpc.core.JsonRpcRequestValidationOptions; +import com.limehee.jsonrpc.core.JsonRpcResponseErrorCodePolicy; +import com.limehee.jsonrpc.core.JsonRpcResponseValidationOptions; +import java.util.ArrayList; +import java.util.List; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.StringNode; + +public final class ValidationProfileExample { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + private ValidationProfileExample() { + } + + public static JsonRpcRequestValidationOptions strictRequestOptions() { + return JsonRpcRequestValidationOptions.builder() + .requireJsonRpcVersion20(true) + .requireIdMember(true) + .allowNullId(false) + .allowStringId(true) + .allowNumericId(true) + .allowFractionalId(false) + .rejectResponseFields(true) + .paramsTypeViolationCodePolicy(JsonRpcParamsTypeViolationCodePolicy.INVALID_REQUEST) + .build(); + } + + public static JsonRpcResponseValidationOptions strictResponseOptions() { + return JsonRpcResponseValidationOptions.builder() + .requireJsonRpcVersion20(true) + .requireIdMember(true) + .allowNullId(false) + .allowStringId(true) + .allowNumericId(true) + .allowFractionalId(false) + .requireExclusiveResultOrError(true) + .requireErrorObjectWhenPresent(true) + .requireIntegerErrorCode(true) + .requireStringErrorMessage(true) + .rejectRequestFields(true) + .rejectDuplicateMembers(true) + .errorCodePolicy(JsonRpcResponseErrorCodePolicy.STANDARD_OR_SERVER_ERROR_RANGE) + .build(); + } + + public static JsonRpcDispatcher createStrictDispatcher() { + JsonRpcDispatcher dispatcher = new JsonRpcDispatcher( + new InMemoryJsonRpcMethodRegistry(JsonRpcMethodRegistrationConflictPolicy.REJECT), + new DefaultJsonRpcRequestParser(), + new DefaultJsonRpcRequestValidator(strictRequestOptions()), + new DefaultJsonRpcMethodInvoker(), + new DefaultJsonRpcExceptionResolver(false), + new DefaultJsonRpcResponseComposer(), + 100, + List.of(), + new DirectJsonRpcNotificationExecutor() + ); + dispatcher.register("ping", params -> StringNode.valueOf("pong")); + return dispatcher; + } + + public static JsonRpcDispatchResult dispatchStrict(String rawRequest) throws JacksonException { + JsonRpcDispatcher dispatcher = createStrictDispatcher(); + return dispatcher.dispatch(OBJECT_MAPPER.readTree(rawRequest)); + } + + public static List parseAndValidateStrictResponses(String rawPayload) { + JsonRpcResponseValidationOptions options = strictResponseOptions(); + DefaultJsonRpcResponseParser parser = new DefaultJsonRpcResponseParser(options.rejectDuplicateMembers()); + DefaultJsonRpcResponseValidator validator = new DefaultJsonRpcResponseValidator(options); + + JsonRpcIncomingResponseEnvelope envelope = parser.parse(rawPayload); + List responses = new ArrayList<>(envelope.responses().size()); + for (JsonRpcIncomingResponse response : envelope.responses()) { + validator.validate(response); + responses.add(response); + } + return responses; + } +} 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 index a4db4ea..cf5b94d 100644 --- 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 @@ -69,4 +69,30 @@ void failsValidationForMalformedErrorObject() { {"jsonrpc":"2.0","id":1,"error":{"code":"bad","message":1}} """)); } + + @Test + void rejectsDuplicateMembersWhenConfigured() { + ResponseSideUtilitiesExample example = new ResponseSideUtilitiesExample( + JsonRpcResponseValidationOptions.builder() + .rejectDuplicateMembers(true) + .build() + ); + + assertThrows(JsonRpcException.class, () -> example.inspect(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":"pong"} + """)); + } + + @Test + void rejectsRequestFieldsInsideResponseWhenConfigured() { + ResponseSideUtilitiesExample example = new ResponseSideUtilitiesExample( + JsonRpcResponseValidationOptions.builder() + .rejectRequestFields(true) + .build() + ); + + assertThrows(JsonRpcException.class, () -> example.inspect(""" + {"jsonrpc":"2.0","id":1,"result":"pong","method":"ping"} + """)); + } } diff --git a/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExampleTest.java b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExampleTest.java new file mode 100644 index 0000000..27c77ef --- /dev/null +++ b/samples/pure-java-demo/src/test/java/com/limehee/jsonrpc/sample/purejava/ValidationProfileExampleTest.java @@ -0,0 +1,68 @@ +package com.limehee.jsonrpc.sample.purejava; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.limehee.jsonrpc.core.JsonRpcDispatchResult; +import com.limehee.jsonrpc.core.JsonRpcException; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ValidationProfileExampleTest { + + @Test + void strictRequestProfileRejectsNotificationWithoutId() throws Exception { + JsonRpcDispatchResult result = ValidationProfileExample.dispatchStrict(""" + {"jsonrpc":"2.0","method":"ping"} + """); + + assertTrue(result.hasResponse()); + assertEquals(-32600, result.singleResponse().orElseThrow().error().code()); + assertNull(result.singleResponse().orElseThrow().id()); + } + + @Test + void strictRequestProfileRejectsFractionalId() throws Exception { + JsonRpcDispatchResult result = ValidationProfileExample.dispatchStrict(""" + {"jsonrpc":"2.0","method":"ping","id":1.5} + """); + + assertEquals(-32600, result.singleResponse().orElseThrow().error().code()); + } + + @Test + void strictRequestProfileRejectsResponseFieldsInsideRequest() throws Exception { + JsonRpcDispatchResult result = ValidationProfileExample.dispatchStrict(""" + {"jsonrpc":"2.0","method":"ping","id":1,"result":"unexpected"} + """); + + assertEquals(-32600, result.singleResponse().orElseThrow().error().code()); + } + + @Test + void strictResponseProfileAcceptsStandardServerErrorRange() { + List responses = ValidationProfileExample.parseAndValidateStrictResponses(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"server"}} + """); + + assertEquals(1, responses.size()); + assertEquals(-32000, responses.get(0).error().get("code").asInt()); + } + + @Test + void strictResponseProfileRejectsDuplicateMembers() { + assertThrows(JsonRpcException.class, () -> ValidationProfileExample.parseAndValidateStrictResponses(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":"pong"} + """)); + } + + @Test + void strictResponseProfileRejectsRequestFields() { + assertThrows(JsonRpcException.class, () -> ValidationProfileExample.parseAndValidateStrictResponses(""" + {"jsonrpc":"2.0","id":1,"result":"pong","method":"ping"} + """)); + } +} diff --git a/samples/spring-boot-demo/README.md b/samples/spring-boot-demo/README.md index 94edbcf..3c9a48e 100644 --- a/samples/spring-boot-demo/README.md +++ b/samples/spring-boot-demo/README.md @@ -10,6 +10,14 @@ From repository root: ./gradlew -p samples/spring-boot-demo bootRun ``` +## Run Tests + +From repository root: + +```bash +./gradlew -p samples/spring-boot-demo test +``` + Endpoint: - URL: `http://localhost:8080/jsonrpc` @@ -37,6 +45,12 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"ping","id":1}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":1,"result":"pong"} +``` + ### 2. Single-parameter DTO binding (`greet`) ```bash @@ -45,6 +59,12 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"greet","params":{"name":"developer"},"id":2}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":2,"result":"hello developer"} +``` + ### 3. Named params with `@JsonRpcParam` (`sum`) ```bash @@ -53,6 +73,12 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"sum","params":{"left":2,"right":3},"id":3}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":3,"result":5} +``` + ### 4. Positional params (`sum`) ```bash @@ -61,6 +87,12 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"sum","params":[2,3],"id":4}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":4,"result":5} +``` + ### 5. Manual registration (`manual.echo`) ```bash @@ -69,6 +101,12 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"manual.echo","id":5}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":5,"result":"echo"} +``` + ### 6. Typed registration (`typed.upper`, `typed.tags`) ```bash @@ -77,12 +115,24 @@ curl -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"typed.upper","params":{"value":"spring"},"id":6}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":6,"result":{"value":"SPRING"}} +``` + ```bash curl -s http://localhost:8080/jsonrpc \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","method":"typed.tags","id":7}' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":7,"result":["alpha","beta"]} +``` + ### 7. Notification (no response body) ```bash @@ -91,6 +141,11 @@ curl -i -s http://localhost:8080/jsonrpc \ -d '{"jsonrpc":"2.0","method":"ping"}' ``` +Expected response: + +- HTTP status: `204 No Content` +- body: empty + ### 8. Mixed batch (success + notification + error) ```bash @@ -103,6 +158,15 @@ curl -s http://localhost:8080/jsonrpc \ ]' ``` +Expected response: + +```json +[ + {"jsonrpc":"2.0","id":8,"result":"echo"}, + {"jsonrpc":"2.0","id":9,"error":{"code":-32601,"message":"Method not found"}} +] +``` + ### 9. Parse error ```bash @@ -111,6 +175,12 @@ curl -s http://localhost:8080/jsonrpc \ -d '{' ``` +Expected response: + +```json +{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}} +``` + ## Notification Executor Scenarios - Configured executor path is covered by @@ -130,6 +200,31 @@ curl -s http://localhost:8080/jsonrpc \ - Custom exception mapping path (`JsonRpcExceptionResolver` override) is covered by `GreetingRpcServiceCustomExceptionResolverIntegrationTest`. +## Validation Profile Scenarios + +Spring sample also demonstrates request/response validation key symmetry through properties: + +```yaml +jsonrpc: + validation: + request: + require-id-member: true + allow-fractional-id: false + reject-response-fields: true + reject-duplicate-members: true + response: + reject-request-fields: true + reject-duplicate-members: true + error-code: + policy: STANDARD_ONLY +``` + +Covered by `GreetingRpcServiceValidationProfilesIntegrationTest`: + +- request-side validation at the HTTP endpoint (`require-id-member`, fractional ID, polluted request fields, duplicate + members) +- response-side parser/validator beans (`reject-duplicate-members`, `reject-request-fields`, `error-code.policy`) + ## Test Coverage Entry Points - `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceIntegrationTest.java` @@ -140,3 +235,4 @@ curl -s http://localhost:8080/jsonrpc \ - `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` +- `src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceValidationProfilesIntegrationTest.java` diff --git a/samples/spring-boot-demo/src/main/resources/application.yml b/samples/spring-boot-demo/src/main/resources/application.yml index 67b5e7c..febf879 100644 --- a/samples/spring-boot-demo/src/main/resources/application.yml +++ b/samples/spring-boot-demo/src/main/resources/application.yml @@ -4,3 +4,29 @@ server: jsonrpc: path: /jsonrpc include-error-data: false + validation: + request: + require-json-rpc-version-20: true + require-id-member: false + allow-null-id: true + allow-string-id: true + allow-numeric-id: true + allow-fractional-id: true + reject-response-fields: false + reject-duplicate-members: false + params-type-violation-code-policy: INVALID_PARAMS + response: + require-json-rpc-version-20: true + require-id-member: true + allow-null-id: true + allow-string-id: true + allow-numeric-id: true + allow-fractional-id: true + require-exclusive-result-or-error: true + require-error-object-when-present: true + require-integer-error-code: true + require-string-error-message: true + reject-request-fields: false + reject-duplicate-members: false + error-code: + policy: ANY_INTEGER diff --git a/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceValidationProfilesIntegrationTest.java b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceValidationProfilesIntegrationTest.java new file mode 100644 index 0000000..77e6786 --- /dev/null +++ b/samples/spring-boot-demo/src/test/java/com/limehee/jsonrpc/sample/GreetingRpcServiceValidationProfilesIntegrationTest.java @@ -0,0 +1,96 @@ +package com.limehee.jsonrpc.sample; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.limehee.jsonrpc.core.DefaultJsonRpcResponseParser; +import com.limehee.jsonrpc.core.JsonRpcException; +import com.limehee.jsonrpc.core.JsonRpcIncomingResponse; +import com.limehee.jsonrpc.core.JsonRpcResponseValidator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import tools.jackson.databind.JsonNode; + +@SpringBootTest(properties = { + "jsonrpc.validation.request.require-id-member=true", + "jsonrpc.validation.request.allow-fractional-id=false", + "jsonrpc.validation.request.reject-response-fields=true", + "jsonrpc.validation.request.reject-duplicate-members=true", + "jsonrpc.validation.response.reject-duplicate-members=true", + "jsonrpc.validation.response.reject-request-fields=true", + "jsonrpc.validation.response.error-code.policy=STANDARD_ONLY" +}) +class GreetingRpcServiceValidationProfilesIntegrationTest extends AbstractJsonRpcIntegrationSupport { + + @Autowired + private DefaultJsonRpcResponseParser responseParser; + + @Autowired + private JsonRpcResponseValidator responseValidator; + + @Test + void rejectsNotificationWithoutIdWhenRequireIdMemberEnabled() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"ping"} + """); + + assertEquals(-32600, body.get("error").get("code").asInt()); + } + + @Test + void rejectsResponseFieldsInsideRequestWhenConfigured() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"ping","id":1,"result":"unexpected"} + """); + + assertEquals(-32600, body.get("error").get("code").asInt()); + } + + @Test + void rejectsFractionalRequestIdWhenConfigured() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"ping","id":1.5} + """); + + assertEquals(-32600, body.get("error").get("code").asInt()); + } + + @Test + void rejectsDuplicateRequestMembersWhenConfigured() throws Exception { + JsonNode body = invokeJsonRpc(""" + {"jsonrpc":"2.0","method":"ping","id":1,"id":2} + """); + + assertEquals(-32700, body.get("error").get("code").asInt()); + } + + @Test + void responseParserRejectsDuplicateMembersWhenConfigured() { + assertThrows(JsonRpcException.class, () -> responseParser.parse(""" + {"jsonrpc":"2.0","id":1,"id":2,"result":"pong"} + """)); + } + + @Test + void responseValidatorAppliesStandardOnlyErrorCodePolicy() { + JsonRpcIncomingResponse standardError = responseParser.parse(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"internal"}} + """).singleResponse().orElseThrow(); + JsonRpcIncomingResponse serverRangeError = responseParser.parse(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"server"}} + """).singleResponse().orElseThrow(); + + responseValidator.validate(standardError); + assertThrows(JsonRpcException.class, () -> responseValidator.validate(serverRangeError)); + } + + @Test + void responseValidatorRejectsRequestFieldsWhenConfigured() { + JsonRpcIncomingResponse pollutedResponse = responseParser.parse(""" + {"jsonrpc":"2.0","id":1,"result":"ok","method":"ping"} + """).singleResponse().orElseThrow(); + + assertThrows(JsonRpcException.class, () -> responseValidator.validate(pollutedResponse)); + } +}