diff --git a/README.md b/README.md index 2146f92..4f744e4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ 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 Gradle. Be @@ -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 efc3f13..020e2fa 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 0000000..5c14f14 --- /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 0000000..d29d0d9 --- /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 c1de8d8..191f36f 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 c41ce85..2dd2485 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 6c01b4e..9d79145 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 0000000..9c79a0a --- /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 d6bd954..5f6592e 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 dc95c72..65d1b6a 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 66026ac..4eb6f43 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(