From 6bbafccfe42f9ca6e7bbe0adbdbc2d42336a2801 Mon Sep 17 00:00:00 2001 From: cyb3r4nt <104218001+cyb3r4nt@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:37:32 +0300 Subject: [PATCH] Create JsonRpcInterceptor for objects validation with Jakarta Validation --- README.md | 232 ++- build.gradle | 72 +- .../BeanValidationJsonRpcInterceptor.java | 412 ++++++ .../JsonRpcServerBeanValidationTest.java | 1277 +++++++++++++++++ .../jsonrpc4j/DefaultErrorResolver.java | 9 + .../jsonrpc4j/JsonRpcBasicServer.java | 508 +++---- .../jsonrpc4j/JsonRpcInterceptor.java | 75 +- .../jsonrpc4j/JsonRpcServerException.java | 52 + .../com/googlecode/jsonrpc4j/JsonUtil.java | 95 +- .../googlecode/jsonrpc4j/ReflectionUtil.java | 154 +- .../JsonRpcServerAnnotatedParamTest.java | 77 +- 11 files changed, 2662 insertions(+), 301 deletions(-) create mode 100644 src/beanValidationSupport/java/com/github/briandilley/jsonrpc4j/beanvalidation/BeanValidationJsonRpcInterceptor.java create mode 100644 src/beanValidationSupportTest/java/com/github/briandilley/jsonrpc4j/beanvalidation/JsonRpcServerBeanValidationTest.java create mode 100644 src/main/java/com/googlecode/jsonrpc4j/JsonRpcServerException.java diff --git a/README.md b/README.md index 417ba016..4f744e47 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ JSON-RPC). * Annotations support * Custom error resolving * Composite services + * Objects validation with [Jakarta Validation](https://beanvalidation.org/) (server only at the moment) ## Maven -This project is built with Gralde. Be +This project is built with Gradle. Be sure to check the build.gradle for the dependencies if you're not using gradle. If you're already using spring you should have most (if not all) of the dependencies already - outside of maybe the @@ -150,7 +151,7 @@ be accessed by any JSON-RPC capable client, including the `JsonProxyFactoryBean` - + ``` In the case that your JSON-RPC requires named based parameters rather than indexed @@ -164,8 +165,8 @@ public interface UserService { } ``` -By default all error message responses contain the the message as returned by -Exception.getmessage() with a code of 0. This is not always desirable. +By default, all error message responses contain the message as returned by +`Exception.getMessage()` with a code of `0`. This is not always desirable. jsonrpc4j supports annotated based customization of these error messages and codes, for example: @@ -377,6 +378,229 @@ Of course, this is all possible in the Spring Framework as well: ``` } +### Requests and responses validation with Jakarta Validation + +It is possible to use [Jakarta Validation](https://beanvalidation.org/) annotations +on methods and returned values to validate objects. +jsonrpc4j provides an additional module, which can intercept method calls, +and validate method parameters and results with the `jakarta.validation.Validator`. + +#### Installation + +Validation support logic is packaged as a separate Maven artifact. +It is necessary to include it separately in the `build.gradle` or `pom.xml`. + +```groovy +... +dependencies { + ... + implementation('com.github.briandilley.jsonrpc4j:jsonrpc4j:X.Y.Z') + implementation('com.github.briandilley.jsonrpc4j:jsonrpc4j:X.Y.Z') { + capabilities { + requireCapability("com.github.briandilley.jsonrpc4j:bean-validation-support") + } + } + implementation( + // Contains validation API and annotations + 'jakarta.validation:jakarta.validation-api:3.1.1', + // Validation provider + 'org.hibernate.validator:hibernate-validator:9.0.1.Final' + ) + ... +} +... +``` + +```xml +... + + ... + + com.github.briandilley.jsonrpc4j + jsonrpc4j + X.Y.Z + + + com.github.briandilley.jsonrpc4j + jsonrpc4j + X.Y.Z + bean-validation-support + + + + + jakarta.validation + jakarta.validation-api + 3.1.1 + + + + org.hibernate.validator + hibernate-validator + 9.0.1.Final + + ... + +... +``` + +The `jakarta.validation:jakarta.validation-api` is required for the validation specific annotations. +The `org.hibernate.validator:hibernate-validator` is a reference implementation of the +Jakarta Validation specification, +this must be present in the JVM classpath if validation is enabled. +Choose versions, which are compatible with your JVM and Jakarta EE platform dependencies. + +#### Server validation + +```java + +// annotate necessary classes + +class User { + @Positive + final long id; + + @NotNull + @Size(min = 3, max = 255) + final String userName; + + @NotNull + @Size(min = 12) + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=]).{12,}$") + final String password; + + public User(long id, String userName, String password) { + this.id = id; + this.userName = userName; + this.password = password; + } +} + +interface UserService { + @Valid User createUser( + @JsonRpcParam("theUserName") + @NotNull + @Size(min = 3, max = 255) + String userName, + + @JsonRpcParam("thePassword") + @NotNull + @Size(min = 12, max = 4096) + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=]).{12,}$", + message = "Password must be at least 12 characters long," + + " and must contain small and capital letters," + + " numbers and special symbols @#$%^&+=" + ) + String password + ); +} + +class UserServiceImpl implements UserService{ + @Override + public User createUser(String userName, String password) { + return new User(1L, userName, password); + } +} + +UserService userService = new UserServiceImpl(); + +// create the jsonRpcServer +JsonRpcServer jsonRpcServer = new JsonRpcServer(userService, UserService.class); + +// create the bean validation interceptor with default jakarta.validation.ValidatorFactory +JsonRpcInterceptor beanValidationJsonRpcInterceptor = new BeanValidationJsonRpcInterceptor(); + +// or create with existing ValidatorFactory +// ValidatorFactory validatorFactory = getOrCreateValidatorFactory(); +// JsonRpcInterceptor beanValidationJsonRpcInterceptor = new BeanValidationJsonRpcInterceptor(validatorFactory); + +// Add validation interceptor +jsonRpcServer.getInterceptorList().add(beanValidationJsonRpcInterceptor); + +// Validation must be configured now + +ByteArrayOutputStream output = new ByteArrayOutputStream(); +int errorCode; +try { + errorCode = jsonRpcServer.handleRequest( + new ByteArrayInputStream( + ( + "{\"jsonrpc\": \"2.0\", \"id\": 54321, \"method\": \"createUser\", " + + "\"params\": {\"theUserName\": \"me\", \"thePassword\": \"123456\"}" + + "}" + ).getBytes(StandardCharsets.UTF_8) + ), + output + ); +} catch (IOException e) { + throw new RuntimeException("Failed to handle request", e); +} + +assert errorCode == -32602 : "Request with invalid parameters must produce the Invalid params (-32602) error"; +String errorResponse = new String(output.toByteArray(), StandardCharsets.UTF_8); +System.out.println(response); +``` + +The `errorResponse` looks like: + +```json +{ + "jsonrpc": "2.0", + "id": 54321, + "error": { + "code": -32602, + "message": "method parameters invalid", + "data": { + "errors": [ + { + "paramName": "thePassword", + "detail": "size must be between 12 and 4096", + "jsonPointer": "/thePassword" + }, + { + "paramName": "theUserName", + "detail": "size must be between 3 and 255", + "jsonPointer": "/theUserName" + }, + { + "paramName": "thePassword", + "detail": "Password must be at least 12 characters long, and must contain small and capital letters, numbers and special symbols @#$%^&+=", + "jsonPointer": "/thePassword" + } + ] + } + } +} +``` + +Error object can have the following fields: + +* `paramIndex` - the invalid method parameter index if parameters object is a JSON array. +This field may be absent if parameter names are used. +* `paramName` - the invalid method parameter name if parameters object is a JSON object +and a corresponding annotated method parameter has been found +(annotated with `@JsonRpcParam` or similar annotations). +Server may return a parameter index instead in the `paramIndex` parameter, +which is an index of a server's Java method parameter, +and not a JSON field position in the request object. +Either `paramIndex` or `paramName` is always returned. +* `detail` - the error message. Contains a detailed description of a problem. +* `jsonPointer` - the [RFC6901](https://www.rfc-editor.org/rfc/rfc6901) JSON Pointer, +which points to the exact location within the invalid JSON parameter +inside the JSON-RPC `params` request value. +Pointers are returned only for the recognized server parameters. + +##### Response validation + +The response objects are also validated by the `BeanValidationJsonRpcInterceptor`. + +If server fails to prepare a valid response value, +then the error `Internal error (-32603)` is written to the RPC method response. +Configure a standard or custom `ErrorResolver` for the `JsonRpcServer` +to receive and handle such response objects validation errors. +See also `JsonRpcServer.setShouldLogInvocationErrors(boolean shouldLogInvocationErrors)` +logging configuration option. ### `JsonRpcServer` settings explained The following settings apply to both the `JsonRpcServer` and `JsonServiceExporter`: diff --git a/build.gradle b/build.gradle index efc3f133..020e2fa2 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ buildscript { plugins { id('jacoco') + id('java-library') } repositories { @@ -81,6 +82,18 @@ jacocoTestReport { } } +sourceSets { + beanValidationSupport { + java { + srcDir 'src/beanvalidation/java' + } + } + beanValidationSupportTest { + compileClasspath += sourceSets.test.output + sourceSets.beanValidationSupport.output + runtimeClasspath += sourceSets.test.output + sourceSets.beanValidationSupport.output + } +} + java { registerFeature('servletSupport') { // TODO: create a separate sourceSet for this library feature. @@ -92,6 +105,20 @@ java { // Gradle is planning to break this in v9.0 usingSourceSet(sourceSets.main) } + registerFeature('beanValidationSupport') { + usingSourceSet(sourceSets.beanValidationSupport) + capability("com.github.briandilley.jsonrpc4j", "bean-validation-support", "1.0") + dependencies { + project(":") + } + withSourcesJar() + withJavadocJar() + } +} + +configurations { + beanValidationSupportImplementation.extendsFrom(implementation) + beanValidationSupportTestImplementation.extendsFrom(testImplementation, beanValidationSupportImplementation) } dependencies { @@ -131,9 +158,20 @@ dependencies { testImplementation("org.eclipse.jetty:jetty-servlet:${jettyVersion}") { exclude module: 'org.eclipse.jetty.orbit' } - testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl:2.24.3' - testRuntimeOnly 'org.apache.logging.log4j:log4j-core:2.24.3' + beanValidationSupportImplementation 'jakarta.validation:jakarta.validation-api:3.0.0' + beanValidationSupportImplementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + beanValidationSupportImplementation(project(":")) + + beanValidationSupportTestImplementation(project(":")) { + capabilities { + requireCapability("com.github.briandilley.jsonrpc4j:bean-validation-support") + } + } + + beanValidationSupportTestRuntimeOnly 'org.hibernate.validator:hibernate-validator:7.0.5.Final' + beanValidationSupportTestRuntimeOnly 'org.glassfish:jakarta.el:4.0.2' + beanValidationSupportTestRuntimeOnly 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.19.0' } @@ -143,16 +181,42 @@ jar { } } -task documentationJar(type: Jar) { +tasks.register('documentationJar', Jar) { archiveClassifier.set("javadoc") from javadoc } -task sourcesJar(type: Jar) { +tasks.register('sourcesJar', Jar) { archiveClassifier.set("sources") from sourceSets.main.allSource } +tasks.register('jacocoBeanValidationSupportTestReport', JacocoReport) { + description = 'Generates code coverage report for the beanValidationSupportTest task.' + group = 'verification' + + sourceSets sourceSets.beanValidationSupportTest + executionData beanValidationSupportTest + + reports { + xml.required = true + csv.required = true + html.required = true + } + mustRunAfter beanValidationSupportTest +} + +tasks.register('beanValidationSupportTest', Test) { + description = 'Runs bean validation module tests.' + group = 'verification' + + testClassesDirs = sourceSets.beanValidationSupportTest.output.classesDirs + classpath = sourceSets.beanValidationSupportTest.runtimeClasspath + shouldRunAfter test + finalizedBy jacocoBeanValidationSupportTestReport +} +check.dependsOn beanValidationSupportTest + artifacts { archives documentationJar, sourcesJar } diff --git a/src/beanValidationSupport/java/com/github/briandilley/jsonrpc4j/beanvalidation/BeanValidationJsonRpcInterceptor.java b/src/beanValidationSupport/java/com/github/briandilley/jsonrpc4j/beanvalidation/BeanValidationJsonRpcInterceptor.java new file mode 100644 index 00000000..5c14f144 --- /dev/null +++ b/src/beanValidationSupport/java/com/github/briandilley/jsonrpc4j/beanvalidation/BeanValidationJsonRpcInterceptor.java @@ -0,0 +1,412 @@ +package com.github.briandilley.jsonrpc4j.beanvalidation; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.ElementKind; +import jakarta.validation.Path; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.metadata.BeanDescriptor; +import jakarta.validation.metadata.MethodDescriptor; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import com.googlecode.jsonrpc4j.JsonRpcServerException; + +/** + * Validates requests and responses using Jakarta Bean Validation + */ +public class BeanValidationJsonRpcInterceptor implements JsonRpcInterceptor { + + private final ValidatorFactory validatorFactory; + + public BeanValidationJsonRpcInterceptor(ValidatorFactory validatorFactory) { + this.validatorFactory = validatorFactory; + } + + public BeanValidationJsonRpcInterceptor() { + this.validatorFactory = Validation.buildDefaultValidatorFactory(); + } + + @Override + public void preHandleJson(JsonNode json) { + // noop + } + + @Override + public void preHandle(Object target, Method method, List params) { + // noop + } + + @Override + public void preHandle( + Object target, + Method method, + JsonNode paramsJsonNode, List jsonParams, + List deserializedParams, + List deserializedParamsNames + ) { + Validator validator = this.validatorFactory.getValidator(); + BeanDescriptor beanDescriptor = validator.getConstraintsForClass(target.getClass()); + MethodDescriptor methodDescriptor = beanDescriptor.getConstraintsForMethod( + method.getName(), + method.getParameterTypes() + ); + if (methodDescriptor == null || !methodDescriptor.hasConstrainedParameters()) { + return; + } + + Set> constraintViolations; + try { + constraintViolations = validator + .forExecutables() + .validateParameters(target, method, deserializedParams.toArray()); + } catch (Exception e) { + throw new JsonRpcServerException( + JsonError.INTERNAL_ERROR.code, + JsonError.INTERNAL_ERROR.message, + null, + new IllegalStateException( + "Failed to validate method parameters with bean validation", + e + ) + ); + } + + if (!constraintViolations.isEmpty()) { + handleMethodParametersConstraintViolations( + target, + method, + jsonParams, + paramsJsonNode, + deserializedParams, + deserializedParamsNames, + constraintViolations + ); + } + } + + protected static void handleMethodParametersConstraintViolations( + Object target, + Method method, + List jsonParams, + JsonNode paramsJsonNode, + List deserializedParams, + List deserializedParamsNames, + Set> constraintViolations + ) { + List validationErrors = new ArrayList<>(constraintViolations.size()); + for (ConstraintViolation violation : constraintViolations) { + ValidationError validationError = createValidationError( + method, + paramsJsonNode, + deserializedParamsNames, + violation + ); + validationErrors.add(validationError); + } + + throw new JsonRpcServerException( + JsonError.METHOD_PARAMS_INVALID.code, + JsonError.METHOD_PARAMS_INVALID.message, + new ErrorData(validationErrors), + new ConstraintViolationException(constraintViolations) + ); + } + + private static ValidationError createValidationError( + Method method, + JsonNode paramsJsonNode, + List deserializedParamsNames, + ConstraintViolation violation + ) { + Iterator pathIterator = violation.getPropertyPath().iterator(); + + readAndCheckMethodName(pathIterator); + int paramIndex = readAndCheckParameterAndGetIndex(pathIterator); + + String paramName = null; + if (paramsJsonNode.isObject() && paramIndex < deserializedParamsNames.size()) { + paramName = deserializedParamsNames.get(paramIndex); + } + + StringBuilder jsonPointer = new StringBuilder(); + if (paramName != null) { + jsonPointer + .append('/') + .append(paramName); + } else if ( + paramsJsonNode.isArray() + && method.isVarArgs() + && method.getParameterCount() == 1 + && pathIterator.hasNext() + ) { + // skip index addition into json pointer here for varargs, + // this will be added later based on the argument position in varargs array + paramIndex = getParameterIndexInVarargsArray( + violation.getPropertyPath().iterator() + ); + } else { + jsonPointer + .append('/') + .append(paramIndex); + } + + appendRemainingPath(jsonPointer, pathIterator); + + return new ValidationError( + paramName == null ? paramIndex : null, + paramName, + violation.getMessage(), + jsonPointer.toString() + ); + } + + private static int getParameterIndexInVarargsArray(Iterator pathIterator) { + readAndCheckMethodName(pathIterator); + readAndCheckParameterAndGetIndex(pathIterator); + + Path.Node invalidObjectNode = pathIterator.next(); + + if (invalidObjectNode.isInIterable()) { + Integer arrayIndex = invalidObjectNode.getIndex(); + if (arrayIndex != null) { + return arrayIndex; + } + } + + throw new JsonRpcServerException( + JsonError.INTERNAL_ERROR.code, + JsonError.INTERNAL_ERROR.message, + null, + new IllegalStateException( + "Failed to get invalid object index in varargs array" + ) + ); + } + + private static void appendRemainingPath( + StringBuilder jsonPointer, + Iterator pathIterator + ) { + while (pathIterator.hasNext()) { + final Path.Node nextNode = pathIterator.next(); + final ElementKind kind = nextNode.getKind(); + + final String nodeName; + if ( + nextNode.isInIterable() + && ( + ElementKind.PROPERTY.equals(kind) + || ElementKind.CONTAINER_ELEMENT.equals(kind) + ) + ) { + Object mapKey = nextNode.getKey(); + Integer arrayIndex = nextNode.getIndex(); + if (mapKey != null) { + // Note: mapKey may be a user input, + // but has already been validated by the JSON parser, + // and this should be a valid JSON object field name. + // Object field names cannot have different types other that String. + if (ElementKind.PROPERTY.equals(kind)) { + nodeName = mapKey + "/" + nextNode.getName(); + } else { + nodeName = mapKey.toString(); + } + } else if (arrayIndex != null) { + if (ElementKind.PROPERTY.equals(kind)) { + nodeName = arrayIndex + "/" + nextNode.getName(); + } else { + nodeName = String.valueOf(arrayIndex); + } + } else { + throw new JsonRpcServerException( + JsonError.INTERNAL_ERROR.code, + JsonError.INTERNAL_ERROR.message, + null, + new IllegalStateException( + "Found invalid " + ConstraintViolation.class.getSimpleName() + + ": collection member has neither an index nor a key" + ) + ); + } + } else { + nodeName = nextNode.getName(); + } + + jsonPointer + .append('/') + .append(nodeName); + } + } + + private static void readAndCheckMethodName(Iterator pathIterator) { + Path.Node methodNode = pathIterator.next(); + if (!ElementKind.METHOD.equals(methodNode.getKind())) { + throw new IllegalStateException( + "method node expected, got : " + methodNode.getKind() + ); + } + } + + private static int readAndCheckParameterAndGetIndex(Iterator pathIterator) { + if (!pathIterator.hasNext()) { + throw new IllegalStateException("parameter node missing"); + } + Path.Node parameterNode = pathIterator.next(); + if (!ElementKind.PARAMETER.equals(parameterNode.getKind())) { + throw new IllegalStateException( + "parameter node expected, got : " + parameterNode.getKind() + ); + } + return parameterNode.as(Path.ParameterNode.class).getParameterIndex(); + } + + @Override + public void postHandle(Object target, Method method, List params, JsonNode result) { + // noop + } + + @Override + public void postHandle( + Object target, + Method method, + List jsonParams, + JsonNode paramsJsonNode, + List deserializedParams, + List detectedParamNames, + Object result + ) { + Validator validator = this.validatorFactory.getValidator(); + BeanDescriptor beanDescriptor = validator.getConstraintsForClass(target.getClass()); + MethodDescriptor methodDescriptor = beanDescriptor.getConstraintsForMethod( + method.getName(), + method.getParameterTypes() + ); + if (methodDescriptor == null || !methodDescriptor.hasConstrainedReturnValue()) { + return; + } + + Set> constraintViolations; + try { + constraintViolations = validator + .forExecutables() + .validateReturnValue(target, method, result); + } catch (Exception e) { + throw new JsonRpcServerException( + JsonError.INTERNAL_ERROR.code, + JsonError.INTERNAL_ERROR.message, + null, + new IllegalStateException( + "Failed to validate response object with bean validation", + e + ) + ); + } + + if (!constraintViolations.isEmpty()) { + handleResponseObjectConstraintViolations(constraintViolations); + } + } + + protected void handleResponseObjectConstraintViolations( + Set> constraintViolations + ) { + throw new JsonRpcServerException( + JsonError.INTERNAL_ERROR.code, + JsonError.INTERNAL_ERROR.message, + null, + new ConstraintViolationException(constraintViolations) + ); + } + + @Override + public void postHandleJson(JsonNode json) { + // noop + } + + protected static final class ErrorData { + private final List errors; + + protected ErrorData(List errors) { + this.errors = errors; + } + + public List getErrors() { + return errors; + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + protected static final class ValidationError { + /** + * Java method parameter index + */ + private final Integer paramIndex; + + /** + * Parameter name from the @JsonRpcParam or similar annotation. + */ + private final String paramName; + private final String detail; + /** + * RFC6901 JSON pointer + */ + private final String jsonPointer; + + protected ValidationError( + Integer paramIndex, + String paramName, + String detail, + String jsonPointer + ) { + this.paramIndex = paramIndex; + this.paramName = paramName; + this.detail = detail; + this.jsonPointer = jsonPointer; + } + + public Integer getParamIndex() { + return paramIndex; + } + + public String getParamName() { + return paramName; + } + + public String getDetail() { + return detail; + } + + public String getJsonPointer() { + return jsonPointer; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ValidationError)) { + return false; + } + ValidationError that = (ValidationError) o; + return Objects.equals(paramIndex, that.paramIndex) + && Objects.equals(paramName, that.paramName) + && Objects.equals(detail, that.detail) + && Objects.equals(jsonPointer, that.jsonPointer); + } + + @Override + public int hashCode() { + return Objects.hash(paramIndex, paramName, detail, jsonPointer); + } + } +} diff --git a/src/beanValidationSupportTest/java/com/github/briandilley/jsonrpc4j/beanvalidation/JsonRpcServerBeanValidationTest.java b/src/beanValidationSupportTest/java/com/github/briandilley/jsonrpc4j/beanvalidation/JsonRpcServerBeanValidationTest.java new file mode 100644 index 00000000..d29d0d99 --- /dev/null +++ b/src/beanValidationSupportTest/java/com/github/briandilley/jsonrpc4j/beanvalidation/JsonRpcServerBeanValidationTest.java @@ -0,0 +1,1277 @@ +package com.github.briandilley.jsonrpc4j.beanvalidation; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.easymock.EasyMock; +import org.easymock.EasyMockRunner; +import org.easymock.Mock; +import org.easymock.MockType; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.googlecode.jsonrpc4j.ErrorResolver; +import com.googlecode.jsonrpc4j.JsonRpcBasicServer; +import com.googlecode.jsonrpc4j.JsonRpcInterceptor; +import com.googlecode.jsonrpc4j.JsonRpcParam; +import com.googlecode.jsonrpc4j.JsonRpcServer; + +import static com.googlecode.jsonrpc4j.JsonRpcBasicServer.RESULT; +import static com.googlecode.jsonrpc4j.util.Util.*; +import static org.junit.Assert.*; + +@RunWith(EasyMockRunner.class) +public class JsonRpcServerBeanValidationTest { + @Mock(type = MockType.NICE) + private ServiceInterfaceWithBeanValidationAnnotations mockService; + + private ByteArrayOutputStream byteArrayOutputStream; + private JsonRpcBasicServer jsonRpcServer; + private ObjectMapper objectMapper; + private TestBeanValidationJsonRpcInterceptor testBeanValidationJsonRpcInterceptor; + + @Before + public void setup() { + byteArrayOutputStream = new ByteArrayOutputStream(); + objectMapper = new ObjectMapper(); + jsonRpcServer = new JsonRpcBasicServer(objectMapper, mockService, ServiceInterfaceWithBeanValidationAnnotations.class); + List interceptors = new ArrayList<>(1); + testBeanValidationJsonRpcInterceptor = new TestBeanValidationJsonRpcInterceptor(); + interceptors.add(testBeanValidationJsonRpcInterceptor); + jsonRpcServer.setInterceptorList(interceptors); + } + + // annotate necessary classes + + class User { + @Positive + final long id; + + @NotNull + @Size(min = 3, max = 255) + final String userName; + + @NotNull + @Size(min = 12) + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=]).{12,}$") + final String password; + + public User(long id, String userName, String password) { + this.id = id; + this.userName = userName; + this.password = password; + } + } + + interface UserService { + @Valid User createUser( + @JsonRpcParam("theUserName") + @NotNull + @Size(min = 3, max = 255) + String userName, + + @JsonRpcParam("thePassword") + @NotNull + @Size(min = 12) + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=]).{12,}$", + message = "Password must be at least 12 characters long and contain " + ) + String password + ); + } + + class UserServiceImpl implements UserService{ + @Override + public User createUser(String userName, String password) { + return new User(1L, userName, password); + } + } + + @Test + public void validationExampleFromTheReadmeDoc() { + UserService userService = new UserServiceImpl(); + +// create the jsonRpcServer + JsonRpcServer jsonRpcServer = new JsonRpcServer(userService, UserService.class); + +// create the bean validation interceptor with the default jakarta.validation.ValidatorFactory + JsonRpcInterceptor beanValidationJsonRpcInterceptor = new BeanValidationJsonRpcInterceptor(); + +// or create with existing ValidatorFactory +// ValidatorFactory validatorFactory = getOrCreateValidatorFactory(); +// JsonRpcInterceptor beanValidationJsonRpcInterceptor = new BeanValidationJsonRpcInterceptor(validatorFactory); + + +// Add validation interceptor + jsonRpcServer.getInterceptorList().add(beanValidationJsonRpcInterceptor); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int errorCode; + try { + errorCode = jsonRpcServer.handleRequest( + new ByteArrayInputStream( + ( + "{\"jsonrpc\": \"2.0\", \"id\": 54321, \"method\": \"createUser\", " + + "\"params\": {\"theUserName\": \"me\", \"thePassword\": \"123456\"}" + + "}" + ).getBytes(StandardCharsets.UTF_8) + ), + output + ); + } catch (IOException e) { + throw new RuntimeException("Failed to handle request", e); + } + + assert errorCode == -32602 : "Request with invalid parameters must produce the Invalid params (-32602) error"; + String response = new String(output.toByteArray(), StandardCharsets.UTF_8); + assertTrue(response.contains("-32602")); + assertTrue(response.contains("error")); + assertTrue(response.contains("theUserName")); + assertTrue(response.contains("thePassword")); + } + + @Test + public void callMethodWithNoParametersAndAnnotations() throws Exception { + EasyMock.expect(mockService.testMethod1(param1)).andReturn(param1); + EasyMock.replay(mockService); + jsonRpcServer.handleRequest( + messageWithListParamsStream(1, "testMethod1", param1), + byteArrayOutputStream + ); + assertEquals(param1, result().textValue()); + } + + @Test + public void callMethodWithValidatedParameterUsingArrayParams() throws Exception { + EasyMock.expect(mockService.testMethod2(EasyMock.anyObject())).andReturn(param1); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithListParamsStream(1, "testMethod2", (Object) null), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(0) + .assertDetailContainsTokens("null") + .assertJsonPointerEquals("/0"); + } + + @Test + public void callMethodWithValidatedCompositeParameterUsingObjectParams() throws Exception { + EasyMock.expect( + mockService.testMethod3( + EasyMock.anyString(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObject testObject = new TestObject(); + testObject.setNotNullField(null); + + jsonRpcServer.handleRequest( + messageWithMapParamsStream("testMethod3", + "stringParam", "stringValue", + "testObject", testObject + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamNameEquals("testObject") + .assertDetailContainsTokens("null") + .assertJsonPointerEquals("/testObject/notNullField"); + } + + @Test + public void callMethodWithValidatedCompositeParameterUsingArrayParams() throws Exception { + EasyMock + .expect( + mockService.testMethod3( + EasyMock.eq(param1), + EasyMock.anyObject() + ) + ) + .andReturn(param1); + EasyMock.replay(mockService); + + TestObject testObject = new TestObject(); + testObject.setNotNullField(null); + + jsonRpcServer.handleRequest( + messageWithListParamsStream(1, "testMethod3", param1, testObject), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(1) + .assertDetailContainsTokens("null") + .assertJsonPointerEquals("/1/notNullField"); + } + + + @Test + public void callMethodWithNestedInvalidParameterUsingObjectParams() throws Exception { + EasyMock.expect( + mockService.testMethod4( + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + + TestObjectHolder testObjectHolder = new TestObjectHolder(); + testObjectHolder.getObjectMap().put( + "invalidTestObj", + invalidTestObject + ); + + jsonRpcServer.handleRequest( + messageWithMapParamsStream("testMethod4", + "testObject", new TestObject(), + "testObjectHolder", testObjectHolder + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamNameEquals("testObjectHolder") + .assertDetailContainsTokens("less", "equal", "10") + .assertJsonPointerEquals( + "/testObjectHolder/objectMap/invalidTestObj/intValue" + ); + } + + @Test + public void callMethodWithNestedInvalidParameterUsingArrayParams() throws Exception { + EasyMock.expect( + mockService.testMethod4( + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setShortString("tooLongStringValue"); + + TestObjectHolder testObjectHolder = new TestObjectHolder(); + testObjectHolder.getObjectList().add(invalidTestObject); + testObjectHolder.getObjectList().add(new TestObject()); + + TestObject[] objects = new TestObject[3]; + objects[0] = new TestObject(); + objects[1] = new TestObject(); + TestObject testObject = new TestObject(); + testObject.setNotNullField(null); + objects[2] = testObject; + testObjectHolder.setObjectArray(objects); + + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod4", + new TestObject(), + testObjectHolder + ), + byteArrayOutputStream + ); + + JsonNode error = error(byteArrayOutputStream); + + List validationErrors = + assertThatErrorContainsAListOfValidationErrors(error); + + assertEquals(2, validationErrors.size()); + + for (ValidationError validationError : validationErrors) { + validationError.assertParamIndexEquals(1); + + String jsonPointer = validationError.getJsonPointer(); + assertNotNull(jsonPointer); + + // the exact order of validation errors is not specified + if (jsonPointer.contains("objectList")) { + validationError + .assertDetailContainsTokens("size", "between", "1", "16") + .assertJsonPointerEquals("/1/objectList/1/shortString"); + } else if (jsonPointer.contains("objectArray")) { + validationError + .assertDetailContainsTokens("null") + .assertJsonPointerEquals("/1/objectArray/2/notNullField"); + } else { + fail("unexpected validation error occured: " + jsonPointer); + } + } + } + + @Test + public void callMethodWithInvalidCollectionSizes() throws Exception { + EasyMock.expect( + mockService.testMethod4( + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObjectHolder testObjectHolder = new TestObjectHolder(); + + TestObject[] objects = new TestObject[4]; + for (int i = 0; i < 4; i++) { + testObjectHolder.getObjectList().add(new TestObject()); + testObjectHolder.getObjectMap().put("testObject" + i, new TestObject()); + objects[i] = new TestObject(); + } + testObjectHolder.setObjectArray(objects); + + + jsonRpcServer.handleRequest( + messageWithMapParamsStream("testMethod4", + "testObject", new TestObject(), + "testObjectHolder", testObjectHolder + ), + byteArrayOutputStream + ); + + List validationErrors = + assertThatErrorContainsAListOfValidationErrors(error(byteArrayOutputStream)); + + assertEquals(3, validationErrors.size()); + + for (ValidationError validationError : validationErrors) { + validationError.assertParamNameEquals("testObjectHolder"); + + + String jsonPointer = validationError.getJsonPointer(); + assertNotNull(jsonPointer); + + validationError.assertDetailContainsTokens("size", "between", "1", "3"); + + // the exact order of validation errors is not specified + if (jsonPointer.contains("objectList")) { + assertEquals( + "/testObjectHolder/objectList", + jsonPointer + ); + } else if (jsonPointer.contains("objectMap")) { + assertEquals( + "/testObjectHolder/objectMap", + jsonPointer + ); + } else if (jsonPointer.contains("objectArray")) { + assertEquals( + "/testObjectHolder/objectArray", + jsonPointer + ); + } else { + fail("unexpected validation error occured: " + jsonPointer); + } + } + } + + @Test + public void callMethodWithInvalidMapKey() throws Exception { + EasyMock.expect( + mockService.testMethod4( + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObjectHolder testObjectHolder = new TestObjectHolder(); + testObjectHolder.getObjectMap().put("veryLongKeyString", new TestObject()); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod4", + new TestObject(), + testObjectHolder + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(1) + .assertDetailContainsTokens("size", "between", "1", "16") + .assertJsonPointerEquals("/1/objectMap/veryLongKeyString"); + } + + @Test + public void callMethodWithInvalidIntegerInTheArray() throws Exception { + EasyMock.expect( + mockService.testMethod4( + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObjectHolder testObjectHolder = new TestObjectHolder(); + testObjectHolder.getIntList().add(Integer.MAX_VALUE); + testObjectHolder.getIntList().add(2); + + jsonRpcServer.handleRequest( + messageWithMapParamsStream("testMethod4", + "testObject", new TestObject(), + "testObjectHolder", testObjectHolder + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamNameEquals("testObjectHolder") + .assertDetailContainsTokens("less", "equal", "10") + .assertJsonPointerEquals("/testObjectHolder/intList/1"); + } + + @Test + public void callMethodWithParameterWhichHasOptionalFields() throws Exception { + // java.util.Optional fields require a separate Jackson module. + Jdk8Module module = new Jdk8Module(); + module.configureReadAbsentAsNull(true); + objectMapper.registerModule(module); + + EasyMock.expect( + mockService.testMethod5( + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + OptionalListHolder optionalListHolder = new OptionalListHolder(); + + List> optList = optionalListHolder.getOptList().get(); + + OptionalFieldHolder optionalFieldHolder = new OptionalFieldHolder(); + optionalFieldHolder.setOptString(Optional.of("veryLongStringForOptional")); + optList.add(Optional.of(optionalFieldHolder)); + + HashMap request = messageOfStream( + 1, + "testMethod5", + new Object[] { optionalListHolder } + ); + ByteArrayInputStream inputStream = new ByteArrayInputStream( + objectMapper.writeValueAsBytes(request) + ); + + jsonRpcServer.handleRequest( + inputStream, + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(0) + .assertDetailContainsTokens("size", "between", "1", "16") + .assertJsonPointerEquals("/0/optList/1/optString"); + } + + @Test + public void callMethodWithSingleVarargsParamUsingObjectParams() throws Exception { + EasyMock.expect(mockService.testMethod6(EasyMock.anyObject())).andReturn(intParam1); + EasyMock.replay(mockService); + + List testObjects = new ArrayList<>(); + testObjects.add(new TestObject()); + testObjects.add(new TestObject()); + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + testObjects.add(invalidTestObject); + + jsonRpcServer.handleRequest( + messageWithMapParamsStream( + "testMethod6", + "testObjects", + testObjects + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamNameEquals("testObjects") + .assertDetailContainsTokens("less", "equal", "10") + .assertJsonPointerEquals("/testObjects/2/intValue"); + } + + @Test + public void callMethodWithSingleVarargsParamUsingArrayParams() throws Exception { + EasyMock.expect(mockService.testMethod6(EasyMock.anyObject())).andReturn(intParam1); + EasyMock.replay(mockService); + + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + + TestObject[] testObjects = new TestObject[3]; + testObjects[0] = new TestObject(); + testObjects[1] = invalidTestObject; + testObjects[2] = new TestObject(); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod6", + (Object[]) testObjects + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(1) + .assertDetailContainsTokens("less", "equal", "10") + .assertJsonPointerEquals("/1/intValue"); + } + + @Test + public void callMethodWithSingleVarargsParamUsingArrayOfArrayParams() throws Exception { + EasyMock.expect( + mockService.testMethod7( + EasyMock.anyObject(), + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(intParam1); + EasyMock.replay(mockService); + + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + + TestObject[][] testObjects = new TestObject[3][3]; + + testObjects[0] = new TestObject[]{ + new TestObject(), + new TestObject(), + new TestObject() + }; + testObjects[1] = new TestObject[]{ + new TestObject(), + invalidTestObject, + new TestObject(), + }; + testObjects[2] = new TestObject[]{ + new TestObject(), + new TestObject(), + new TestObject() + }; + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod7", + (Object[]) testObjects + ), + byteArrayOutputStream + ); + + // Validation of nested arrays does not work at the moment. + // This test should break if a validation provider starts to support it. + assertEquals(intParam1, result().intValue()); + } + + @Test + public void callMethodWithSingleVarargsParamWithoutParameters() throws Exception { + EasyMock.expect(mockService.testMethod6()).andReturn(intParam1); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod6" + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(0) + .assertDetailContainsTokens("empty") + .assertJsonPointerEquals("/0"); + } + + @Test + public void callMethodWithListOfListsParams() throws Exception { + EasyMock.expect(mockService.testMethod8(EasyMock.anyObject())).andReturn(intParam1); + EasyMock.replay(mockService); + + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + + List> testObjects = new ArrayList<>(3); + + List testObjects1 = new ArrayList<>(3); + testObjects1.add(new TestObject()); + testObjects1.add(new TestObject()); + testObjects1.add(new TestObject()); + testObjects.add(testObjects1); + + List testObjects2 = new ArrayList<>(3); + testObjects2.add(new TestObject()); + testObjects2.add(invalidTestObject); + testObjects2.add(new TestObject()); + testObjects.add(testObjects2); + + List testObjects3 = new ArrayList<>(3); + testObjects3.add(new TestObject()); + testObjects3.add(new TestObject()); + testObjects3.add(new TestObject()); + testObjects.add(testObjects3); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod8", + testObjects + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(0) + .assertDetailContainsTokens("less", "equal", "10") + .assertJsonPointerEquals("/0/1/1/intValue"); + } + + @Test + public void callMethodWithVarargsIntsUsingObjectParams() throws Exception { + EasyMock.expect( + mockService.testMethod9( + EasyMock.anyInt(), + EasyMock.anyString(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithMapParamsStream( + "testMethod9", + "intParam", intParam1, + "stringParam", param1, + "testInts", new int[] {} + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamNameEquals("testInts") + .assertDetailContainsTokens("empty") + .assertJsonPointerEquals("/testInts"); + } + + @Test + public void callMethodWithVarargsAndRegularParamsInFrontUsingArraysParams() throws Exception { + EasyMock.expect( + mockService.testMethod10( + EasyMock.anyObject(), + EasyMock.anyObject(), + EasyMock.anyObject() + ) + ).andReturn(param1); + EasyMock.replay(mockService); + + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod10", + new TestObject(), + new TestObject(), + new TestObject[] { + // the remaining parameters still need to be passed in an array, + // it can work without it only if method has one varargs parameter + new TestObject(), + invalidTestObject, + new TestObject() + } + ), + byteArrayOutputStream + ); + + assertThatErrorContainsOneValidationError(error(byteArrayOutputStream)) + .assertParamIndexEquals(2) + .assertDetailContainsTokens("less", "equal", "10") + .assertJsonPointerEquals("/2/1/intValue"); + } + + @Test + public void callMethodWithSimpleResponseObjectValidation() throws Exception { + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setIntValue(Integer.MAX_VALUE); + + EasyMock.expect(mockService.testMethod11(param1)).andReturn(invalidTestObject); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod11", + param1 + ), + byteArrayOutputStream + ); + + JsonNode error = error(byteArrayOutputStream); + assertNotNull(error); + assertErrorCodeIsInternalError(error); + } + + @Test + public void callMethodWithComplexResponseObjectValidation() throws Exception { + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setNotNullField(null); + + TestObjectHolder testObjectHolder = new TestObjectHolder(); + testObjectHolder.getObjectMap().put( + "invalidTestObj", + invalidTestObject + ); + + EasyMock.expect(mockService.testMethod12(param1)).andReturn(testObjectHolder); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod12", + param1 + ), + byteArrayOutputStream + ); + + JsonNode error = error(byteArrayOutputStream); + assertNotNull(error); + assertErrorCodeIsInternalError(error); + + assertEquals(1, testBeanValidationJsonRpcInterceptor.constraintViolations.size()); + ConstraintViolation constraintViolation = + testBeanValidationJsonRpcInterceptor.constraintViolations.iterator().next(); + assertEquals( + testObjectHolder, + constraintViolation.getExecutableReturnValue() + ); + assertEquals( + "testMethod12..objectMap[invalidTestObj].notNullField", + constraintViolation.getPropertyPath().toString() + ); + assertTrue(constraintViolation.getMessage().contains("null")); + } + + @Test + public void callMethodWithPrimitiveVarargsAndResponseObjectValidation() throws Exception { + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setShortString("tooLongStringValue1TooLongStringValue2"); + + EasyMock.expect(mockService.testMethod13(intParam1, intParam2)).andReturn(invalidTestObject); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod13", + intParam1, + intParam2 + ), + byteArrayOutputStream + ); + + JsonNode error = error(byteArrayOutputStream); + assertNotNull(error); + assertErrorCodeIsInternalError(error); + + assertEquals(1, testBeanValidationJsonRpcInterceptor.constraintViolations.size()); + ConstraintViolation constraintViolation = + testBeanValidationJsonRpcInterceptor.constraintViolations.iterator().next(); + assertEquals( + invalidTestObject, + constraintViolation.getExecutableReturnValue() + ); + assertEquals( + "testMethod13..shortString", + constraintViolation.getPropertyPath().toString() + ); + String message = constraintViolation.getMessage(); + assertTrue( + message.contains("size") + && message.contains("between") + && message.contains("1") + && message.contains("16") + ); + } + + @Test + public void callMethodWithNonPrimitiveVarargsAndResponseObjectValidation() throws Exception { + TestObject invalidTestObject = new TestObject(); + invalidTestObject.setNotNullField(null); + + EasyMock.expect(mockService.testMethod14(param1, param2)).andReturn(invalidTestObject); + EasyMock.replay(mockService); + + jsonRpcServer.handleRequest( + messageWithListParamsStream( + 1, + "testMethod14", + param1, + param2 + ), + byteArrayOutputStream + ); + + JsonNode error = error(byteArrayOutputStream); + assertNotNull(error); + assertErrorCodeIsInternalError(error); + + assertEquals(1, testBeanValidationJsonRpcInterceptor.constraintViolations.size()); + ConstraintViolation constraintViolation = + testBeanValidationJsonRpcInterceptor.constraintViolations.iterator().next(); + assertEquals( + invalidTestObject, + constraintViolation.getExecutableReturnValue() + ); + assertEquals( + "testMethod14..notNullField", + constraintViolation.getPropertyPath().toString() + ); + assertTrue( + constraintViolation.getMessage().contains("null") + ); + } + + private static void assertErrorCodeIsMethodParamsInvalid(JsonNode error) { + assertEquals( + ErrorResolver.JsonError.METHOD_PARAMS_INVALID.code, + errorCode(error).intValue() + ); + assertEquals( + ErrorResolver.JsonError.METHOD_PARAMS_INVALID.message, + errorMessage(error).textValue() + ); + } + + private static void assertErrorCodeIsInternalError(JsonNode error) { + assertEquals( + ErrorResolver.JsonError.INTERNAL_ERROR.code, + errorCode(error).intValue() + ); + assertEquals( + ErrorResolver.JsonError.INTERNAL_ERROR.message, + errorMessage(error).textValue() + ); + } + + private static List assertThatErrorContainsAListOfValidationErrors( + JsonNode error + ) throws IOException { + assertNotNull(error); + assertErrorCodeIsMethodParamsInvalid(error); + + JsonNode errorData = errorData(error); + assertNotNull(errorData); + + JsonNode errors = errorData.get("errors"); + + ObjectMapper objectMapper = new ObjectMapper(); + List validationErrors = + objectMapper.readerForListOf(ValidationError.class).readValue(errors); + + assertNotNull(validationErrors); + assertFalse(validationErrors.isEmpty()); + + return validationErrors; + } + + private static ValidationError assertThatErrorContainsOneValidationError( + JsonNode error + ) throws IOException { + List validationErrors = + assertThatErrorContainsAListOfValidationErrors(error); + + assertEquals(1, validationErrors.size()); + + return validationErrors.get(0); + } + + private JsonNode result() throws IOException { + return decodeAnswer(byteArrayOutputStream).get(RESULT); + } + + protected interface ServiceInterfaceWithBeanValidationAnnotations { + String testMethod1(String stringParam); + + String testMethod2(@Valid @NotNull @Size(max = 10) String stringParam); + + String testMethod3( + @JsonRpcParam("stringParam") String stringParam, + @JsonRpcParam("testObject") @Valid TestObject testObject + ); + + String testMethod4( + @JsonRpcParam("testObject") @Valid TestObject testObject, + @JsonRpcParam("testObjectHolder") @Valid TestObjectHolder testObjectHolder + ); + + String testMethod5( + @Valid OptionalListHolder optList + ); + + Integer testMethod6( + @JsonRpcParam("testObjects") + @Valid + @NotEmpty + TestObject... testObjects + ); + + // array of arrays does not work currently, + // it may be better to use collections instead + Integer testMethod7( + @JsonRpcParam("testObjects") + @Valid + @NotEmpty + TestObject[]... testObjects + ); + + Integer testMethod8( + @JsonRpcParam("testObjects") + @Valid + @NotEmpty + List<@Valid List<@Valid TestObject>> testObjects + ); + + String testMethod9( + @JsonRpcParam("intParam") @Min(1) @Max(10) int intParam, + @JsonRpcParam("stringParam") String stringParam, + @JsonRpcParam("testInts") + @Valid + @NotEmpty + int... testInts + ); + + String testMethod10( + @Valid TestObject firstTestObject, + @Valid TestObject secondTestObject, + @Valid TestObject... otherTestObjects + ); + + @Valid TestObject testMethod11(String param); + + @Valid TestObjectHolder testMethod12(String param); + + @Valid TestObject testMethod13(int... intParams); + + @Valid TestObject testMethod14(String... strings); + } + + private static final class ValidationError { + private Integer paramIndex; + private String paramName; + private String detail; + private String jsonPointer; + + public Integer getParamIndex() { + return paramIndex; + } + + public void setParamIndex(Integer paramIndex) { + this.paramIndex = paramIndex; + } + + public String getParamName() { + return paramName; + } + + public void setParamName(String paramName) { + this.paramName = paramName; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public String getJsonPointer() { + return jsonPointer; + } + + public void setJsonPointer(String jsonPointer) { + this.jsonPointer = jsonPointer; + } + + public ValidationError assertParamIndexEquals(int expectedIndex) { + assertEquals(Integer.valueOf(expectedIndex), getParamIndex()); + assertNull(getParamName()); + return this; + } + + public ValidationError assertParamNameEquals(String expectedParamName) { + assertEquals(expectedParamName, getParamName()); + assertNull(getParamIndex()); + return this; + } + + public ValidationError assertDetailContainsTokens(String... tokens) { + String actualDetail = getDetail(); + assertNotNull(actualDetail); + String actualDetailLowerCase = actualDetail.toLowerCase(Locale.ROOT); + for (String token : tokens) { + assertTrue( + actualDetailLowerCase.contains(token) + ); + } + return this; + } + + public void assertJsonPointerEquals(String expectedJsonPointer) { + assertEquals(expectedJsonPointer, getJsonPointer()); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ValidationError)) return false; + ValidationError that = (ValidationError) o; + return Objects.equals(paramIndex, that.paramIndex) + && Objects.equals(paramName, that.paramName) + && Objects.equals(detail, that.detail) + && Objects.equals(jsonPointer, that.jsonPointer); + } + + @Override + public int hashCode() { + return Objects.hash(paramIndex, paramName, detail, jsonPointer); + } + } + + protected static final class TestObject { + @NotNull + private String notNullField; + + @Size(min = 1, max = 16) + private String shortString; + + @Min(0) + @Max(10) + private int intValue; + + public TestObject() { + this.notNullField = "notNullStringValue"; + this.shortString = "shortStringValue"; + this.intValue = 1; + } + + public String getNotNullField() { + return notNullField; + } + + public void setNotNullField(String notNullField) { + this.notNullField = notNullField; + } + + public String getShortString() { + return shortString; + } + + public void setShortString(String shortString) { + this.shortString = shortString; + } + + public int getIntValue() { + return intValue; + } + + public void setIntValue(int intValue) { + this.intValue = intValue; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof TestObject)) return false; + TestObject that = (TestObject) o; + return intValue == that.intValue + && Objects.equals(notNullField, that.notNullField) + && Objects.equals(shortString, that.shortString); + } + + @Override + public int hashCode() { + return Objects.hash(notNullField, shortString, intValue); + } + } + + protected static final class TestObjectHolder { + TestObject testObject; + + @NotEmpty + @Size(min = 1, max = 3) + List<@Valid TestObject> objectList; + + @NotEmpty + @Size(min = 1, max = 3) + Map<@Valid @Size(min = 1, max = 16) String, @Valid TestObject> objectMap; + + @Valid + @NotEmpty + @Size(min = 1, max = 3) + TestObject[] objectArray; + + @NotEmpty + @Size(min = 1, max = 3) + List<@Valid @Min(0) @Max(10) Integer> intList; + + public TestObjectHolder() { + this.testObject = new TestObject(); + + this.objectList = new ArrayList<>(); + this.objectList.add(new TestObject()); + + this.objectMap = new LinkedHashMap<>(); + this.objectMap.put("testObject", new TestObject()); + + this.objectArray = new TestObject[]{new TestObject()}; + + this.intList = new ArrayList<>(); + this.intList.add(0); + } + + public TestObject getTestObject() { + return testObject; + } + + public void setTestObject(TestObject testObject) { + this.testObject = testObject; + } + + public List getObjectList() { + return objectList; + } + + public void setObjectList(List objectList) { + this.objectList = objectList; + } + + public Map getObjectMap() { + return objectMap; + } + + public void setObjectMap(Map objectMap) { + this.objectMap = objectMap; + } + + public TestObject[] getObjectArray() { + return objectArray; + } + + public void setObjectArray(TestObject[] objectArray) { + this.objectArray = objectArray; + } + + public List getIntList() { + return intList; + } + + public void setIntList(List intList) { + this.intList = intList; + } + } + + protected static final class OptionalFieldHolder { + + // java.util.Optional types in class fields are bad and should not be used, + // but there may be existing code, which already uses those. + @SuppressWarnings("all") + Optional<@Valid @Size(min = 1, max = 16) String> optString; + + public OptionalFieldHolder() { + this.optString = Optional.of("shortStringValue"); + } + + public Optional getOptString() { + return optString; + } + + @SuppressWarnings("all") + public void setOptString(Optional optString) { + this.optString = optString; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof OptionalFieldHolder)) return false; + OptionalFieldHolder that = (OptionalFieldHolder) o; + return Objects.equals(optString, that.optString); + } + + @Override + public int hashCode() { + return Objects.hashCode(optString); + } + } + + protected static final class OptionalListHolder { + @SuppressWarnings("all") + Optional<@Valid List<@Valid Optional<@Valid OptionalFieldHolder>>> optList; + + public OptionalListHolder() { + this.optList = Optional.of(new ArrayList<>()); + this.optList.get().add(Optional.of(new OptionalFieldHolder())); + } + + public Optional>> getOptList() { + return optList; + } + + @SuppressWarnings("all") + public void setOptList(Optional>> optList) { + this.optList = optList; + } + } + + private static class TestBeanValidationJsonRpcInterceptor + extends BeanValidationJsonRpcInterceptor { + + private final Set> constraintViolations = new HashSet<>(); + + @Override + protected void handleResponseObjectConstraintViolations( + Set> constraintViolations + ) { + this.constraintViolations.addAll(constraintViolations); + super.handleResponseObjectConstraintViolations(constraintViolations); + } + } +} diff --git a/src/main/java/com/googlecode/jsonrpc4j/DefaultErrorResolver.java b/src/main/java/com/googlecode/jsonrpc4j/DefaultErrorResolver.java index c1de8d8e..191f36f3 100644 --- a/src/main/java/com/googlecode/jsonrpc4j/DefaultErrorResolver.java +++ b/src/main/java/com/googlecode/jsonrpc4j/DefaultErrorResolver.java @@ -20,6 +20,15 @@ public enum DefaultErrorResolver implements ErrorResolver { * {@inheritDoc} */ public JsonError resolveError(Throwable t, Method method, List arguments) { + if (t instanceof JsonRpcServerException) { + JsonRpcServerException serverException = (JsonRpcServerException) t; + return new JsonError( + serverException.getCode(), + serverException.getMessage(), + serverException.getData() + ); + } + return new JsonError(ERROR_NOT_HANDLED.code, t.getMessage(), new ErrorData(t.getClass().getName(), t.getMessage())); } diff --git a/src/main/java/com/googlecode/jsonrpc4j/JsonRpcBasicServer.java b/src/main/java/com/googlecode/jsonrpc4j/JsonRpcBasicServer.java index c41ce851..2dd24854 100644 --- a/src/main/java/com/googlecode/jsonrpc4j/JsonRpcBasicServer.java +++ b/src/main/java/com/googlecode/jsonrpc4j/JsonRpcBasicServer.java @@ -12,7 +12,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.lang.annotation.Annotation; import java.lang.reflect.*; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; @@ -71,7 +70,7 @@ public class JsonRpcBasicServer { private List interceptorList = new ArrayList<>(); private ExecutorService batchExecutorService = null; private long parallelBatchProcessingTimeout = Long.MAX_VALUE; - private final Set> webParamAnnotationClasses; + /** * Creates the server with the given {@link ObjectMapper} delegating @@ -97,7 +96,6 @@ public JsonRpcBasicServer(final ObjectMapper mapper, final Object handler, final this.mapper = mapper; this.handler = handler; this.remoteInterface = remoteInterface; - this.webParamAnnotationClasses = loadWebParamAnnotationClasses(); if (handler != null) { logger.debug("created server for interface {} with handler {}", remoteInterface, handler.getClass()); } @@ -125,33 +123,6 @@ public JsonRpcBasicServer(final Object handler) { this(new ObjectMapper(), handler, null); } - private Set> loadWebParamAnnotationClasses() { - final ClassLoader classLoader = JsonRpcBasicServer.class.getClassLoader(); - Set> webParamClasses = new HashSet<>(2); - for (String className: Arrays.asList("javax.jws.WebParam", "jakarta.jws.WebParam")) { - try { - Class clazz = - classLoader - .loadClass(className) - .asSubclass(Annotation.class); - // check that method with name "name" is present - clazz.getMethod(NAME); - webParamClasses.add(clazz); - } catch (ClassNotFoundException | NoSuchMethodException e) { - logger.debug("Could not find {}.{}", className, NAME); - } - } - - if (webParamClasses.isEmpty()) { - logger.debug( - "Could not find any @WebParam classes in classpath." + - " @WebParam support is disabled" - ); - } - - return Collections.unmodifiableSet(webParamClasses); - } - /** * Returns parameters into an {@link InputStream} of JSON data. * @@ -362,7 +333,7 @@ private JsonResponse getBatchResponseInParallel(ArrayNode node) { Map> responses = new HashMap<>(); for (int i = 0; i < node.size(); i++) { JsonNode jsonNode = node.get(i); - Object id = parseId(jsonNode.get(ID)); + Object id = JsonUtil.parseId(jsonNode.get(ID)); Future responseFuture = batchExecutorService.submit(() -> handleJsonNodeRequest(jsonNode)); responses.put(id, responseFuture); } @@ -426,7 +397,7 @@ private JsonResponse handleObject(final ObjectNode node) if (!isValidRequest(node)) { return createResponseError(VERSION, NULL, JsonError.INVALID_REQUEST); } - Object id = parseId(node.get(ID)); + Object id = JsonUtil.parseId(node.get(ID)); String jsonRpc = hasNonNullData(node, JSONRPC) ? node.get(JSONRPC).asText() : VERSION; if (!hasNonNullData(node, METHOD)) { @@ -456,7 +427,7 @@ private JsonResponse handleObject(final ObjectNode node) interceptor.preHandle(target, methodArgs.method, methodArgs.arguments); } // invocation - JsonNode result = invoke(target, methodArgs.method, methodArgs.arguments); + JsonNode result = invoke(target, methodArgs); handler.result = result; // interceptors postHandle for (JsonRpcInterceptor interceptor : interceptorList) { @@ -570,83 +541,169 @@ protected String getMethodName(final String methodName) { protected Object getHandler(String serviceName) { return handler; } - - /** - * Invokes the given method on the {@code handler} passing - * the given params (after converting them to beans\objects) - * to it. - * - * @param target optional service name used to locate the target object - * to invoke the Method on - * @param method the method to invoke - * @param params the params to pass to the method - * @return the return value (or null if no return) - * @throws IOException on error - * @throws IllegalAccessException on error - * @throws InvocationTargetException on error - */ - private JsonNode invoke(Object target, Method method, List params) throws IOException, IllegalAccessException, InvocationTargetException { - logger.debug("Invoking method: {} with args {}", method.getName(), params); - Object result; + /** + * Invokes the given method on the {@code handler} passing + * the given params (after converting them to beans\objects) + * to it. + * + * @param target optional service name used to locate the target object + * to invoke the Method on + * @param methodArgs the method to invoke and its params + * @return the return value (or null if no return) + * @throws IOException on error + * @throws IllegalAccessException on error + * @throws InvocationTargetException on error + */ + private JsonNode invoke(Object target, AMethodWithItsArgs methodArgs) throws IOException, IllegalAccessException, InvocationTargetException { + Method method = methodArgs.method; + List params = methodArgs.arguments; + + logger.debug("Invoking method: {} with args {}", method.getName(), params); + + Object result; if (method.getGenericParameterTypes().length == 1 && method.isVarArgs()) { - Class componentType = method.getParameterTypes()[0].getComponentType(); - result = componentType.isPrimitive() ? - invokePrimitiveVarargs(target, method, params, componentType) : - invokeNonPrimitiveVarargs(target, method, params, componentType); + Class componentType = method.getParameterTypes()[0].getComponentType(); + result = componentType.isPrimitive() ? + invokePrimitiveVarargs(target, method, params, componentType, methodArgs) : + invokeNonPrimitiveVarargs(target, method, componentType, methodArgs); } else { Object[] convertedParams = convertJsonToParameters(method, params); - if (convertedParameterTransformer != null) { - convertedParams = convertedParameterTransformer.transformConvertedParameters(target, convertedParams); - } - result = method.invoke(target, convertedParams); + if (convertedParameterTransformer != null) { + convertedParams = convertedParameterTransformer.transformConvertedParameters(target, convertedParams); + } + + List convertedParamsList = new ArrayList<>(convertedParams.length); + Collections.addAll(convertedParamsList, convertedParams); + + for (JsonRpcInterceptor interceptor : interceptorList) { + interceptor.preHandle( + target, + method, + methodArgs.paramsNode, params, + convertedParamsList, + methodArgs.argumentsNames + ); + } + + if (method.getGenericParameterTypes().length == 1 && method.isVarArgs()) { + convertedParams = new Object[]{convertedParams}; + } + + result = method.invoke(target, convertedParams); + + for (JsonRpcInterceptor interceptor : interceptorList) { + interceptor.postHandle( + target, + method, + params, + methodArgs.paramsNode, + convertedParamsList, + methodArgs.argumentsNames, + result + ); + } } - logger.debug("Invoked method: {}, result {}", method.getName(), result); + logger.debug("Invoked method: {}, result {}", method.getName(), result); - return hasReturnValue(method) ? mapper.valueToTree(result) : null; - } + return hasReturnValue(method) ? mapper.valueToTree(result) : null; + } - private Object invokePrimitiveVarargs(Object target, Method method, List params, Class componentType) throws IllegalAccessException, InvocationTargetException { + private Object invokePrimitiveVarargs(Object target, Method method, List params, Class componentType, AMethodWithItsArgs methodArgs) throws IllegalAccessException, InvocationTargetException { // need to cast to object here in order to support primitives. Object convertedParams = Array.newInstance(componentType, params.size()); for (int i = 0; i < params.size(); i++) { - Object object = convertAndLogParam(method, params, i); + Object object = convertAndLogParam(method, params, i, componentType); Array.set(convertedParams, i, object); } - return method.invoke(target, convertedParams); + List interceptorParams = new ArrayList<>(params.size()); + interceptorParams.add(convertedParams); + + for (JsonRpcInterceptor interceptor : interceptorList) { + interceptor.preHandle( + target, + method, + methodArgs.paramsNode, params, + interceptorParams, + methodArgs.argumentsNames + ); + } + + Object result = method.invoke(target, convertedParams); + + for (JsonRpcInterceptor interceptor : interceptorList) { + interceptor.postHandle( + target, + method, + params, + methodArgs.paramsNode, + interceptorParams, + methodArgs.argumentsNames, + result + ); + } + + return result; } - private Object invokeNonPrimitiveVarargs(Object target, Method method, List params, Class componentType) throws IllegalAccessException, InvocationTargetException { + private Object invokeNonPrimitiveVarargs(Object target, Method method, Class componentType, AMethodWithItsArgs methodArgs) throws IllegalAccessException, InvocationTargetException { + List params = methodArgs.arguments; Object[] convertedParams = (Object[]) Array.newInstance(componentType, params.size()); for (int i = 0; i < params.size(); i++) { - Object object = convertAndLogParam(method, params, i); + Object object = convertAndLogParam(method, params, i, componentType); convertedParams[i] = object; } - return method.invoke(target, new Object[] { convertedParams }); + List interceptorParams = new ArrayList<>(params.size()); + interceptorParams.add(convertedParams); + + for (JsonRpcInterceptor interceptor : interceptorList) { + interceptor.preHandle( + target, + method, + methodArgs.paramsNode, params, + interceptorParams, + methodArgs.argumentsNames + ); + } + + Object result = method.invoke(target, new Object[]{convertedParams}); + + for (JsonRpcInterceptor interceptor : interceptorList) { + interceptor.postHandle( + target, + method, + params, + methodArgs.paramsNode, + interceptorParams, + methodArgs.argumentsNames, + result + ); + } + + return result; } - private Object convertAndLogParam(Method method, List params, int paramIndex) { + private Object convertAndLogParam(Method method, List params, int paramIndex, Class componentType) { JsonNode jsonNode = params.get(paramIndex); - Class type = JsonUtil.getJavaTypeForJsonType(jsonNode); Object object; try { - object = mapper.convertValue(jsonNode, type); + object = mapper.convertValue(jsonNode, componentType); } catch (IllegalArgumentException e) { logger.debug( "[{}] Failed to convert param: {} -> {}", method.getName(), paramIndex, - type.getName() + componentType.getName() ); throw new ParameterConvertException(paramIndex, e); } - logger.debug("[{}] param: {} -> {}", method.getName(), paramIndex, type.getName()); + logger.debug("[{}] param: {} -> {}", method.getName(), paramIndex, componentType.getName()); return object; } @@ -762,13 +819,13 @@ private JsonResponse createResponseSuccess(String jsonRpc, Object id, JsonNode r */ private AMethodWithItsArgs findBestMethodByParamsNode(Set methods, JsonNode paramsNode) { if (hasNoParameters(paramsNode)) { - return findBestMethodUsingParamIndexes(methods, 0, null); + return findBestMethodUsingParamIndexes(methods, null); } AMethodWithItsArgs matchedMethod; if (paramsNode.isArray()) { - matchedMethod = findBestMethodUsingParamIndexes(methods, paramsNode.size(), (ArrayNode) paramsNode); + matchedMethod = findBestMethodUsingParamIndexes(methods, (ArrayNode) paramsNode); } else if (paramsNode.isObject()) { - matchedMethod = findBestMethodUsingParamNames(methods, collectFieldNames(paramsNode), (ObjectNode) paramsNode); + matchedMethod = findBestMethodUsingParamNames(methods, (ObjectNode) paramsNode); } else { throw new IllegalArgumentException("Unknown params node type: " + paramsNode); } @@ -777,18 +834,9 @@ private AMethodWithItsArgs findBestMethodByParamsNode(Set methods, JsonN } return matchedMethod; } - - private Set collectFieldNames(JsonNode paramsNode) { - Set fieldNames = new HashSet<>(); - Iterator itr = paramsNode.fieldNames(); - while (itr.hasNext()) { - fieldNames.add(itr.next()); - } - return fieldNames; - } - - private boolean hasNoParameters(JsonNode paramsNode) { - return isNullNodeOrValue(paramsNode); + + private boolean hasNoParameters(JsonNode paramsNode) { + return JsonUtil.isNullNodeOrValue(paramsNode); } /** @@ -797,19 +845,18 @@ private boolean hasNoParameters(JsonNode paramsNode) { * it as a {@link AMethodWithItsArgs} class. * * @param methods the {@link Method}s - * @param paramCount the number of expect parameters * @param paramNodes the parameters for matching types * @return the {@link AMethodWithItsArgs} */ - private AMethodWithItsArgs findBestMethodUsingParamIndexes(Set methods, int paramCount, ArrayNode paramNodes) { - int numParams = isNullNodeOrValue(paramNodes) ? 0 : paramNodes.size(); + private AMethodWithItsArgs findBestMethodUsingParamIndexes(Set methods, ArrayNode paramNodes) { + int numParams = JsonUtil.isNullNodeOrValue(paramNodes) ? 0 : paramNodes.size(); int bestParamNumDiff = Integer.MAX_VALUE; - Set matchedMethods = collectMethodsMatchingParamCount(methods, paramCount, bestParamNumDiff); + Set matchedMethods = collectMethodsMatchingParamCount(methods, numParams, bestParamNumDiff); if (matchedMethods.isEmpty()) { return null; } Method bestMethod = getBestMatchingArgTypeMethod(paramNodes, numParams, matchedMethods); - return new AMethodWithItsArgs(bestMethod, paramCount, paramNodes); + return new AMethodWithItsArgs(bestMethod, numParams, paramNodes); } private Method getBestMatchingArgTypeMethod(ArrayNode paramNodes, int numParams, Set matchedMethods) { @@ -853,7 +900,7 @@ private AMethodWithItsArgs findBestMethodForVarargs(Set methods, JsonNod private int getNumArgTypeMatches(ArrayNode paramNodes, int numParams, List> parameterTypes) { int numMatches = 0; for (int i = 0; i < parameterTypes.size() && i < numParams; i++) { - if (isMatchingType(paramNodes.get(i), parameterTypes.get(i))) { + if (JsonUtil.isMatchingType(paramNodes.get(i), parameterTypes.get(i))) { numMatches++; } } @@ -900,28 +947,28 @@ private boolean acceptMoreParam(int paramNumDiff) { private boolean hasLessOrEqualAbsParamDiff(int bestParamNumDiff, int paramNumDiff) { return Math.abs(paramNumDiff) <= Math.abs(bestParamNumDiff); } - + /** - * Finds the {@link Method} from the supplied {@link Set} that best matches the rest of the arguments supplied and - * returns it as a {@link AMethodWithItsArgs} class. - * - * @param methods the {@link Method}s - * @param paramNames the parameter allNames - * @param paramNodes the parameters for matching types - * @return the {@link AMethodWithItsArgs} - */ - private AMethodWithItsArgs findBestMethodUsingParamNames(Set methods, Set paramNames, ObjectNode paramNodes) { - ParameterCount max = new ParameterCount(); - + * Finds the {@link Method} from the supplied {@link Set} that best matches the rest of the arguments supplied and + * returns it as a {@link AMethodWithItsArgs} class. + * + * @param methods the {@link Method}s + * @param requestObject the parameters object from request + * @return the {@link AMethodWithItsArgs} + */ + private AMethodWithItsArgs findBestMethodUsingParamNames(Set methods, ObjectNode requestObject) { + Set paramNames = JsonUtil.collectFieldNames(requestObject); + + ParameterCount max = new ParameterCount(); for (Method method : methods) { List> parameterTypes = getParameterTypes(method); - + int typeNameCountDiff = parameterTypes.size() - paramNames.size(); if (!acceptParamCount(typeNameCountDiff)) { continue; } - - ParameterCount parStat = new ParameterCount(paramNames, paramNodes, parameterTypes, method); + + ParameterCount parStat = new ParameterCount(paramNames, requestObject, parameterTypes, method); if (!acceptParamCount(parStat.nameCount - paramNames.size())) { continue; } @@ -932,69 +979,15 @@ private AMethodWithItsArgs findBestMethodUsingParamNames(Set methods, Se if (max.method == null) { return null; } - return new AMethodWithItsArgs(max.method, paramNames, max.allNames, paramNodes); - + return new AMethodWithItsArgs(max.method, paramNames, max.allNames, requestObject); + } private boolean hasMoreMatches(int maxMatchingParams, int numMatchingParams) { return numMatchingParams > maxMatchingParams; } - - private boolean missingAnnotation(JsonRpcParam name) { - return name == null; - } - - /** - * Determines whether or not the given {@link JsonNode} matches - * the given type. This method is limited to a few java types - * only and shouldn't be used to determine with great accuracy - * whether or not the types match. - * - * @param node the {@link JsonNode} - * @param type the {@link Class} - * @return true if the types match, false otherwise - */ - @SuppressWarnings("SimplifiableIfStatement") - private boolean isMatchingType(JsonNode node, Class type) { - if (node.isNull()) { - return true; - } - if (node.isTextual()) { - return String.class.isAssignableFrom(type); - } - if (node.isNumber()) { - return isNumericAssignable(type); - } - if (node.isArray() && type.isArray()) { - return node.size() > 0 && isMatchingType(node.get(0), type.getComponentType()); - } - if (node.isArray()) { - return type.isArray() || Collection.class.isAssignableFrom(type); - } - if (node.isBinary()) { - return byteOrCharAssignable(type); - } - if (node.isBoolean()) { - return boolean.class.isAssignableFrom(type) || Boolean.class.isAssignableFrom(type); - } - if (node.isObject() || node.isPojo()) { - return !type.isPrimitive() && !String.class.isAssignableFrom(type) && - !Number.class.isAssignableFrom(type) && !Boolean.class.isAssignableFrom(type); - } - return false; - } - - private boolean byteOrCharAssignable(Class type) { - return byte[].class.isAssignableFrom(type) || Byte[].class.isAssignableFrom(type) || - char[].class.isAssignableFrom(type) || Character[].class.isAssignableFrom(type); - } - - private boolean isNumericAssignable(Class type) { - return Number.class.isAssignableFrom(type) || short.class.isAssignableFrom(type) || int.class.isAssignableFrom(type) - || long.class.isAssignableFrom(type) || float.class.isAssignableFrom(type) || double.class.isAssignableFrom(type); - } - - /** + + /** * Writes and flushes a value to the given {@link OutputStream} * and prevents Jackson from closing it. Also writes newline. * @@ -1011,38 +1004,8 @@ private void writeAndFlushValue(OutputStream output, JsonNode value) throws IOEx mapper.writeValue(new NoCloseOutputStream(output), value); output.write('\n'); } - - private Object parseId(JsonNode node) { - if (isNullNodeOrValue(node)) { - return null; - } - if (node.isDouble()) { - return node.asDouble(); - } - if (node.isFloatingPointNumber()) { - return node.asDouble(); - } - if (node.isInt()) { - return node.asInt(); - } - if (node.isLong()) { - return node.asLong(); - } - //TODO(donequis): consider parsing bigints - if (node.isIntegralNumber()) { - return node.asInt(); - } - if (node.isTextual()) { - return node.asText(); - } - throw new IllegalArgumentException("Unknown id type"); - } - - private boolean isNullNodeOrValue(JsonNode node) { - return node == null || node.isNull(); - } - - /** + + /** * Sets whether or not the server should be backwards * compatible to JSON-RPC 1.0. This only includes the * omission of the jsonrpc property on the request object, @@ -1157,15 +1120,14 @@ public void setParallelBatchProcessingTimeout(long parallelBatchProcessingTimeou */ private static class AMethodWithItsArgs { private final List arguments = new ArrayList<>(); + private final List argumentsNames = new ArrayList<>(); private final Method method; - - public AMethodWithItsArgs(Method method, int paramCount, ArrayNode paramNodes) { - this(method); - collectArgumentsBasedOnCount(method, paramCount, paramNodes); - } - - public AMethodWithItsArgs(Method method) { + private final JsonNode paramsNode; + + public AMethodWithItsArgs(Method method, int paramCount, ArrayNode paramsNode) { this.method = method; + this.paramsNode = paramsNode; + collectArgumentsBasedOnCount(method, paramCount, paramsNode); } private void collectArgumentsBasedOnCount(Method method, int paramCount, ArrayNode paramNodes) { @@ -1179,14 +1141,16 @@ private void collectArgumentsBasedOnCount(Method method, int paramCount, ArrayNo } } - public AMethodWithItsArgs(Method method, Set paramNames, List allNames, ObjectNode paramNodes) { - this(method); - collectArgumentsBasedOnName(method, paramNames, allNames, paramNodes); + public AMethodWithItsArgs(Method method, Set paramNames, List allNames, ObjectNode paramsNode) { + this.method = method; + this.paramsNode = paramsNode; + collectArgumentsBasedOnName(method, paramNames, allNames, paramsNode); } - public AMethodWithItsArgs(Method method, JsonNode jsonNode) { - this(method); - collectVarargsFromNode(jsonNode); + public AMethodWithItsArgs(Method method, JsonNode paramsNode) { + this.method = method; + this.paramsNode = paramsNode; + collectVarargsFromNode(paramsNode); } private void collectArgumentsBasedOnName(Method method, Set paramNames, List allNames, ObjectNode paramNodes) { @@ -1197,11 +1161,12 @@ private void collectArgumentsBasedOnName(Method method, Set paramNames, if (param != null && paramNames.contains(param.value())) { if (types[i].isArray() && method.isVarArgs() && numParameters == 1) { collectVarargsFromNode(paramNodes.get(param.value())); + argumentsNames.add(param.value()); } else { - addArgument(paramNodes.get(param.value())); + addArgumentAndName(paramNodes.get(param.value()), param.value()); } } else { - addArgument(NullNode.getInstance()); + addArgumentAndName(NullNode.getInstance(), null); } } } @@ -1216,9 +1181,7 @@ private void collectVarargsFromNode(JsonNode node) { if (node.isObject()) { ObjectNode objectNode = (ObjectNode) node; - Iterator> items = objectNode.fields(); - while (items.hasNext()) { - Map.Entry item = items.next(); + for (Map.Entry item : objectNode.properties()) { JsonNode name = JsonNodeFactory.instance.objectNode().put(item.getKey(),item.getKey()); addArgument(name.get(item.getKey())); addArgument(item.getValue()); @@ -1226,9 +1189,14 @@ private void collectVarargsFromNode(JsonNode node) { } } - public void addArgument(JsonNode argumentJsonNode) { + private void addArgument(JsonNode argumentJsonNode) { arguments.add(argumentJsonNode); } + + private void addArgumentAndName(JsonNode argumentJsonNode, String argName) { + addArgument(argumentJsonNode); + argumentsNames.add(argName); + } } private static class InvokeListenerHandler implements AutoCloseable { @@ -1255,85 +1223,51 @@ public void close() { } } - private class ParameterCount { - private final int typeCount; + private static class ParameterCount { + + private final int typeCount; private final int nameCount; private final List allNames; private final Method method; - public ParameterCount(Set paramNames, ObjectNode paramNodes, List> parameterTypes, Method method) { - this.allNames = getAnnotatedParameterNames(method); + public ParameterCount( + Set paramNames, + ObjectNode requestObject, + List> parameterTypes, + Method method + ) { + this.allNames = getAnnotatedParameterNames(method); this.method = method; int typeCount = 0; int nameCount = 0; - int at = 0; - - for (JsonRpcParam name : this.allNames) { - if (missingAnnotation(name)) { - continue; - } - String paramName = name.value(); - boolean hasParamName = paramNames.contains(paramName); - if (hasParamName) { - nameCount += 1; - } - if (hasParamName && isMatchingType(paramNodes.get(paramName), parameterTypes.get(at))) { - typeCount += 1; - } - at += 1; - } + + for (int i = 0; i < parameterTypes.size(); i++) { + JsonRpcParam name = this.allNames.get(i); + if (name == null) { + continue; + } + String paramName = name.value(); + boolean hasParamName = paramNames.contains(paramName); + if (hasParamName) { + nameCount += 1; + + JsonNode objectField = requestObject.get(paramName); + Class declaredParamType = parameterTypes.get(i); + if (JsonUtil.isMatchingType(objectField, declaredParamType)) { + typeCount += 1; + } + } + } + this.typeCount = typeCount; this.nameCount = nameCount; } - - @SuppressWarnings("Convert2streamapi") - private List getAnnotatedParameterNames(Method method) { - List parameterNames = new ArrayList<>(); - for (List webParamAnnotation : getWebParameterAnnotations(method)) { - if (!webParamAnnotation.isEmpty()) { - parameterNames.add(createNewJsonRcpParamType(webParamAnnotation.get(0))); - } - } - for (List annotation : getJsonRpcParamAnnotations(method)) { - if (!annotation.isEmpty()) { - parameterNames.add(annotation.get(0)); - } - } - return parameterNames; - } - - private List> getWebParameterAnnotations(Method method) { - List> annotations = new ArrayList<>(); - for (Class clazz : JsonRpcBasicServer.this.webParamAnnotationClasses) { - annotations.addAll( - ReflectionUtil.getParameterAnnotations(method, clazz) - ); - } - return annotations; - } - - private JsonRpcParam createNewJsonRcpParamType(final Annotation annotation) { - return new JsonRpcParam() { - public Class annotationType() { - return JsonRpcParam.class; - } - - public String value() { - try { - Method method = annotation.getClass().getMethod(NAME); - return (String) method.invoke(annotation); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - }; - } - - private List> getJsonRpcParamAnnotations(Method method) { - return ReflectionUtil.getParameterAnnotations(method, JsonRpcParam.class); - } - - public ParameterCount() { + + private static List getAnnotatedParameterNames(Method method) { + return ReflectionUtil.getAnnotatedParameterNames(method, logger); + } + + public ParameterCount() { typeCount = -1; nameCount = -1; allNames = null; diff --git a/src/main/java/com/googlecode/jsonrpc4j/JsonRpcInterceptor.java b/src/main/java/com/googlecode/jsonrpc4j/JsonRpcInterceptor.java index 6c01b4ef..9d791450 100644 --- a/src/main/java/com/googlecode/jsonrpc4j/JsonRpcInterceptor.java +++ b/src/main/java/com/googlecode/jsonrpc4j/JsonRpcInterceptor.java @@ -50,16 +50,87 @@ public interface JsonRpcInterceptor { */ void preHandle(Object target, Method method, List params); + /** + * If exception will be thrown in this method, standard JSON RPC error will be generated. + *

Example + *

+     * {
+     *      "jsonrpc":"2.0",
+     *      "id":0,
+     *      "error":{
+     *          "code":-32001,
+     *          "message":"123",
+     *          "data":{
+     *              "exceptionTypeName":"java.lang.RuntimeException",
+     *              "message":"123"
+     *          }
+     *      }
+     * }
+     * 
+ *

+ * For changing exception handling custom {@link ErrorResolver} could be generated. + *

+ * + * @param target target service + * @param method target method + * @param paramsJsonNode a JSON node received in the "params" request object field + * @param jsonParams list of params as {@link JsonNode}s + * @param deserializedParams list of params as deserialized objects + * @param detectedParamNames list of params names. + * Names are present only if the request object contains + * a JSON object in the "parameters" field, + * and the target method has annotated parameters. + * This List may contain {@code null} elements. + */ + default void preHandle( + Object target, + Method method, + JsonNode paramsJsonNode, + List jsonParams, + List deserializedParams, + List detectedParamNames + ) { + } + /** * If exception will be thrown in this method, standard JSON RPC error will be generated. Example in preHandle - * Even if target method retruns without exception. + * Even if target method returns without exception. + * * @param target target service * @param method target method * @param params list of params as {@link JsonNode}s - * @param result returned by target service + * @param result object returned by target service, + * which is already converted to {@link JsonNode}s */ void postHandle(Object target, Method method, List params, JsonNode result); + /** + * If exception will be thrown in this method, standard JSON RPC error will be generated. Example in preHandle + * Even if target method returns without exception. + * + * @param target target service + * @param method target method + * @param paramsJsonNode a JSON node received in the "params" request object field + * @param jsonParams list of params as {@link JsonNode}s + * @param deserializedParams list of params as deserialized objects + * @param detectedParamNames list of params names. + * Names are present only if the request object contains + * a JSON object in the "parameters" field, + * and the target method has annotated parameters. + * This List may contain {@code null} elements. + * @param result object returned by target service + */ + default void postHandle( + Object target, + Method method, + List jsonParams, + JsonNode paramsJsonNode, + List deserializedParams, + List detectedParamNames, + Object result + ) { + } + /** * If exception will be thrown in this method, standard JSON RPC error will be generated. Example in preHandle * Even if target method retruns without exception. diff --git a/src/main/java/com/googlecode/jsonrpc4j/JsonRpcServerException.java b/src/main/java/com/googlecode/jsonrpc4j/JsonRpcServerException.java new file mode 100644 index 00000000..9c79a0a2 --- /dev/null +++ b/src/main/java/com/googlecode/jsonrpc4j/JsonRpcServerException.java @@ -0,0 +1,52 @@ +package com.googlecode.jsonrpc4j; + +/** + * Exception thrown by a JSON-RPC server when an error occurs. + * + */ +public class JsonRpcServerException extends RuntimeException { + + private final int code; + private final Object data; + + /** + * Creates the exception. + * + * @param code the code from the server + * @param message the message from the server + * @param data the data from the server + */ + public JsonRpcServerException(int code, String message, Object data) { + super(message); + this.code = code; + this.data = data; + } + + /** + * Creates the exception. + * + * @param code the code from the server + * @param message the message from the server + * @param data the data from the server + * @param cause the cause + */ + public JsonRpcServerException(int code, String message, Object data, Throwable cause) { + super(message, cause); + this.code = code; + this.data = data; + } + + /** + * @return the code + */ + public int getCode() { + return code; + } + + /** + * @return the data + */ + public Object getData() { + return data; + } +} diff --git a/src/main/java/com/googlecode/jsonrpc4j/JsonUtil.java b/src/main/java/com/googlecode/jsonrpc4j/JsonUtil.java index d6bd954b..5f6592e4 100644 --- a/src/main/java/com/googlecode/jsonrpc4j/JsonUtil.java +++ b/src/main/java/com/googlecode/jsonrpc4j/JsonUtil.java @@ -5,9 +5,13 @@ import java.math.BigDecimal; import java.math.BigInteger; +import java.util.Collection; import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; public abstract class JsonUtil { private static final Map, Class> numericNodesMap = new IdentityHashMap<>(7); @@ -56,4 +60,93 @@ public static Class getJavaTypeForJsonType(JsonNode node) { return Object.class; } } -} \ No newline at end of file + + /** + * Determines whether or not the given {@link JsonNode} matches + * the given type. This method is limited to a few java types + * only and shouldn't be used to determine with great accuracy + * whether or not the types match. + * + * @param node the {@link JsonNode} + * @param type the {@link Class} + * @return true if the types match, false otherwise + */ + @SuppressWarnings("SimplifiableIfStatement") + static boolean isMatchingType(JsonNode node, Class type) { + if (node.isNull()) { + return true; + } + if (node.isTextual()) { + return String.class.isAssignableFrom(type); + } + if (node.isNumber()) { + return isNumericAssignable(type); + } + if (node.isArray() && type.isArray()) { + return !node.isEmpty() && isMatchingType(node.get(0), type.getComponentType()); + } + if (node.isArray()) { + return type.isArray() || Collection.class.isAssignableFrom(type); + } + if (node.isBinary()) { + return byteOrCharAssignable(type); + } + if (node.isBoolean()) { + return boolean.class.isAssignableFrom(type) || Boolean.class.isAssignableFrom(type); + } + if (node.isObject() || node.isPojo()) { + return !type.isPrimitive() && !String.class.isAssignableFrom(type) && + !Number.class.isAssignableFrom(type) && !Boolean.class.isAssignableFrom(type); + } + return false; + } + + private static boolean byteOrCharAssignable(Class type) { + return byte[].class.isAssignableFrom(type) || Byte[].class.isAssignableFrom(type) || + char[].class.isAssignableFrom(type) || Character[].class.isAssignableFrom(type); + } + + private static boolean isNumericAssignable(Class type) { + return Number.class.isAssignableFrom(type) || short.class.isAssignableFrom(type) || int.class.isAssignableFrom(type) + || long.class.isAssignableFrom(type) || float.class.isAssignableFrom(type) || double.class.isAssignableFrom(type); + } + + static Object parseId(JsonNode node) { + if (isNullNodeOrValue(node)) { + return null; + } + if (node.isDouble()) { + return node.asDouble(); + } + if (node.isFloatingPointNumber()) { + return node.asDouble(); + } + if (node.isInt()) { + return node.asInt(); + } + if (node.isLong()) { + return node.asLong(); + } + //TODO(donequis): consider parsing bigints + if (node.isIntegralNumber()) { + return node.asInt(); + } + if (node.isTextual()) { + return node.asText(); + } + throw new IllegalArgumentException("Unknown id type"); + } + + static boolean isNullNodeOrValue(JsonNode node) { + return node == null || node.isNull(); + } + + static Set collectFieldNames(JsonNode paramsNode) { + Set fieldNames = new LinkedHashSet<>(); + Iterator itr = paramsNode.fieldNames(); + while (itr.hasNext()) { + fieldNames.add(itr.next()); + } + return fieldNames; + } +} diff --git a/src/main/java/com/googlecode/jsonrpc4j/ReflectionUtil.java b/src/main/java/com/googlecode/jsonrpc4j/ReflectionUtil.java index dc95c72e..65d1b6a0 100644 --- a/src/main/java/com/googlecode/jsonrpc4j/ReflectionUtil.java +++ b/src/main/java/com/googlecode/jsonrpc4j/ReflectionUtil.java @@ -3,15 +3,20 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Utilities for reflection. */ @@ -24,8 +29,17 @@ public abstract class ReflectionUtil { private static final Map> methodAnnotationCache = new ConcurrentHashMap<>(); private static final Map>> methodParamAnnotationCache = new ConcurrentHashMap<>(); - - /** + + private static final Map> parametersNamesCache = new ConcurrentHashMap<>(); + + private static final String NAME = "name"; + + private static final Logger logger = LoggerFactory.getLogger(ReflectionUtil.class); + + private static final Set> webParamAnnotationClasses = + loadWebParamAnnotationClasses(); + + /** * Finds methods with the given name on the given class. * * @param classes the classes @@ -264,6 +278,141 @@ private static Map getNamedParameters(Method method, Object[] ar return namedParams; } + private static Set> loadWebParamAnnotationClasses() { + final ClassLoader classLoader = ReflectionUtil.class.getClassLoader(); + Set> webParamClasses = new HashSet<>(2, 1.0f); + for (String className: Arrays.asList("javax.jws.WebParam", "jakarta.jws.WebParam")) { + try { + Class clazz = + classLoader + .loadClass(className) + .asSubclass(Annotation.class); + // check that method with name "name" is present + clazz.getMethod(NAME); + webParamClasses.add(clazz); + } catch (ClassNotFoundException | NoSuchMethodException e) { + logger.debug("Could not find {}.{}", className, NAME); + } + } + + if (webParamClasses.isEmpty()) { + logger.debug( + "Could not find any @WebParam classes in classpath." + + " @WebParam support is disabled" + ); + } + + return Collections.unmodifiableSet(webParamClasses); + } + + /** + * Checks method for {@link JsonRpcParam}, javax.jws.WebParam and jakarta.jws.WebParam annotations, + * and returns all parameters names declared for this method. + * + * @param method the method + * @param logger the logger + * @return a list of parameter names as {@link JsonRpcParam} objects. + * All names from the javax.jws.WebParam and jakarta.jws.WebParam annotations are copied into + * {@link JsonRpcParam} objects. + */ + @SuppressWarnings("Convert2streamapi") + public static List getAnnotatedParameterNames(Method method, Logger logger) { + List paramNames = parametersNamesCache.get(method); + if (paramNames != null) { + return paramNames; + } + + int parameterCount = method.getParameterCount(); + paramNames = new ArrayList<>(parameterCount); + + List> parametersAnnotations = getParameterAnnotations(method); + for (int i = 0; i < parameterCount; i++) { + List parameterAnnotations = parametersAnnotations.get(i); + List declaredNames = new ArrayList<>(); + + for (Annotation annotation : parameterAnnotations) { + if (annotation instanceof JsonRpcParam) { + declaredNames.add((JsonRpcParam) annotation); + } + + for (Class clazz : webParamAnnotationClasses) { + if (clazz.isInstance(annotation)) { + declaredNames.add( + createNewJsonRcpParamType(annotation) + ); + } + } + } + + JsonRpcParam paramName; + if (declaredNames.size() > 1) { + paramName = declaredNames.get(0); + for (JsonRpcParam name : declaredNames) { + if (!Objects.equals(paramName.value(), name.value())) { + logger.warn( + "Method '{}' has multiple parameter names declared" + + " for the parameter at index {}." + + " Only the first name '{}' can be used." + + " Create additional parameters " + + " if alternative names are required.", + method.toGenericString(), + i, + paramName.value() + ); + } + } + + } else if (!declaredNames.isEmpty()) { + paramName = declaredNames.get(0); + } else { + paramName = null; + logger.warn( + "Method '{}' has no parameter name declared" + + " for the parameter at index {}.", + method.toGenericString(), + i + ); + } + + paramNames.add(paramName); + } + + parametersNamesCache.putIfAbsent(method, Collections.unmodifiableList(paramNames)); + + return paramNames; + } + + private static JsonRpcParam createNewJsonRcpParamType(final Annotation annotation) { + return new JsonRpcParam() { + public Class annotationType() { + return JsonRpcParam.class; + } + + public String value() { + try { + Method method = annotation.getClass().getMethod(JsonRpcBasicServer.NAME); + return (String) method.invoke(annotation); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + } + + private static List> getWebParameterAnnotations(Method method) { + List> annotations = new ArrayList<>(); + for (Class clazz : webParamAnnotationClasses) { + annotations.addAll( + ReflectionUtil.getParameterAnnotations(method, clazz) + ); + } + return annotations; + } + + private List> getJsonRpcParamAnnotations(Method method) { + return ReflectionUtil.getParameterAnnotations(method, JsonRpcParam.class); + } + /** * Checks method for @JsonRpcFixedParam annotations and returns fixed * parameters. @@ -312,5 +461,6 @@ public static void clearCache() { parameterTypeCache.clear(); methodAnnotationCache.clear(); methodParamAnnotationCache.clear(); + parametersNamesCache.clear(); } } diff --git a/src/test/java/com/googlecode/jsonrpc4j/server/JsonRpcServerAnnotatedParamTest.java b/src/test/java/com/googlecode/jsonrpc4j/server/JsonRpcServerAnnotatedParamTest.java index 66026ac1..4eb6f43f 100644 --- a/src/test/java/com/googlecode/jsonrpc4j/server/JsonRpcServerAnnotatedParamTest.java +++ b/src/test/java/com/googlecode/jsonrpc4j/server/JsonRpcServerAnnotatedParamTest.java @@ -16,6 +16,8 @@ import java.io.IOException; import java.util.UUID; +import jakarta.jws.WebParam; + import static com.googlecode.jsonrpc4j.ErrorResolver.JsonError.METHOD_PARAMS_INVALID; import static com.googlecode.jsonrpc4j.ErrorResolver.JsonError.PARSE_ERROR; import static com.googlecode.jsonrpc4j.JsonRpcBasicServer.ID; @@ -134,6 +136,66 @@ public void callOverloadedMethodTwoNamedIntParams() throws Exception { jsonRpcServerAnnotatedParam.handleRequest(messageWithMapParamsStream("overloadedMethod", param1, intParam1, param2, intParam2), byteArrayOutputStream); assertEquals((intParam1 + intParam2) + "", result().textValue()); } + + @Test + public void callMethodWithParamCopy() throws Exception { + EasyMock.expect( + mockService.methodWithParamCopy( + EasyMock.anyInt(), + EasyMock.anyInt() + ) + ).andReturn((intParam1 + intParam2) + ""); + EasyMock.replay(mockService); + jsonRpcServerAnnotatedParam.handleRequest( + messageWithMapParamsStream("methodWithParamCopy", param1, intParam1), + byteArrayOutputStream + ); + assertEquals(METHOD_PARAMS_INVALID.code, errorCode(error(byteArrayOutputStream)).intValue()); + } + + @Test + public void callMethodWithAlternativeNames() throws Exception { + EasyMock + .expect( + mockService.methodWithAlternativeNames( + EasyMock.anyInt(), + EasyMock.anyInt() + ) + ).andReturn((intParam1 + intParam2) + ""); + EasyMock.replay(mockService); + jsonRpcServerAnnotatedParam.handleRequest( + messageWithMapParamsStream( + "methodWithAlternativeNames", + "param1alt", intParam1, + param2, intParam2 + ), + byteArrayOutputStream + ); + assertEquals(METHOD_PARAMS_INVALID.code, errorCode(error(byteArrayOutputStream)).intValue()); + } + + @Test + public void callMethodWithLessParamNames() throws Exception { + EasyMock + .expect( + mockService.methodWithLessParamNames( + EasyMock.anyInt(), + EasyMock.anyInt(), + EasyMock.anyInt() + ) + ).andReturn((intParam1 + intParam2) + ""); + EasyMock.replay(mockService); + jsonRpcServerAnnotatedParam.handleRequest( + messageWithMapParamsStream( + "methodWithLessParamNames", + param1, intParam1, + param2, intParam2, + "otherParam", 0 + ), + byteArrayOutputStream + ); + assertEquals(METHOD_PARAMS_INVALID.code, errorCode(error(byteArrayOutputStream)).intValue()); + } @Test public void callOverloadedMethodNamedExtraParams() throws Exception { @@ -223,7 +285,20 @@ public interface ServiceInterfaceWithParamNameAnnotation { String overloadedMethod(@JsonRpcParam("param1") int intParam1); String overloadedMethod(@JsonRpcParam("param1") int intParam1, @JsonRpcParam("param2") int intParam2); - + + String methodWithParamCopy(@JsonRpcParam("param1") int intParam1, @JsonRpcParam("param1") int intParam1Copy); + + String methodWithAlternativeNames( + @JsonRpcParam("param1") @WebParam(name = "param1alt") int intParam1, + @JsonRpcParam("param2") int intParam2 + ); + + String methodWithLessParamNames( + @JsonRpcParam("param1") int intParam1, + @JsonRpcParam("param2") int intParam2, + Integer intParam3 + ); + String methodWithoutRequiredParam(@JsonRpcParam("param1") String stringParam1, @JsonRpcParam(value = "param2") String stringParam2); String methodWithDifferentTypes(