diff --git a/exonum-java-binding/core/src/main/java/com/exonum/binding/core/runtime/TransactionInvoker.java b/exonum-java-binding/core/src/main/java/com/exonum/binding/core/runtime/TransactionInvoker.java new file mode 100644 index 0000000000..a6e2d5a72b --- /dev/null +++ b/exonum-java-binding/core/src/main/java/com/exonum/binding/core/runtime/TransactionInvoker.java @@ -0,0 +1,68 @@ +/* + * Copyright 2019 The Exonum Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.exonum.binding.core.runtime; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.exonum.binding.core.service.Service; +import com.exonum.binding.core.transaction.TransactionContext; +import com.exonum.binding.core.transaction.TransactionExecutionException; +import java.lang.invoke.MethodHandle; +import java.util.Map; + +/** + * Stores ids of transaction methods and their method handles of a corresponding service. + */ +final class TransactionInvoker { + private final Service service; + private final Map transactionMethods; + + TransactionInvoker(Service service) { + this.service = service; + this.transactionMethods = + TransactionMethodExtractor.extractTransactionMethods(service.getClass()); + } + + /** + * Invoke the transaction method with a given transaction identifier. + * + * @param transactionId a transaction method identifier + * @param context a transaction execution context + * @param arguments the serialized transaction arguments + * + * @throws IllegalArgumentException if there is no transaction method with given id in a + * corresponding service + * @throws TransactionExecutionException if {@link TransactionExecutionException} was thrown by + * the transaction method, it is propagated + * @throws RuntimeException any other error is wrapped into a {@link RuntimeException} + */ + void invokeTransaction(int transactionId, byte[] arguments, TransactionContext context) + throws TransactionExecutionException { + checkArgument(transactionMethods.containsKey(transactionId), + "No method with transaction id (%s)", transactionId); + try { + MethodHandle methodHandle = transactionMethods.get(transactionId); + methodHandle.invoke(service, arguments, context); + } catch (Throwable throwable) { + if (throwable instanceof TransactionExecutionException) { + throw (TransactionExecutionException) throwable; + } else { + throw new RuntimeException(throwable); + } + } + } +} diff --git a/exonum-java-binding/core/src/main/java/com/exonum/binding/core/runtime/TransactionMethodExtractor.java b/exonum-java-binding/core/src/main/java/com/exonum/binding/core/runtime/TransactionMethodExtractor.java new file mode 100644 index 0000000000..117338c39d --- /dev/null +++ b/exonum-java-binding/core/src/main/java/com/exonum/binding/core/runtime/TransactionMethodExtractor.java @@ -0,0 +1,111 @@ +/* + * Copyright 2019 The Exonum Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.exonum.binding.core.runtime; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.stream.Collectors.toMap; + +import com.exonum.binding.core.transaction.TransactionContext; +import com.exonum.binding.core.transaction.TransactionMethod; +import com.google.common.annotations.VisibleForTesting; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * Finds and validates transaction methods in a service. + */ +final class TransactionMethodExtractor { + + /** + * Returns a map of transaction ids to transaction methods found in a service class. + * + * @see TransactionMethod + */ + static Map extractTransactionMethods(Class serviceClass) { + Map transactionMethods = findTransactionMethods(serviceClass); + Lookup lookup = MethodHandles.publicLookup() + .in(serviceClass); + return transactionMethods.entrySet().stream() + .peek(tx -> validateTransactionMethod(tx.getValue(), serviceClass)) + .collect(toMap(Map.Entry::getKey, + (e) -> toMethodHandle(e.getValue(), lookup))); + } + + @VisibleForTesting + static Map findTransactionMethods(Class serviceClass) { + Map transactionMethods = new HashMap<>(); + while (serviceClass != Object.class) { + Method[] classMethods = serviceClass.getDeclaredMethods(); + for (Method method : classMethods) { + if (method.isAnnotationPresent(TransactionMethod.class)) { + TransactionMethod annotation = method.getAnnotation(TransactionMethod.class); + int transactionId = annotation.value(); + checkDuplicates(transactionMethods, transactionId, serviceClass, method); + transactionMethods.put(transactionId, method); + } + } + serviceClass = serviceClass.getSuperclass(); + } + return transactionMethods; + } + + private static void checkDuplicates(Map transactionMethods, int transactionId, + Class serviceClass, Method method) { + if (transactionMethods.containsKey(transactionId)) { + String firstMethodName = transactionMethods.get(transactionId).getName(); + String errorMessage = String.format("Service %s has more than one transaction with the same" + + " id (%s): first: %s; second: %s", + serviceClass.getName(), transactionId, firstMethodName, method.getName()); + throw new IllegalArgumentException(errorMessage); + } + } + + /** + * Checks that the given transaction method signature is correct. + */ + private static void validateTransactionMethod(Method transaction, Class serviceClass) { + String errorMessage = String.format("Method %s in a service class %s annotated with" + + " @TransactionMethod should have precisely two parameters of the following types:" + + " 'byte[]' and 'com.exonum.binding.core.transaction.TransactionContext'", + transaction.getName(), serviceClass.getName()); + checkArgument(transaction.getParameterCount() == 2, errorMessage); + Class firstParameter = transaction.getParameterTypes()[0]; + Class secondParameter = transaction.getParameterTypes()[1]; + checkArgument(firstParameter == byte[].class, + String.format(errorMessage + + ". But first parameter type was: %s", firstParameter.getName())); + checkArgument(TransactionContext.class.isAssignableFrom(secondParameter), + String.format(errorMessage + + ". But second parameter type was: %s", secondParameter.getName())); + } + + private static MethodHandle toMethodHandle(Method method, Lookup lookup) { + try { + return lookup.unreflect(method); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException( + String.format("Couldn't access method %s", method.getName()), e); + } + } + + private TransactionMethodExtractor() {} +} diff --git a/exonum-java-binding/core/src/main/java/com/exonum/binding/core/transaction/TransactionMethod.java b/exonum-java-binding/core/src/main/java/com/exonum/binding/core/transaction/TransactionMethod.java new file mode 100644 index 0000000000..8d18139ad6 --- /dev/null +++ b/exonum-java-binding/core/src/main/java/com/exonum/binding/core/transaction/TransactionMethod.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019 The Exonum Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.exonum.binding.core.transaction; + +import com.exonum.binding.common.message.TransactionMessage; +import com.exonum.core.messages.Runtime.ErrorKind; +import com.exonum.core.messages.Runtime.ExecutionError; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that a method is a transaction method. The annotated method should execute the + * transaction, possibly modifying the blockchain state. The method should: + *
    + *
  • be public + *
  • have exactly two parameters - the + * {@linkplain TransactionMessage#getPayload() serialized transaction arguments} of type + * 'byte[]' and a transaction execution context, which allows to access the information about + * this transaction and modify the blockchain state through the included database fork of + * type '{@link TransactionContext}' in this particular order + *
+ * + *

The annotated method might throw {@linkplain TransactionExecutionException} if the + * transaction cannot be executed normally and has to be rolled back. The transaction will be + * committed as failed (error kind {@linkplain ErrorKind#SERVICE SERVICE}), the + * {@linkplain ExecutionError#getCode() error code} with the optional description will be saved + * into the storage. The client can request the error code to know the reason of the failure. + * + *

The annotated method might also throw {@linkplain RuntimeException} if an unexpected error + * occurs. A correct transaction implementation must not throw such exceptions. The transaction + * will be committed as failed (status "panic"). + * + * @see Exonum Transactions + * @see Exonum Services + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +// TODO: rename to Transaction after migration +public @interface TransactionMethod { + + /** + * Returns the transaction type identifier which is unique within the service. + */ + int value(); +} diff --git a/exonum-java-binding/core/src/test/java/com/exonum/binding/core/runtime/TransactionInvokerTest.java b/exonum-java-binding/core/src/test/java/com/exonum/binding/core/runtime/TransactionInvokerTest.java new file mode 100644 index 0000000000..d7cde70172 --- /dev/null +++ b/exonum-java-binding/core/src/test/java/com/exonum/binding/core/runtime/TransactionInvokerTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2019 The Exonum Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.exonum.binding.core.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.exonum.binding.common.hash.HashCode; +import com.exonum.binding.core.service.Node; +import com.exonum.binding.core.service.Service; +import com.exonum.binding.core.storage.database.Snapshot; +import com.exonum.binding.core.transaction.TransactionContext; +import com.exonum.binding.core.transaction.TransactionExecutionException; +import com.exonum.binding.core.transaction.TransactionMethod; +import io.vertx.ext.web.Router; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +class TransactionInvokerTest { + + private static final byte[] ARGUMENTS = new byte[0]; + @Mock + private TransactionContext context; + + @Test + void invokeValidServiceTransaction() throws Exception { + ValidService service = spy(new ValidService()); + TransactionInvoker invoker = new TransactionInvoker(service); + invoker.invokeTransaction(ValidService.TRANSACTION_ID, ARGUMENTS, context); + invoker.invokeTransaction(ValidService.TRANSACTION_ID_2, ARGUMENTS, context); + + verify(service).transactionMethod(ARGUMENTS, context); + verify(service).transactionMethod2(ARGUMENTS, context); + } + + @Test + void invokeInvalidTransactionId() { + TransactionInvoker invoker = new TransactionInvoker(new ValidService()); + int invalidTransactionId = Integer.MAX_VALUE; + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> invoker.invokeTransaction(invalidTransactionId, ARGUMENTS, context)); + assertThat(e.getMessage()) + .contains(String.format("No method with transaction id (%s)", invalidTransactionId)); + } + + @Test + void invokeThrowingTransactionExecutionException() { + TransactionInvoker invoker = new TransactionInvoker(new ThrowingService()); + TransactionExecutionException e = assertThrows(TransactionExecutionException.class, + () -> invoker.invokeTransaction(ThrowingService.TRANSACTION_ID, ARGUMENTS, context)); + assertThat(e.getErrorCode()).isEqualTo(ThrowingService.ERROR_CODE); + } + + @Test + void invokeThrowingServiceException() { + TransactionInvoker invoker = new TransactionInvoker(new ThrowingService()); + RuntimeException e = assertThrows(RuntimeException.class, + () -> invoker.invokeTransaction(ThrowingService.TRANSACTION_ID_2, ARGUMENTS, context)); + assertThat(e.getCause().getClass()).isEqualTo(IllegalArgumentException.class); + } + + static class BasicService implements Service { + + static final int TRANSACTION_ID = 1; + static final int TRANSACTION_ID_2 = 2; + + @Override + public List getStateHashes(Snapshot snapshot) { + return Collections.emptyList(); + } + + @Override + public void createPublicApiHandlers(Node node, Router router) { + // no-op + } + } + + public static class ValidService extends BasicService { + + @TransactionMethod(TRANSACTION_ID) + @SuppressWarnings("WeakerAccess") // Should be accessible + public void transactionMethod(byte[] arguments, TransactionContext context) { + } + + @TransactionMethod(TRANSACTION_ID_2) + @SuppressWarnings("WeakerAccess") // Should be accessible + public void transactionMethod2(byte[] arguments, TransactionContext context) { + } + } + + public static class ThrowingService extends BasicService { + + static final byte ERROR_CODE = 18; + static final String ERROR_MESSAGE = "Service originated exception"; + + @TransactionMethod(TRANSACTION_ID) + public void transactionMethod(byte[] arguments, TransactionContext context) + throws TransactionExecutionException { + throw new TransactionExecutionException(ERROR_CODE); + } + + @TransactionMethod(TRANSACTION_ID_2) + public void transactionMethod2(byte[] arguments, TransactionContext context) + throws TransactionExecutionException { + throw new IllegalArgumentException(ERROR_MESSAGE); + } + } +} diff --git a/exonum-java-binding/core/src/test/java/com/exonum/binding/core/runtime/TransactionMethodExtractorTest.java b/exonum-java-binding/core/src/test/java/com/exonum/binding/core/runtime/TransactionMethodExtractorTest.java new file mode 100644 index 0000000000..e62c5b55e4 --- /dev/null +++ b/exonum-java-binding/core/src/test/java/com/exonum/binding/core/runtime/TransactionMethodExtractorTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2019 The Exonum Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.exonum.binding.core.runtime; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.exonum.binding.common.hash.HashCode; +import com.exonum.binding.core.service.Node; +import com.exonum.binding.core.service.Service; +import com.exonum.binding.core.storage.database.Snapshot; +import com.exonum.binding.core.transaction.TransactionContext; +import com.exonum.binding.core.transaction.TransactionMethod; +import io.vertx.ext.web.Router; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TransactionMethodExtractorTest { + + @Test + void findTransactionMethodsValidService() throws Exception { + Map transactions = + TransactionMethodExtractor.findTransactionMethods(ValidService.class); + assertThat(transactions).hasSize(1); + Method transactionMethod = + ValidService.class.getMethod("transactionMethod", byte[].class, TransactionContext.class); + assertThat(singletonList(transactionMethod)) + .containsExactlyElementsOf(transactions.values()); + } + + @Test + void duplicateTransactionIdsServiceMethodExtraction() { + Exception e = assertThrows(IllegalArgumentException.class, + () -> TransactionMethodExtractor + .extractTransactionMethods(DuplicateTransactionIdsService.class)); + assertThat(e.getMessage()) + .contains(String.format("Service %s has more than one transaction with the same id (%s)", + DuplicateTransactionIdsService.class.getName(), + DuplicateTransactionIdsService.TRANSACTION_ID), + "transactionMethod", + "anotherTransactionMethod"); + } + + @Test + void missingTransactionMethodArgumentsServiceMethodExtraction() { + Exception e = assertThrows(IllegalArgumentException.class, + () -> TransactionMethodExtractor + .extractTransactionMethods(MissingTransactionMethodArgumentsService.class)); + String methodName = "transactionMethod"; + String errorMessage = String.format("Method %s in a service class %s annotated with" + + " @TransactionMethod should have precisely two parameters of the following types:" + + " 'byte[]' and 'com.exonum.binding.core.transaction.TransactionContext'", + methodName, MissingTransactionMethodArgumentsService.class.getName()); + assertThat(e.getMessage()).contains(errorMessage); + } + + @Test + void invalidTransactionMethodArgumentServiceMethodExtraction() { + Exception e = assertThrows(IllegalArgumentException.class, + () -> TransactionMethodExtractor + .extractTransactionMethods(InvalidTransactionMethodArgumentsService.class)); + String methodName = "transactionMethod"; + String errorMessage = String.format("Method %s in a service class %s annotated with" + + " @TransactionMethod should have precisely two parameters of the following types:" + + " 'byte[]' and 'com.exonum.binding.core.transaction.TransactionContext'" + + ". But second parameter type was: " + String.class.getName(), + methodName, InvalidTransactionMethodArgumentsService.class.getName()); + assertThat(e.getMessage()).contains(errorMessage); + } + + @Test + void duplicateTransactionMethodArgumentServiceMethodExtraction() { + Exception e = assertThrows(IllegalArgumentException.class, + () -> TransactionMethodExtractor + .extractTransactionMethods(DuplicateTransactionMethodArgumentsService.class)); + String methodName = "transactionMethod"; + String errorMessage = String.format("Method %s in a service class %s annotated with" + + " @TransactionMethod should have precisely two parameters of the following types:" + + " 'byte[]' and 'com.exonum.binding.core.transaction.TransactionContext'" + + ". But second parameter type was: " + byte[].class.getName(), + methodName, DuplicateTransactionMethodArgumentsService.class.getName()); + assertThat(e.getMessage()).contains(errorMessage); + } + + @Test + void findMethodsValidServiceInterfaceImplementation() throws Exception { + Map transactions = + TransactionMethodExtractor.findTransactionMethods( + ValidServiceInterfaceImplementation.class); + assertThat(transactions).hasSize(2); + Method transactionMethod = ValidServiceInterfaceImplementation.class.getMethod( + "transactionMethod", byte[].class, TransactionContext.class); + Method transactionMethod2 = ValidServiceInterfaceImplementation.class.getMethod( + "transactionMethod2", byte[].class, TransactionContext.class); + List actualMethods = Arrays.asList(transactionMethod, transactionMethod2); + assertThat(actualMethods).containsExactlyInAnyOrderElementsOf(transactions.values()); + } + + static class BasicService implements Service { + + static final int TRANSACTION_ID = 1; + + @Override + public List getStateHashes(Snapshot snapshot) { + return Collections.emptyList(); + } + + @Override + public void createPublicApiHandlers(Node node, Router router) { + // no-op + } + } + + static class ValidService extends BasicService { + + @TransactionMethod(TRANSACTION_ID) + @SuppressWarnings("WeakerAccess") // Should be accessible + public void transactionMethod(byte[] arguments, TransactionContext context) {} + } + + static class DuplicateTransactionIdsService extends BasicService { + + @TransactionMethod(TRANSACTION_ID) + public void transactionMethod(byte[] arguments, TransactionContext context) {} + + @TransactionMethod(TRANSACTION_ID) + public void anotherTransactionMethod(byte[] arguments, TransactionContext context) {} + } + + static class MissingTransactionMethodArgumentsService extends BasicService { + + @TransactionMethod(TRANSACTION_ID) + public void transactionMethod(byte[] arguments) {} + } + + static class InvalidTransactionMethodArgumentsService extends BasicService { + + @TransactionMethod(TRANSACTION_ID) + public void transactionMethod(byte[] arguments, String invalidArgument) {} + } + + static class DuplicateTransactionMethodArgumentsService extends BasicService { + + @TransactionMethod(TRANSACTION_ID) + public void transactionMethod(byte[] arguments, byte[] context) {} + } + + interface ServiceInterface { + int TRANSACTION_ID = 1; + + @TransactionMethod(TRANSACTION_ID) + void transactionMethod(byte[] arguments, TransactionContext context); + } + + static class ValidServiceInterfaceImplementation implements ServiceInterface { + + static final int TRANSACTION_ID_2 = 2; + + @TransactionMethod(TRANSACTION_ID) + public void transactionMethod(byte[] arguments, TransactionContext context) {} + + @TransactionMethod(TRANSACTION_ID_2) + @SuppressWarnings("WeakerAccess") // Should be accessible + public void transactionMethod2(byte[] arguments, TransactionContext context) {} + } +}