From 36a48def1cdab58773a5cb2a67cf83d66a61d63a Mon Sep 17 00:00:00 2001 From: parisyup <66366646+parisyup@users.noreply.github.com> Date: Tue, 30 Apr 2024 19:17:09 +0400 Subject: [PATCH] added customJSON files --- java-samples/customJSON/.ci/Jenkinsfile | 11 + .../.ci/nightly/JenkinsfileSnykScan | 6 + java-samples/customJSON/.gitignore | 91 ++++ .../runConfigurations/DebugCorDapp.run.xml | 15 + java-samples/customJSON/.snyk | 14 + .../customJSON/FlowManagementUI/Dockerfile | 5 + .../customJSON/FlowManagementUI/README.md | 84 +++ .../customJSON/FlowManagementUI/app.py | 12 + .../FlowManagementUI/requirements.txt | 1 + .../FlowManagementUI/static/Scripts/script.js | 322 ++++++++++++ .../FlowManagementUI/static/css/main.css | 202 +++++++ .../FlowManagementUI/templates/index.html | 87 ++++ java-samples/customJSON/README.md | 123 +++++ java-samples/customJSON/build.gradle | 74 +++ .../config/combined-worker-compose.yaml | 87 ++++ .../config/gradle-plugin-default-key.pem | 13 + java-samples/customJSON/config/log4j2.xml | 52 ++ java-samples/customJSON/config/r3-ca-key.pem | 32 ++ .../config/static-network-config.json | 23 + .../customJSON/contracts/build.gradle | 87 ++++ .../apples/contracts/AppleCommands.java | 9 + .../apples/contracts/AppleStampContract.java | 38 ++ .../contracts/BasketOfApplesContract.java | 66 +++ .../developers/apples/states/AppleStamp.java | 47 ++ .../apples/states/BasketOfApples.java | 53 ++ .../customJSON/contracts/ChatContract.java | 72 +++ .../customJSON/states/ChatJsonFactory.java | 37 ++ .../customJSON/states/ChatState.java | 55 ++ .../customJSON/states/CustomChatQuery.java | 25 + java-samples/customJSON/gradle.properties | 73 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63375 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + java-samples/customJSON/gradlew | 248 +++++++++ java-samples/customJSON/gradlew.bat | 92 ++++ java-samples/customJSON/settings.gradle | 26 + .../customJSON/workflows/build.gradle | 96 ++++ .../CreateAndIssueAppleStampFlow.java | 102 ++++ .../CreateAndIssueAppleStampRequest.java | 32 ++ ...CreateAndIssueAppleStampResponderFlow.java | 33 ++ .../apples/workflows/PackageApplesFlow.java | 80 +++ .../workflows/PackageApplesRequest.java | 32 ++ .../apples/workflows/RedeemApplesFlow.java | 130 +++++ .../apples/workflows/RedeemApplesRequest.java | 34 ++ .../workflows/RedeemApplesResponderFlow.java | 32 ++ .../workflows/ChatStateResults.java | 41 ++ .../workflows/CreateNewChatFlow.java | 118 +++++ .../workflows/CreateNewChatFlowArgs.java | 30 ++ .../workflows/FinalizeChatResponderFlow.java | 75 +++ .../workflows/FinalizeChatSubFlow.java | 71 +++ .../customJSON/workflows/GetChatFlow.java | 120 +++++ .../customJSON/workflows/GetChatFlowArgs.java | 24 + .../workflows/ListChatsByCustomQueryFlow.java | 68 +++ .../customJSON/workflows/ListChatsFlow.java | 59 +++ .../workflows/MessageAndSender.java | 22 + .../customJSON/workflows/UpdateChatFlow.java | 120 +++++ .../workflows/UpdateChatFlowArgs.java | 24 + .../flowexample/workflows/Message.java | 28 + .../flowexample/workflows/MyFirstFlow.java | 96 ++++ .../workflows/MyFirstFlowResponder.java | 62 +++ .../workflows/MyFirstFlowStartArgs.java | 19 + kotlin-samples/customJSON/.ci/Jenkinsfile | 11 + .../.ci/nightly/JenkinsfileSnykScan | 6 + kotlin-samples/customJSON/.gitignore | 86 +++ .../runConfigurations/DebugCorDapp.run.xml | 15 + kotlin-samples/customJSON/.snyk | 14 + .../customJSON/FlowManagementUI/Dockerfile | 5 + .../customJSON/FlowManagementUI/README.md | 84 +++ .../customJSON/FlowManagementUI/app.py | 12 + .../FlowManagementUI/requirements.txt | 1 + .../FlowManagementUI/static/Scripts/script.js | 322 ++++++++++++ .../FlowManagementUI/static/css/main.css | 202 +++++++ .../FlowManagementUI/templates/index.html | 87 ++++ kotlin-samples/customJSON/README.md | 122 +++++ kotlin-samples/customJSON/build.gradle | 88 ++++ .../config/combined-worker-compose.yaml | 87 ++++ .../config/gradle-plugin-default-key.pem | 13 + kotlin-samples/customJSON/config/log4j2.xml | 52 ++ .../customJSON/config/r3-ca-key.pem | 32 ++ .../config/static-network-config.json | 23 + .../customJSON/contracts/build.gradle | 88 ++++ .../apples/contracts/AppleCommands.kt | 9 + .../apples/contracts/AppleStampContract.kt | 31 ++ .../contracts/BasketOfApplesContract.kt | 51 ++ .../r3/developers/apples/states/AppleStamp.kt | 19 + .../apples/states/BasketOfApples.kt | 22 + .../customJSON/contracts/ChatContract.kt | 87 ++++ .../customJSON/states/ChatJsonFactory.kt | 34 ++ .../customJSON/states/ChatState.kt | 37 ++ .../customJSON/states/CustomChatQuery.kt | 20 + kotlin-samples/customJSON/gradle.properties | 73 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63375 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + kotlin-samples/customJSON/gradlew | 248 +++++++++ kotlin-samples/customJSON/gradlew.bat | 92 ++++ kotlin-samples/customJSON/settings.gradle | 26 + .../customJSON/workflows/build.gradle | 98 ++++ .../workflows/CreateAndIssueAppleStampFlow.kt | 87 ++++ .../CreateAndIssueAppleStampResponderFlow.kt | 30 ++ .../apples/workflows/PackageApplesFlow.kt | 68 +++ .../apples/workflows/RedeemApplesFlow.kt | 90 ++++ .../workflows/RedeemApplesResponderFlow.kt | 30 ++ .../customJSON/workflows/CreateNewChatFlow.kt | 112 ++++ .../workflows/FinalizeChatSubFlow.kt | 103 ++++ .../customJSON/workflows/GetChatFlow.kt | 117 +++++ .../workflows/ListChatsByCustomQueryFlow.kt | 66 +++ .../customJSON/workflows/ListChatsFlow.kt | 62 +++ .../workflows/ResponderValidations.kt | 24 + .../customJSON/workflows/TestContractFlow.kt | 492 ++++++++++++++++++ .../customJSON/workflows/UpdateChatFlow.kt | 109 ++++ .../flowexample/workflows/MyFirstFlow.kt | 154 ++++++ 110 files changed, 7485 insertions(+) create mode 100644 java-samples/customJSON/.ci/Jenkinsfile create mode 100644 java-samples/customJSON/.ci/nightly/JenkinsfileSnykScan create mode 100644 java-samples/customJSON/.gitignore create mode 100644 java-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml create mode 100644 java-samples/customJSON/.snyk create mode 100644 java-samples/customJSON/FlowManagementUI/Dockerfile create mode 100644 java-samples/customJSON/FlowManagementUI/README.md create mode 100644 java-samples/customJSON/FlowManagementUI/app.py create mode 100644 java-samples/customJSON/FlowManagementUI/requirements.txt create mode 100644 java-samples/customJSON/FlowManagementUI/static/Scripts/script.js create mode 100644 java-samples/customJSON/FlowManagementUI/static/css/main.css create mode 100644 java-samples/customJSON/FlowManagementUI/templates/index.html create mode 100644 java-samples/customJSON/README.md create mode 100644 java-samples/customJSON/build.gradle create mode 100644 java-samples/customJSON/config/combined-worker-compose.yaml create mode 100644 java-samples/customJSON/config/gradle-plugin-default-key.pem create mode 100644 java-samples/customJSON/config/log4j2.xml create mode 100644 java-samples/customJSON/config/r3-ca-key.pem create mode 100644 java-samples/customJSON/config/static-network-config.json create mode 100644 java-samples/customJSON/contracts/build.gradle create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleCommands.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleStampContract.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/BasketOfApplesContract.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/AppleStamp.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/BasketOfApples.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatState.java create mode 100644 java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.java create mode 100644 java-samples/customJSON/gradle.properties create mode 100644 java-samples/customJSON/gradle/wrapper/gradle-wrapper.jar create mode 100644 java-samples/customJSON/gradle/wrapper/gradle-wrapper.properties create mode 100644 java-samples/customJSON/gradlew create mode 100644 java-samples/customJSON/gradlew.bat create mode 100644 java-samples/customJSON/settings.gradle create mode 100644 java-samples/customJSON/workflows/build.gradle create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampRequest.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesRequest.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesRequest.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ChatStateResults.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlowArgs.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatResponderFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlowArgs.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/MessageAndSender.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlowArgs.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/Message.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowResponder.java create mode 100644 java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowStartArgs.java create mode 100644 kotlin-samples/customJSON/.ci/Jenkinsfile create mode 100644 kotlin-samples/customJSON/.ci/nightly/JenkinsfileSnykScan create mode 100644 kotlin-samples/customJSON/.gitignore create mode 100644 kotlin-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml create mode 100644 kotlin-samples/customJSON/.snyk create mode 100644 kotlin-samples/customJSON/FlowManagementUI/Dockerfile create mode 100644 kotlin-samples/customJSON/FlowManagementUI/README.md create mode 100644 kotlin-samples/customJSON/FlowManagementUI/app.py create mode 100644 kotlin-samples/customJSON/FlowManagementUI/requirements.txt create mode 100644 kotlin-samples/customJSON/FlowManagementUI/static/Scripts/script.js create mode 100644 kotlin-samples/customJSON/FlowManagementUI/static/css/main.css create mode 100644 kotlin-samples/customJSON/FlowManagementUI/templates/index.html create mode 100644 kotlin-samples/customJSON/README.md create mode 100644 kotlin-samples/customJSON/build.gradle create mode 100644 kotlin-samples/customJSON/config/combined-worker-compose.yaml create mode 100644 kotlin-samples/customJSON/config/gradle-plugin-default-key.pem create mode 100644 kotlin-samples/customJSON/config/log4j2.xml create mode 100644 kotlin-samples/customJSON/config/r3-ca-key.pem create mode 100644 kotlin-samples/customJSON/config/static-network-config.json create mode 100644 kotlin-samples/customJSON/contracts/build.gradle create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleCommands.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleStampContract.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/BasketOfApplesContract.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/AppleStamp.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/BasketOfApples.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatState.kt create mode 100644 kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.kt create mode 100644 kotlin-samples/customJSON/gradle.properties create mode 100644 kotlin-samples/customJSON/gradle/wrapper/gradle-wrapper.jar create mode 100644 kotlin-samples/customJSON/gradle/wrapper/gradle-wrapper.properties create mode 100644 kotlin-samples/customJSON/gradlew create mode 100644 kotlin-samples/customJSON/gradlew.bat create mode 100644 kotlin-samples/customJSON/settings.gradle create mode 100644 kotlin-samples/customJSON/workflows/build.gradle create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/PackageApplesFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ResponderValidations.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/TestContractFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.kt create mode 100644 kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.kt diff --git a/java-samples/customJSON/.ci/Jenkinsfile b/java-samples/customJSON/.ci/Jenkinsfile new file mode 100644 index 0000000..560ec37 --- /dev/null +++ b/java-samples/customJSON/.ci/Jenkinsfile @@ -0,0 +1,11 @@ +@Library('corda-shared-build-pipeline-steps@5.2') _ + +cordaPipeline( + publishRepoPrefix: '', + slimBuild: true, + runUnitTests: false, + dedicatedJobForSnykDelta: false, + slackChannel: '#corda-corda5-dev-ex-build-notifications', + gitHubComments: false, + javaVersion: '17' + ) diff --git a/java-samples/customJSON/.ci/nightly/JenkinsfileSnykScan b/java-samples/customJSON/.ci/nightly/JenkinsfileSnykScan new file mode 100644 index 0000000..c2ae031 --- /dev/null +++ b/java-samples/customJSON/.ci/nightly/JenkinsfileSnykScan @@ -0,0 +1,6 @@ +@Library('corda-shared-build-pipeline-steps@5.1') _ + +cordaSnykScanPipeline ( + snykTokenId: 'r3-snyk-corda5', + snykAdditionalCommands: "--all-sub-projects -d" +) diff --git a/java-samples/customJSON/.gitignore b/java-samples/customJSON/.gitignore new file mode 100644 index 0000000..b9a99ba --- /dev/null +++ b/java-samples/customJSON/.gitignore @@ -0,0 +1,91 @@ + +# Eclipse, ctags, Mac metadata, log files +.classpath +.project +.settings +tags +.DS_Store +*.log +*.orig + +# Created by .ignore support plugin (hsz.mobi) + +.gradle +local.properties +.gradletasknamecache + +# General build files +**/build/* + +lib/quasar.jar + +**/logs/* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/*.xml +.idea/.name +.idea/copyright +.idea/inspectionProfiles +.idea/libraries +.idea/shelf +.idea/dataSources +.idea/markdown-navigator +.idea/runConfigurations +.idea/dictionaries + + +# Include the -parameters compiler option by default in IntelliJ required for serialization. +!.idea/codeStyleSettings.xml + + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +**/out/ +/classes/ + + + +# vim +*.swp +*.swn +*.swo + + + +# Directory generated during Resolve and TestOSGi gradle tasks +bnd/ + +# Ignore Gradle build output directory +build +/.idea/codeStyles/codeStyleConfig.xml +/.idea/codeStyles/Project.xml + + + +# Ignore Visual studio directory +bin/ + + + +*.cpi +*.cpb +*.cpk +workspace/** +#CordaPID.dat +#*.pem +#*.pfx +#CPIFileStatus*.json +#GroupPolicy.json + +# Ignore temporary data files +*.dat \ No newline at end of file diff --git a/java-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml b/java-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml new file mode 100644 index 0000000..1d8da82 --- /dev/null +++ b/java-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/java-samples/customJSON/.snyk b/java-samples/customJSON/.snyk new file mode 100644 index 0000000..c04521f --- /dev/null +++ b/java-samples/customJSON/.snyk @@ -0,0 +1,14 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.25.0 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JAVA-ORGJETBRAINSKOTLIN-2393744: + - '*': + reason: >- + This vulnerability relates to information exposure via creation of + temporary files (via Kotlin functions) with insecure permissions. + Corda does not use any of the vulnerable functions so it is not + susceptible to this vulnerability + expires: 2023-10-19T17:15:26.836Z + created: 2023-02-02T17:15:26.839Z +patch: {} diff --git a/java-samples/customJSON/FlowManagementUI/Dockerfile b/java-samples/customJSON/FlowManagementUI/Dockerfile new file mode 100644 index 0000000..016bc21 --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/Dockerfile @@ -0,0 +1,5 @@ +FROM python +WORKDIR /app +COPY . /app +RUN pip install -r requirements.txt +CMD ["python3", "app.py"] \ No newline at end of file diff --git a/java-samples/customJSON/FlowManagementUI/README.md b/java-samples/customJSON/FlowManagementUI/README.md new file mode 100644 index 0000000..fffff29 --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/README.md @@ -0,0 +1,84 @@ +# Corda 5 CorDapp Flow Management Tool + + +This user guide provides step-by-step instructions on using the Corda 5 flow management tool. This article will help you learn how to connect the running corDapp, make flow calls, configure flow queries, and retrieve results. + +## Prerequisites +* Install and run Python and Flask framework. link. + +* Prepare your local Corda 5 environment. (By default, the Flow Management Tool is looking to connect to https://localhost:8888/api/v5_2/swagger#/ with Login: Admin and Password: Admin.) + +* Clong the Flow Management Tool repository. FlowManagementUI: main + +## Set Up + +1. Assuming your local Corda 5 environment is populated and the swagger endpoint is at: https://localhost:8888/api/v5_2/swagger#/ + +2. Navigate to where you downloaded the Corda 5 Flow Management Tool + +3. To run the framework + * Navigate to the file name using cd command. + * use the python app.py command to run it. + ![image](https://github.com/parisyup/FlowManagementUI/assets/51169685/f0c3bf59-8180-48a0-91cc-80f2d260e530) + + * Later on, click on the IP Address which will open the Interface: + +![image(4)](https://github.com/parisyup/FlowManagementUI/assets/66366646/8d88e37c-edbb-4d6d-8bcd-d773e818a106) + + +The Flow Management Tool should be automatically connected with the CorDapp running locally from your CSDE. You can test the connection by click on the dropdown list at the Flow Initiator section. You should be able to see the vNodes of your started CorDapp from CSDE. + +![image](https://github.com/parisyup/FlowManagementUI/assets/51169685/5a2356f2-cd14-489c-abd0-4afe0bf0d251) + +## Set Up With Docker + +1- Open up Command Prompt + +2- Navigate to the application folder using the CD commands + +3- Ensure that Docker application is open and build the image using the following command: +`docker build -t your-image-name .` + +Make sure to include the dot at the end of the command + +the `your-image-name` at the end of the command can be whatever you like but make sure to use the same name in the next step + +4- Run the docker image using the following command: +`docker run --rm -it --expose 8888 -p 5000:5000 your-image-name` + +5- You can access the website by using https://localhost:5000 or https://127.0.0.1:5000 + +## Using the Flow Management Tool + +### Selecting the Flow Initiator + +As the first step of using the Flow Management Tool, you would need to select the Flow Initiator. The Flow Initiator indicates which vNode will be triggering the flow. If you wish to have Alice to run a transaction to Bob, select the X500Name of Alice. The selected vNode’s shortHash (Corda 5 Network participant identifier) will also be shown below the dropdown list to signify your selection. + +### Function 1: To Make a Flow Call + +1. Click on "Flow Call" tab in the application. +2. Paste the your JSON format request body into the request input box. +3. Click Post button to trigger the call. + +![image(5)](https://github.com/parisyup/FlowManagementUI/assets/66366646/c65195a6-0a70-4354-804e-37884f657746) + + +### Function 2: To Configure Flow Query + +1. Click on the “Flow Query” tab. +2. Choose whether to query a single flow or all flows at the selected Flow Initiator. +3. If you choose to query all of the flows, select “Query All Flows“ then click “Get“. + +![image](https://github.com/parisyup/FlowManagementUI/assets/51169685/0482cfa4-7ee1-42f2-8786-2d8ad80b2936) +4. If you choose to query a single flow, select “Query Single Flow“, please add the ClientID in specified filed. +5. Click on “Get” to retrieve the result. + +![image(6)](https://github.com/parisyup/FlowManagementUI/assets/66366646/13e979b0-f76e-4f2c-9d55-81be8880890b) + +If you have any suggestions or questions, feel free to give us your feedback through Github for a better experience in the future! + +## Conclusion +In summary, our project introduces a specialized flow management layer on top of Swagger for Corda developers. We understand the challenges developers face in testing Corda applications due to the complexity of commands, our solution focuses on simplifying the process. + +Our all-in-one flow management system provides developers with a unified platform, streamlining development and enhancing efficiency. A key feature allows developers to run flows directly from an externally developed website and monitor their status in real-time, offering a user-friendly and practical solution for Corda developers. Overall, our project aims to make Corda development more accessible and tailored to the specific needs of flow management. + diff --git a/java-samples/customJSON/FlowManagementUI/app.py b/java-samples/customJSON/FlowManagementUI/app.py new file mode 100644 index 0000000..5d3e3f6 --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/app.py @@ -0,0 +1,12 @@ +from flask import Flask +from flask import render_template +app = Flask(__name__) + + +@app.route('/') +def home(): # put application's code here + return render_template("index.html") + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0') + diff --git a/java-samples/customJSON/FlowManagementUI/requirements.txt b/java-samples/customJSON/FlowManagementUI/requirements.txt new file mode 100644 index 0000000..8ab6294 --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/requirements.txt @@ -0,0 +1 @@ +flask \ No newline at end of file diff --git a/java-samples/customJSON/FlowManagementUI/static/Scripts/script.js b/java-samples/customJSON/FlowManagementUI/static/Scripts/script.js new file mode 100644 index 0000000..8fd4ec0 --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/static/Scripts/script.js @@ -0,0 +1,322 @@ +// This script contains functions for making API requests, handling data, and updating the UI. + +// Variable to store the selected X500Name from the dropdown +let selectedX500Name; + +// Variable to indicate whether data is currently being loaded from the pull request +let loading = false; + +// Function to make a GET request to an external API and return the data +function getData() { + // Replace the URL with the actual API endpoint + return fetch('https://jsonplaceholder.typicode.com/todos/1') + .then(response => response.json()) + .then(data => { + console.log('API Result:', data); + + // Return specific data fields + return { + id: data.id, + title: data.title + }; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to get the data and display it on the page +function getDataAndDisplay() { + getData() + .then(result => { + // Update the result div with the data + document.getElementById('result').innerHTML = ` +

ID: ${result.id}

+

Title: ${result.title}

+ `; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to make a GET request to retrieve CPI data +function getCPI() { + const url = 'https://localhost:8888/api/v1/cpi'; + // Perform the GET request with authorization headers + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + console.log('API Result:', data); + // Further processing of the data can be done here + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to get all virtual nodes and populate a dropdown with the data +function getAllVirtualNodes() { + const url = 'https://localhost:8888/api/v1/virtualnode'; + + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + // Extract virtualNodes from the API response + const virtualNodes = data.virtualNodes; + console.log('API Result:', virtualNodes); + + // Process the data and populate the dropdown + if (Array.isArray(virtualNodes)) { + const dropdown = document.getElementById('itemDropdown'); + dropdown.innerHTML = ''; + dropdown.innerHTML += ''; + + virtualNodes.forEach(item => { + // Display each item on the console + console.log('Item X500Name:', item.holdingIdentity.x500Name); + console.log('Item ShortHash:', item.holdingIdentity.shortHash); + + // Create an option element and add it to the dropdown + const option = document.createElement('option'); + option.value = item.holdingIdentity.shortHash; + option.text = item.holdingIdentity.x500Name; + dropdown.appendChild(option); + }); + + // Add event listener to the dropdown to detect changes + dropdown.addEventListener('change', function () { + selectedX500Name = this.value; + console.log('Selected ShortHash:', selectedX500Name); + + // Call a function or update a variable based on the selected item + handleDropdownChange(selectedX500Name); + }); + + } else { + console.warn('API Result is not an array.'); + } + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Initialize the virtual nodes dropdown on page load +getAllVirtualNodes(); + +// function to handle dropdown change +function handleDropdownChange(selectedX500Name) { + console.log('Handling dropdown change for Item ID:', selectedX500Name); + getSelectedVNode(); + // Additional actions based on the selected item can be performed here +} + +// Function to get the selected virtual node and display it +function getSelectedVNode() { + const displayElement = document.getElementById('selectedX500Display'); + displayElement.textContent = `Selected X500: ${selectedX500Name}`; +} + +// Function to get all flow results based on the selected virtual node +function getAllFlowResult() { + const url = `https://localhost:8888/api/v1/flow/${selectedX500Name}`; + + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + const flowStatusResponses = data.flowStatusResponses; + console.log('API Result:', flowStatusResponses); + + // Convert the JSON object to a string for display + const jsonString = JSON.stringify(flowStatusResponses, null, 2); + + // Display the JSON string in the result div + document.getElementById('idResutl').innerHTML = `
${jsonString}
`; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to make a POST request with a request body +async function postCallFlow() { + let postBtn = $('#postBtn'); + const url = `https://localhost:8888/api/v1/flow/${selectedX500Name}`; + + // Change the button text to indicate loading + postBtn.html('Loading...'); + + try { + // Perform the POST request with the provided request body + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=', + 'Content-Type': 'application/json' + }, + body: `${document.getElementById('requestBody').value}` + }); + + // Parse the response as JSON + const data = await response.json(); + + if (!data.ok) { + console.log(data.status); + + // Check if the response status is not OK (2xx range) + if (data.status == "409" || data.status == "400") { + let flowStatusResponse = "title: " + data.title + "\nStatus: " + data.status; + + // Display additional details for 400 status + if (data.status == "400") { + flowStatusResponse += "\nDetails: \n Cause: " + data.details.cause + "\n Reason: " + data.details.reason; + } + + document.getElementById('queryResult').innerHTML = `
${flowStatusResponse}
`; + return; + } + } + + let msg = "null"; + let typ = "null"; + + // Construct a string with the flow status responses + let flowStatusResponses = "client request ID: " + data.clientRequestId + + "\nFlow Result " + data.flowResult + + "\nFlow Error Message: " + msg + + "\nflow error type: " + typ + + "\nFlow ID: " + data.flowId + + "\nFlow status: " + data.flowStatus + + "\nHolding identity short hash: " + data.holdingIdentityShortHash + + "\nTime stamp: " + data.timestamp; + + console.log('API Result:', flowStatusResponses); + + // Display the flow status responses in the result div + document.getElementById('queryResult').innerHTML = `
${flowStatusResponses}
`; + } catch (error) { + console.log('Error:', error); + } finally { + // Restore the button text after the operation is complete + postBtn.html('Post'); + } +} + +// Function to display an item on the page +function displayItemOnPage(item) { + const resultDiv = document.getElementById('queryResult'); + + // Create a new element to display the item + const itemElement = document.createElement('div'); + itemElement.innerHTML = ` +

QueryResult: ${item}

+ `; + + // Append the new element to the result div + resultDiv.appendChild(itemElement); +} + +// Function to open a specific tab by hiding/showing content +function openTab(tabName) { + // Hide all tab content + var tabContents = document.getElementsByClassName("tab-content"); + for (var i = 0; i < tabContents.length; i++) { + tabContents[i].style.display = "none"; + document.getElementById(tabContents[i].id + "-tab").style.backgroundColor = "rgb(52,152,219)"; + } + + // Show the selected tab content + var selectedTab = document.getElementById(tabName); + if (selectedTab) { + selectedTab.style.display = "block"; + document.getElementById(tabName + "-tab").style.backgroundColor = "rgb(173,216,230)"; + } +} + +// Function to display a flow for a specific virtual node +function oneFlow() { + const url = `https://localhost:8888/api/v1/flow/${selectedX500Name}/${document.getElementById('clientID').value}`; + + // Validate clientID input + if (document.getElementById('clientID').value == "") { + alert("Please input a clientId"); + return; + } + + // Perform a GET request to display a flow + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + let msg = "null"; + let typ = "null"; + + // Check if the flow status is "FAILED" and extract error details + if (data.flowStatus == "FAILED") { + msg = data.flowError.message; + typ = data.flowError.type; + } + + // Construct a string with the flow status responses + const flowStatusResponses = "client request ID: " + data.clientRequestId + + "\nFlow Result " + data.flowResult + + "\nFlow Error Message: " + msg + + "\nflow error type: " + typ + + "\nFlow ID: " + data.flowId + + "\nFlow status: " + data.flowStatus + + "\nHolding identity short hash: " + data.holdingIdentityShortHash + + "\nTime stamp: " + data.timestamp; + + console.log('API Result:', flowStatusResponses); + + // Display the flow status responses in the result div + document.getElementById('idResutl').innerHTML = `
${flowStatusResponses}
`; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to determine which flow-related action to execute based on user input +function executeButtonFlow() { + // Check the value of the dropdown to determine which action to perform + if (document.getElementById("dropdown").value == "option1") { + getAllFlowResult(); + } else { + oneFlow(); + } +} + +function queryDropDownChange(){ + if (document.getElementById("dropdown").value == "option1") { + document.getElementById("clientID").style.display = 'none'; + }else{ + document.getElementById("clientID").style.display = 'block'; + + } +} \ No newline at end of file diff --git a/java-samples/customJSON/FlowManagementUI/static/css/main.css b/java-samples/customJSON/FlowManagementUI/static/css/main.css new file mode 100644 index 0000000..5e4d849 --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/static/css/main.css @@ -0,0 +1,202 @@ +body { + font-family: 'Roboto', sans-serif; + background-color: #f4f4f4; + color: #333; + margin: 50px; /* Add margin to the entire body */ + padding: 0; +} + +h1 { + text-align: center; + color: #0e0c0c; +} + +/* Style for the label */ +label { + display: block; + margin-bottom: 10px; + font-weight: bold; + color: #333; +} +/* Style for the dropdown button */ +select { + width: 20%; + padding: 8px; + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 14px; + color: #555; +} + +#itemDropdown { + width: 40%; + padding: 10px; + box-sizing: border-box; +} + +#clientID{ + padding: 8px; + margin-bottom: 15px; + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 14px; + color: #555; +} + +#OneFlowButon{ + padding: 8px; + margin-bottom: 15px; + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 14px; +} + +#result { + margin-bottom: 10px; +} + +/* Button styling */ +button { + background-color: #3498db; + color: #fff; + padding: 10px 25px; + font-size: 16px; + border: 2px; + border-radius: 15px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +button:hover { + background-color: #2980b9; +} + +/* Tab styling */ +.tab { + list-style-type: none; /* Remove default list styles */ + display: inline-block; /* Display tabs inline */ + padding: 2px 00px; /* Add padding to the tabs */ + margin: 0 1px; /* Add margin between tabs */ + cursor: pointer; /* Change cursor to pointer on hover */ +} + + +.tab li { + flex: 1; + text-align: center; + padding: 10px; + background-color: #3498db; + color: #fff; + border-radius: 8px 15px 0 0; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.tab li:hover { + background-color: #2980b9; +} + +/* Tab content styling */ +.tab-content { + display: none; + padding: 20px; + border: 1px solid #3498db; + border-radius: 0 0 5px 5px; + background-color: #fff; +} + +.styled-input { + width: 100%; + padding: 10px; + margin-bottom: 10px; + box-sizing: border-box; +} + +.output { + border: 1px solid #3498db; + padding: 10px; + border-radius: 5px; + background-color: #fff; + box-sizing: border-box; +} + +#idResutl { + border: 1px solid #3498db; + padding: 10px; + border-radius: 5px; + background-color: #fff; + margin-top: 10px; + height: 290px; + max-height: 290px; /* Set a maximum height for the scroll box */ + overflow-y: auto; /* Enable vertical scrolling if content exceeds the box height */ +} + +/* Responsive design */ +@media screen and (max-width: 600px) { + .tab li { + border-radius: 5px; + margin-bottom: 5px; + } + .tab-content { + border-radius: 5px; + } +} +#call { + display: -ms-inline-flexbox; + flex-wrap: wrap; + +} + +/* Style for side-by-side input boxes */ +.flowcall-container { + display: flex; +} + +.input-box, .text-box { + width: 150px; /* Set the desired width */ + margin-right: 10px; /* Optional: Add margin for spacing between input boxes */ + border-radius: 5px; + border: 1px solid #3498db; +} + +.queryoption-container{ + display: flex; +} + + + +#requestBody { + flex: 1; + box-sizing: border-box; + width: 100px; /* Set the desired width */ + height: 300px; /* Set the desired height */ + padding: 10px; /* Optional: Add padding for better aesthetics */ + margin-right: 10px; /* Add some margin between the input and button */ +} + +#postBtn { + flex: 0 0 auto; /* Don't allow the button to grow or shrink */ + margin-top: 10px; /* Add some margin between the button and result box */ + margin-right: 10px; /* Add some margin between the button and result box */ + width: 500px; /* Set the desired width */ + height: 50px; /* Set the desired height */ +} + +#queryResult { + flex: 1; + box-sizing: border-box; + width: 100px; /* Set the desired width */ + height: 300px; /* Set the desired height */ + padding: 10px; /* Optional: Add padding for better aesthetics */ +} +.content-box{ + padding: 10px; /* Add padding to the content boxes */ +} + + diff --git a/java-samples/customJSON/FlowManagementUI/templates/index.html b/java-samples/customJSON/FlowManagementUI/templates/index.html new file mode 100644 index 0000000..44df7ac --- /dev/null +++ b/java-samples/customJSON/FlowManagementUI/templates/index.html @@ -0,0 +1,87 @@ + + + + + + + + + + Flask Frontend Example + + + + + + + + + + + + +

Flow Management APIs

+
+ + + + + + +

Please select a flow initiator

+ + + + + +
+ +
+ + + + +
+
+ + +
Result will be displayed here
+
+ +
+
+ + +
+ + + +
+ + + + + + + + +
+ + + + + +
Result will be displayed here
+
+ + diff --git a/java-samples/customJSON/README.md b/java-samples/customJSON/README.md new file mode 100644 index 0000000..bc883eb --- /dev/null +++ b/java-samples/customJSON/README.md @@ -0,0 +1,123 @@ +# cordapp-template-java (Corda v5.2) + + +## This template repository provides: + +- A pre-setup Cordapp Project which you can use as a starting point to develop your own prototypes. + +- A base Gradle configuration which brings in the dependencies you need to write and test a Corda 5 Cordapp. + +- A set of Gradle helper tasks, provided by the [Corda runtime gradle plugin](https://github.com/corda/corda-runtime-os/tree/release/os/5.2/tools/corda-runtime-gradle-plugin#readme), which speed up and simplify the development and deployment process. + +- Debug configuration for debugging a local Corda cluster. + +- The MyFirstFlow code which forms the basis of this getting started documentation, this is located in package com.r3.developers.cordapptemplate.flowexample + +- A UTXO example in package com.r3.developers.cordapptemplate.customJSON packages + +- Ability to configure the Members of the Local Corda Network. + +To find out how to use the template, please refer to the *CorDapp Template* subsection within the *Developing Applications* section in the latest Corda 5 documentation at https://docs.r3.com/ + +## Prerequisite +1. Java 17 +2. Corda-cli (v5.2), Download [here](https://github.com/corda/corda-runtime-os/releases/tag/release-5.2.0.0). You need to install Java 17 first. +3. Docker Desktop + +## Setting up + +1. We will begin our test deployment with clicking the `startCorda`. This task will load up the combined Corda workers in docker. + A successful deployment will allow you to open the REST APIs at: https://localhost:8888/api/v5_2/swagger#. You can test out some of the + functions to check connectivity. (GET /cpi function call should return an empty list as for now.) +2. We will now deploy the cordapp with a click of `vNodeSetup` task. Upon successful deployment of the CPI, the GET /cpi function call should now return the meta data of the cpi you just upload + +## Flow Management Tool[Optional] +We had developed a simple GUI for you to interact with the cordapp. You can access the website by using https://localhost:5000 or https://127.0.0.1:5000. The Flow Management Tool will automatically connect with the CorDapp running locally from your Corda cluster. You can test the connection by click on the dropdown list at the Flow Initiator section. You should be able to see the vNodes of your started CorDapp. You can easily trigger and query a Corda flow. + + + +![image](https://github.com/corda/cordapp-template-kotlin/assets/51169685/88e6568e-49b4-46a8-b1e1-34140bcf03a9) + + +## Running the Chat app +We have built a simple one to one chat app to demo some functionalities of the next gen Corda platform. + +In this app you can: +1. Create a new chat with a counterparty. `CreateNewChatFlow` +2. List out the chat entries you had. `ListChatsFlow` +3. Individually query out the history of one chat entry. `GetChatFlowArgs` +4. Continue chatting within the chat entry with the counterparty. `UpdateChatFlow` + + + + +### Running the chat app + +In Corda 5, flows will be triggered via `POST /flow/{holdingidentityshorthash}` and flow result will need to be view at `GET /flow/{holdingidentityshorthash}/{clientrequestid}` +* holdingidentityshorthash: the id of the network participants, ie Bob, Alice, Charlie. You can view all the short hashes of the network member with another gradle task called `listVNodes` +* clientrequestid: the id you specify in the flow requestBody when you trigger a flow. + +#### Step 1: Create Chat Entry +Pick a VNode identity to initiate the chat, and get its short hash. (Let's pick Alice. Dont pick Bob because Bob is the person who we will have the chat with). + +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} +``` + +After trigger the create-chat flow, hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the short hash(Alice's hash) and clientrequestid to view the flow result + +#### Step 2: List the chat +In order to continue the chat, we would need the chat ID. This step will bring out all the chat entries this entity (Alice) has. +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.ListChatsFlow", + "requestBody": {} +} +``` +After trigger the list-chats flow, again, we need to hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and check the result. As the screenshot shows, in the response body, +we will see a list of chat entries, but it currently only has one entry. And we can see the id of the chat entry. Let's record that id. + + +#### Step 3: Continue the chat with `UpdateChatFlow` +In this step, we will continue the chat between Alice and Bob. +Goto `POST /flow/{holdingidentityshorthash}`, enter the identity short hash and request body. Note that here we can have either Alice or Bob's short hash. If you enter Alice's hash, +this message will be recorded as a message from Alice, vice versa. And the id field is the chat entry id we got from the previous step. +``` +{ + "clientRequestId": "update-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.UpdateChatFlow", + "requestBody": { + "id":" ** fill in id **", + "message": "How are you today?" + } +} +``` +And as for the result of this flow, go to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the required fields. + +#### Step 4: See the whole chat history of one chat entry +After a few back and forth of the messaging, you can view entire chat history by calling GetChatFlow. + +``` +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.GetChatFlow", + "requestBody": { + "id":" ** fill in id **", + "numberOfRecords":"4" + } +} +``` +And as for the result, you need to go to the Get API again and enter the short hash and client request ID. + +Thus, we have concluded a full run through of the chat app. diff --git a/java-samples/customJSON/build.gradle b/java-samples/customJSON/build.gradle new file mode 100644 index 0000000..88f4106 --- /dev/null +++ b/java-samples/customJSON/build.gradle @@ -0,0 +1,74 @@ +import static org.gradle.api.JavaVersion.VERSION_17 +import static org.gradle.jvm.toolchain.JavaLanguageVersion.of + +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'net.corda.cordapp.cordapp-configuration' + id 'org.jetbrains.kotlin.plugin.jpa' + id 'java' + id 'maven-publish' + id 'net.corda.gradle.plugin' +} + +allprojects { + group 'com.r3.developers.cordapptemplate' + version '1.0-SNAPSHOT' + + // Configure Corda runtime gradle plugin + cordaRuntimeGradlePlugin { + notaryVersion = cordaNotaryPluginsVersion + notaryCpiName = "NotaryServer" + corDappCpiName = "MyCorDapp" + cpiUploadTimeout = "30000" + vnodeRegistrationTimeout = "60000" + cordaProcessorTimeout = "300000" + workflowsModuleName = "workflows" + cordaClusterURL = "https://localhost:8888" + cordaRestUser = "admin" + cordaRestPasswd ="admin" + composeFilePath = "config/combined-worker-compose.yaml" + networkConfigFile = "config/static-network-config.json" + r3RootCertFile = "config/r3-ca-key.pem" + skipTestsDuringBuildCpis = "false" + cordaRuntimePluginWorkspaceDir = "workspace" + cordaBinDir = "${System.getProperty("user.home")}/.corda/corda5" + cordaCliBinDir = "${System.getProperty("user.home")}/.corda/cli" + } + + java { + toolchain { + languageVersion = of(VERSION_17.majorVersion.toInteger()) + } + withSourcesJar() + } + + // Declare the set of Java compiler options we need to build a CorDapp. + tasks.withType(JavaCompile) { + // -parameters - Needed for reflection and serialization to work correctly. + options.compilerArgs += [ + "-parameters" + ] + } + + repositories { + // All dependencies are held in Maven Central + mavenLocal() + mavenCentral() + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } + +} + +publishing { + publications { + maven(MavenPublication) { + artifactId "corda-dev-template-java-sample" + groupId project.group + artifact jar + } + } +} + diff --git a/java-samples/customJSON/config/combined-worker-compose.yaml b/java-samples/customJSON/config/combined-worker-compose.yaml new file mode 100644 index 0000000..da2495d --- /dev/null +++ b/java-samples/customJSON/config/combined-worker-compose.yaml @@ -0,0 +1,87 @@ +version: '2' +services: + postgresql: + image: postgres:14.10 + restart: unless-stopped + tty: true + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=cordacluster + ports: + - 5432:5432 + + kafka: + image: confluentinc/cp-kafka:7.6.0 + ports: + - 9092:9092 + environment: + KAFKA_NODE_ID: 1 + CLUSTER_ID: ZDFiZmU3ODUyMzRiNGI3NG + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,DOCKER_INTERNAL://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,DOCKER_INTERNAL://kafka:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,DOCKER_INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER_INTERNAL + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_DEFAULT_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + + kafka-create-topics: + image: openjdk:17-jdk + depends_on: + - kafka + volumes: + - ${CORDA_CLI:-~/.corda/cli}:/opt/corda-cli + working_dir: /opt/corda-cli + command: [ + "java", + "-jar", + "corda-cli.jar", + "topic", + "-b=kafka:29092", + "create", + "connect" + ] + + corda: + image: corda/corda-os-combined-worker-kafka:5.2.0.0 + depends_on: + - postgresql + - kafka + - kafka-create-topics + volumes: + - ../config:/config + - ../logs:/logs + environment: + JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + LOG4J_CONFIG_FILE: config/log4j2.xml + CONSOLE_LOG_LEVEL: info + ENABLE_LOG4J2_DEBUG: false + command: [ + "-mbus.busType=KAFKA", + "-mbootstrap.servers=kafka:29092", + "-spassphrase=password", + "-ssalt=salt", + "-ddatabase.user=user", + "-ddatabase.pass=password", + "-ddatabase.jdbc.url=jdbc:postgresql://postgresql:5432/cordacluster", + "-ddatabase.jdbc.directory=/opt/jdbc-driver/" + ] + ports: + - 8888:8888 + - 7004:7004 + - 5005:5005 + + flow-management-tool: + depends_on: + - corda + build: + context: ../FlowManagementUI + dockerfile: Dockerfile + ports: + - 5000:5000 \ No newline at end of file diff --git a/java-samples/customJSON/config/gradle-plugin-default-key.pem b/java-samples/customJSON/config/gradle-plugin-default-key.pem new file mode 100644 index 0000000..5294bbd --- /dev/null +++ b/java-samples/customJSON/config/gradle-plugin-default-key.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC +MQ8wDQYDVQQHDAZMb25kb24xDjAMBgNVBAoMBUNvcmRhMQswCQYDVQQLDAJSMzEe +MBwGA1UEAwwVQ29yZGEgRGV2IENvZGUgU2lnbmVyMB4XDTIwMDYyNTE4NTI1NFoX +DTMwMDYyMzE4NTI1NFowWzELMAkGA1UEBhMCR0IxDzANBgNVBAcTBkxvbmRvbjEO +MAwGA1UEChMFQ29yZGExCzAJBgNVBAsTAlIzMR4wHAYDVQQDExVDb3JkYSBEZXYg +Q29kZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQDjSJtzQ+ldDFt +pHiqdSJebOGPZcvZbmC/PIJRsZZUF1bl3PfMqyG3EmAe0CeFAfLzPQtf2qTAnmJj +lGTkkQhxo0MwQTATBgNVHSUEDDAKBggrBgEFBQcDAzALBgNVHQ8EBAMCB4AwHQYD +VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh +ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL +zU+Rc5yMtcOY4/moZUq36r0Ilg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/java-samples/customJSON/config/log4j2.xml b/java-samples/customJSON/config/log4j2.xml new file mode 100644 index 0000000..4e297be --- /dev/null +++ b/java-samples/customJSON/config/log4j2.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/java-samples/customJSON/config/r3-ca-key.pem b/java-samples/customJSON/config/r3-ca-key.pem new file mode 100644 index 0000000..a803613 --- /dev/null +++ b/java-samples/customJSON/config/r3-ca-key.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y +ithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If +xp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV +ySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO +DCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ +jdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/ +CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi +EhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM +fRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY +uKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK +chYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t +9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +hjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2 +SV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd ++SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc +fFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa +sjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N +cCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N +0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie +4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI +r/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1 +/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm +gKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/java-samples/customJSON/config/static-network-config.json b/java-samples/customJSON/config/static-network-config.json new file mode 100644 index 0000000..b0f2519 --- /dev/null +++ b/java-samples/customJSON/config/static-network-config.json @@ -0,0 +1,23 @@ +[ + { + "x500Name" : "CN=Alice, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=Charlie, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=Dave, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=NotaryRep1, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "NotaryServer", + "serviceX500Name": "CN=NotaryService, OU=Test Dept, O=R3, L=London, C=GB" + } +] diff --git a/java-samples/customJSON/contracts/build.gradle b/java-samples/customJSON/contracts/build.gradle new file mode 100644 index 0000000..af7e199 --- /dev/null +++ b/java-samples/customJSON/contracts/build.gradle @@ -0,0 +1,87 @@ + +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:$slf4jVersion" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by example tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" +// testImplementation "com.r3.corda.ledger.utxo:contract-testing:$contractTestingVersion" +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + contract { + name contractsModule + versionId 1 + licence cordappLicense + vendor cordappVendorName + } +} + +// Use the name of the contract module as the name of the generated CPK and CPB. +archivesBaseName = cordapp.contract.name.isPresent() ? cordapp.contract.name.get() : contractsModule + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleCommands.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleCommands.java new file mode 100644 index 0000000..2746877 --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleCommands.java @@ -0,0 +1,9 @@ +package com.r3.developers.apples.contracts; + +import net.corda.v5.ledger.utxo.Command; + +public interface AppleCommands extends Command { + public class Issue implements AppleCommands { }; + public class Redeem implements AppleCommands { }; + public class PackBasket implements AppleCommands { }; +} \ No newline at end of file diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleStampContract.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleStampContract.java new file mode 100644 index 0000000..a57002f --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/AppleStampContract.java @@ -0,0 +1,38 @@ +package com.r3.developers.apples.contracts; + +import com.r3.developers.apples.states.AppleStamp; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.Command; +import net.corda.v5.ledger.utxo.Contract; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; + +public class AppleStampContract implements Contract { + + @Override + public void verify(UtxoLedgerTransaction transaction) { + // Extract the command from the transaction + // Verify the transaction according to the intention of the transaction + final Command command = transaction.getCommands().get(0); + if (command instanceof AppleCommands.Issue) { + AppleStamp output = transaction.getOutputStates(AppleStamp.class).get(0); + require( + transaction.getOutputContractStates().size() == 1, + "This transaction should only have one AppleStamp state as output" + ); + require( + !output.getStampDesc().isBlank(), + "The output AppleStamp state should have clear description of the type of redeemable goods"); + } else if (command instanceof AppleCommands.Redeem) { + // Transaction verification will happen in BasketOfApplesContract + } else { + //Unrecognised Command Type + throw new IllegalArgumentException(String.format("Incorrect type of AppleStamp commands: %s", command.getClass().toString())); + } + } + + private void require(boolean asserted, String errorMessage) { + if (!asserted) { + throw new CordaRuntimeException(errorMessage); + } + } +} \ No newline at end of file diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/BasketOfApplesContract.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/BasketOfApplesContract.java new file mode 100644 index 0000000..b19802c --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/contracts/BasketOfApplesContract.java @@ -0,0 +1,66 @@ +package com.r3.developers.apples.contracts; + +import com.r3.developers.apples.states.AppleStamp; +import com.r3.developers.apples.states.BasketOfApples; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.Command; +import net.corda.v5.ledger.utxo.Contract; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; +import java.util.List; + +public class BasketOfApplesContract implements Contract { + + @Override + public void verify(UtxoLedgerTransaction transaction) { + // Extract the command from the transaction + final Command command = transaction.getCommands().get(0); + + if (command instanceof AppleCommands.PackBasket) { + //Retrieve the output state of the transaction + BasketOfApples output = transaction.getOutputStates(BasketOfApples.class).get(0); + require( + transaction.getOutputContractStates().size() == 1, + "This transaction should only output one BasketOfApples state" + ); + require( + !output.getDescription().isBlank(), + "The output BasketOfApples state should have clear description of Apple product" + ); + require( + output.getWeight() > 0, + "The output BasketOfApples state should have non zero weight" + ); + } else if (command instanceof AppleCommands.Redeem) { + require( + transaction.getInputContractStates().size() == 2, + "This transaction should consume two states" + ); + + // Retrieve the inputs to this transaction, which should be exactly one AppleStamp + // and one BasketOfApples + List stampInputs = transaction.getInputStates(AppleStamp.class); + List basketInputs = transaction.getInputStates(BasketOfApples.class); + + require( + !stampInputs.isEmpty() && !basketInputs.isEmpty(), + "This transaction should have exactly one AppleStamp and one BasketOfApples input state" + ); + require( + stampInputs.get(0).getIssuer().equals(basketInputs.get(0).getFarm()), + "The issuer of the Apple stamp should be the producing farm of this basket of apple" + ); + require( + basketInputs.get(0).getWeight() > 0, + "The basket of apple has to weigh more than 0" + ); + } else { + throw new IllegalArgumentException(String.format("Incorrect type of BasketOfApples commands: %s", command.getClass().toString())); + } + } + + private void require(boolean asserted, String errorMessage) { + if (!asserted) { + throw new CordaRuntimeException(errorMessage); + } + } +} \ No newline at end of file diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/AppleStamp.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/AppleStamp.java new file mode 100644 index 0000000..61dfd09 --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/AppleStamp.java @@ -0,0 +1,47 @@ +package com.r3.developers.apples.states; + +import com.r3.developers.apples.contracts.AppleStampContract; +import net.corda.v5.base.annotations.ConstructorForDeserialization; +import net.corda.v5.ledger.utxo.BelongsToContract; +import net.corda.v5.ledger.utxo.ContractState; +import java.security.PublicKey; +import java.util.*; + +@BelongsToContract(AppleStampContract.class) +public class AppleStamp implements ContractState { + private UUID id; + private String stampDesc; + private PublicKey issuer; + private PublicKey holder; + private List participants; + + @ConstructorForDeserialization + public AppleStamp(UUID id, String stampDesc, PublicKey issuer, PublicKey holder, List participants) { + this.id = id; + this.stampDesc = stampDesc; + this.issuer = issuer; + this.holder = holder; + this.participants = participants; + } + + @Override + public List getParticipants() { + return participants; + } + + public UUID getId() { + return this.id; + } + + public String getStampDesc() { + return this.stampDesc; + } + + public PublicKey getIssuer() { + return this.issuer; + } + + public PublicKey getHolder() { + return this.holder; + } +} \ No newline at end of file diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/BasketOfApples.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/BasketOfApples.java new file mode 100644 index 0000000..67b1936 --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/apples/states/BasketOfApples.java @@ -0,0 +1,53 @@ +package com.r3.developers.apples.states; + +import com.r3.developers.apples.contracts.BasketOfApplesContract; +import net.corda.v5.base.annotations.ConstructorForDeserialization; +import net.corda.v5.ledger.utxo.BelongsToContract; +import net.corda.v5.ledger.utxo.ContractState; +import java.security.PublicKey; +import java.util.List; + +@BelongsToContract(BasketOfApplesContract.class) +public class BasketOfApples implements ContractState { + private String description; + private PublicKey farm; + private PublicKey owner; + private int weight; + private List participants; + + @ConstructorForDeserialization + public BasketOfApples( + String description, PublicKey farm, PublicKey owner, int weight, List participants + ) { + this.description = description; + this.farm = farm; + this.owner = owner; + this.weight = weight; + this.participants = participants; + } + + public List getParticipants() { + return participants; + } + + public String getDescription() { + return this.description; + } + + public PublicKey getFarm() { + return this.farm; + } + + public PublicKey getOwner() { + return this.owner; + } + + public int getWeight() { + return this.weight; + } + + public BasketOfApples changeOwner(PublicKey buyer) { + List participants = List.of(farm, buyer); + return new BasketOfApples(this.description, this.farm, buyer, this.weight, participants); + } +} \ No newline at end of file diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.java new file mode 100644 index 0000000..1eaacbe --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.java @@ -0,0 +1,72 @@ +package com.r3.developers.cordapptemplate.customJSON.contracts; + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.Command; +import net.corda.v5.ledger.utxo.Contract; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class ChatContract implements Contract { + + private final static Logger log = LoggerFactory.getLogger(ChatContract.class); + + // Use constants to hold the error messages + // This allows the tests to use them, meaning if they are updated you won't need to fix tests just because the wording was updated + static final String REQUIRE_SINGLE_COMMAND = "Require a single command."; + static final String UNKNOWN_COMMAND = "Unsupported command"; + static final String OUTPUT_STATE_SHOULD_ONLY_HAVE_TWO_PARTICIPANTS = "The output state should have two and only two participants."; + static final String TRANSACTION_SHOULD_BE_SIGNED_BY_ALL_PARTICIPANTS = "The transaction should have been signed by both participants."; + + static final String CREATE_COMMAND_SHOULD_HAVE_NO_INPUT_STATES = "When command is Create there should be no input states."; + static final String CREATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE = "When command is Create there should be one and only one output state."; + + static final String UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_INPUT_STATE = "When command is Update there should be one and only one input state."; + static final String UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE = "When command is Update there should be one and only one output state."; + static final String UPDATE_COMMAND_ID_SHOULD_NOT_CHANGE = "When command is Update id must not change."; + static final String UPDATE_COMMAND_CHATNAME_SHOULD_NOT_CHANGE = "When command is Update chatName must not change."; + static final String UPDATE_COMMAND_PARTICIPANTS_SHOULD_NOT_CHANGE = "When command is Update participants must not change."; + + public static class Create implements Command { } + public static class Update implements Command { } + + @Override + public void verify(UtxoLedgerTransaction transaction) { + + requireThat( transaction.getCommands().size() == 1, REQUIRE_SINGLE_COMMAND); + Command command = transaction.getCommands().get(0); + + ChatState output = transaction.getOutputStates(ChatState.class).get(0); + + requireThat(output.getParticipants().size() == 2, OUTPUT_STATE_SHOULD_ONLY_HAVE_TWO_PARTICIPANTS); + requireThat(transaction.getSignatories().containsAll(output.getParticipants()), TRANSACTION_SHOULD_BE_SIGNED_BY_ALL_PARTICIPANTS); + + if(command.getClass() == Create.class) { + requireThat(transaction.getInputContractStates().isEmpty(), CREATE_COMMAND_SHOULD_HAVE_NO_INPUT_STATES); + requireThat(transaction.getOutputContractStates().size() == 1, CREATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE); + } + else if(command.getClass() == Update.class) { + requireThat(transaction.getInputContractStates().size() == 1, UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_INPUT_STATE); + requireThat(transaction.getOutputContractStates().size() == 1, UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE); + + ChatState input = transaction.getInputStates(ChatState.class).get(0); + requireThat(input.getId().equals(output.getId()), UPDATE_COMMAND_ID_SHOULD_NOT_CHANGE); + requireThat(input.getChatName().equals(output.getChatName()), UPDATE_COMMAND_CHATNAME_SHOULD_NOT_CHANGE); + requireThat( + input.getParticipants().containsAll(output.getParticipants()) && + output.getParticipants().containsAll(input.getParticipants()), + UPDATE_COMMAND_PARTICIPANTS_SHOULD_NOT_CHANGE); + } + else { + throw new CordaRuntimeException(UNKNOWN_COMMAND); + } + } + + private void requireThat(boolean asserted, String errorMessage) { + if(!asserted) { + throw new CordaRuntimeException("Failed requirement: " + errorMessage); + } + } +} diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.java new file mode 100644 index 0000000..7223ba2 --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.java @@ -0,0 +1,37 @@ +package com.r3.developers.cordapptemplate.customJSON.states; + +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.ledger.utxo.query.json.ContractStateVaultJsonFactory; + +import java.util.HashMap; +import java.util.Map; + +// This class represents a custom JSON factory for serializing/deserializing ChatState objects to JSON format after each use. +public class ChatJsonFactory implements ContractStateVaultJsonFactory { + + // This function specifies the type of state this factory is responsible for. + @Override + public Class getStateType() { + return ChatState.class; + } + + // Constants used for JSON key names. + private static final String ID = "Id"; + private static final String CHATNAME = "chatName"; + private static final String MESSAGE = "messageContent"; + private static final String MESSAGEFROM = "messageContentFrom"; + + // This function creates a JSON representation of a ChatState object. + @Override + public String create(ChatState state, JsonMarshallingService jsonMarshallingService) { + // Constructs a map representing the ChatState object using the provided constants. + Map jsonMap = new HashMap<>(); + jsonMap.put(ID, state.getId()); + jsonMap.put(CHATNAME, state.getChatName()); + jsonMap.put(MESSAGE, state.getMessage()); + jsonMap.put(MESSAGEFROM, state.getMessageFrom()); + + // Uses the JsonMarshallingService's format() function to serialize the map to JSON. + return jsonMarshallingService.format(jsonMap); + } +} diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatState.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatState.java new file mode 100644 index 0000000..c6d80eb --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/ChatState.java @@ -0,0 +1,55 @@ +package com.r3.developers.cordapptemplate.customJSON.states; + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract; +import net.corda.v5.base.annotations.ConstructorForDeserialization; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.BelongsToContract; +import net.corda.v5.ledger.utxo.ContractState; + +import java.security.PublicKey; +import java.util.*; + +@BelongsToContract(ChatContract.class) +public class ChatState implements ContractState { + + private UUID id; + private String chatName; + private MemberX500Name messageFrom; + private String message; + public List participants; + + // Allows serialisation and to use a specified UUID. + @ConstructorForDeserialization + public ChatState(UUID id, + String chatName, + MemberX500Name messageFrom, + String message, + List participants) { + this.id = id; + this.chatName = chatName; + this.messageFrom = messageFrom; + this.message = message; + this.participants = participants; + } + + public UUID getId() { + return id; + } + public String getChatName() { + return chatName; + } + public MemberX500Name getMessageFrom() { + return messageFrom; + } + public String getMessage() { + return message; + } + + public List getParticipants() { + return participants; + } + + public ChatState updateMessage(MemberX500Name name, String message) { + return new ChatState(id, chatName, name, message, participants); + } +} \ No newline at end of file diff --git a/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.java b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.java new file mode 100644 index 0000000..e40830d --- /dev/null +++ b/java-samples/customJSON/contracts/src/main/java/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.java @@ -0,0 +1,25 @@ +package com.r3.developers.cordapptemplate.customJSON.states; + +import net.corda.v5.ledger.utxo.query.VaultNamedQueryFactory; +import net.corda.v5.ledger.utxo.query.registration.VaultNamedQueryBuilderFactory; +import org.jetbrains.annotations.NotNull; + +public class CustomChatQuery implements VaultNamedQueryFactory { + + + @Override + public void create(@NotNull VaultNamedQueryBuilderFactory vaultNamedQueryBuilderFactory) { + + vaultNamedQueryBuilderFactory.create("GET_ALL_MSG") + .whereJson( + "WHERE visible_states.custom_representation ? 'com.r3.developers.cordapptemplate.utxoexample.states.ChatState' " + ) + .register(); + + vaultNamedQueryBuilderFactory.create("GET_MSG_FROM") + .whereJson( + "WHERE visible_states.custom_representation -> 'com.r3.developers.cordapptemplate.utxoexample.states.ChatState' ->> 'messageContentFrom' = :nameOfSender" + ) + .register(); + } +} diff --git a/java-samples/customJSON/gradle.properties b/java-samples/customJSON/gradle.properties new file mode 100644 index 0000000..dc9c265 --- /dev/null +++ b/java-samples/customJSON/gradle.properties @@ -0,0 +1,73 @@ +kotlin.code.style=official + +# Specify the version of the Corda-API to use. +# This needs to match the version supported by the Corda Cluster the CorDapp will run on. +cordaApiVersion=5.2.0.52 + +# Specify the version of the notary plugins to use. +# Currently packaged as part of corda-runtime-os, so should be set to a corda-runtime-os version. +cordaNotaryPluginsVersion=5.2.0.0 + +# Specify the version of the cordapp-cpb and cordapp-cpk plugins +cordaPluginsVersion=7.0.4 + +# Specify the version of the Corda runtime Gradle plugin to use +cordaGradlePluginVersion=5.2.0.0 + +# Specify the name of the workflows module +# This will be the name of the generated cpk and cpb files +workflowsModule=workflows + +# Specify the name of the contracts module +# This will be the name of the generated cpk and cpb files +contractsModule=contracts + +# Specify the location of where Corda 5 binaries can be downloaded +# Relative path from user.home +cordaBinariesDirectory = .corda/corda5 + +# Specify the location of where Corda 5 CLI binaries can be downloaded +# Relative path from user.home +cordaCliBinariesDirectory = .corda/cli + +# Metadata for the CorDapp. +cordappLicense="Apache License, Version 2.0" +cordappVendorName="R3" + +# For the time being this just needs to be set to a dummy value. +platformVersion = 999 + +# Version of Kotlin to use. +# We recommend using a version close to that used by Corda-API. +kotlinVersion = 1.7.21 + +# Do not use default dependencies. +kotlin.stdlib.default.dependency=false + +# Test Tooling Dependency Versions +junitVersion = 5.10.0 +mockitoKotlinVersion=4.0.0 +mockitoVersion=4.6.1 +hamcrestVersion=2.2 +assertjVersion = 3.24.1 +contractTestingVersion=1.0.0-beta-+ +jacksonVersion=2.15.2 +slf4jVersion=1.7.36 + +# Specify the maximum amount of time allowed for the CPI upload +# As your CorDapp grows you might need to increase this +# Value is in milliseconds +cpiUploadDefault=10000 + +# Specify the length of time, in milliseconds, that Corda waits for an individual event to process. +# Keep at -1 to use the default. Refer to the Corda Api Docs for the exact value. +processorTimeout=-1 + +# Specify the maximum amount of time allowed to check all vNodes are registered +# Value is in milliseconds +vnodeRegistrationTimeoutDefault=30000 + +# Specify if you want to run the contracts and workflows tests as part of the corda-runtime-plugin-cordapp > buildCpis task +# False by default, will execute the tests every time you stand the template up - gives extra protection +# Set to true to skip the tests, making the launching process quicker. You will be responsible for running workflow tests yourself +skipTestsDuringBuildCpis=false diff --git a/java-samples/customJSON/gradle/wrapper/gradle-wrapper.jar b/java-samples/customJSON/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..033e24c4cdf41af1ab109bc7f253b2b887023340 GIT binary patch literal 63375 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≫L+crb^Bz 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/java-samples/customJSON/gradlew.bat b/java-samples/customJSON/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/java-samples/customJSON/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/java-samples/customJSON/settings.gradle b/java-samples/customJSON/settings.gradle new file mode 100644 index 0000000..9c62177 --- /dev/null +++ b/java-samples/customJSON/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + // Declare the repositories where plugins are stored. + repositories { + gradlePluginPortal() + mavenCentral() + mavenLocal() + } + + // The plugin dependencies with versions of the plugins congruent with the specified CorDapp plugin version, + // Corda API version, and Kotlin version. + plugins { + id 'net.corda.plugins.cordapp-cpk2' version cordaPluginsVersion + id 'net.corda.plugins.cordapp-cpb2' version cordaPluginsVersion + id 'net.corda.cordapp.cordapp-configuration' version cordaApiVersion + id 'org.jetbrains.kotlin.jvm' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.jpa' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.allopen' version kotlinVersion + id 'net.corda.gradle.plugin' version cordaGradlePluginVersion + } +} + +// Root project name, used in naming the project as a whole and used in naming objects built by the project. +rootProject.name = 'customJSON' +include ':workflows' +include ':contracts' + diff --git a/java-samples/customJSON/workflows/build.gradle b/java-samples/customJSON/workflows/build.gradle new file mode 100644 index 0000000..708edd6 --- /dev/null +++ b/java-samples/customJSON/workflows/build.gradle @@ -0,0 +1,96 @@ +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + constraints { + testImplementation('org.slf4j:slf4j-api') { + version { + // Corda cannot use SLF4J 2.x yet. + strictly '1.7.36' + } + } + } + // From other subprojects: + cordapp project(':contracts') + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:$slf4jVersion" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by example tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" + testImplementation "org.assertj:assertj-core:$assertjVersion" +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + workflow { + name workflowsModule + versionId 1 + licence cordappLicense + vendor cordappVendorName + } +} + +// Use the name of the workflow module as the name of the generated CPK and CPB. +archivesBaseName = cordapp.workflow.name.isPresent() ? cordapp.workflow.name.get() : workflowsModule + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.java new file mode 100644 index 0000000..5f82a45 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.java @@ -0,0 +1,102 @@ +package com.r3.developers.apples.workflows; + +import com.r3.developers.apples.contracts.AppleCommands; +import com.r3.developers.apples.states.AppleStamp; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatingFlow; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.application.messaging.FlowMessaging; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.common.NotaryLookup; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.membership.MemberInfo; +import net.corda.v5.membership.NotaryInfo; +import org.jetbrains.annotations.NotNull; +import java.security.PublicKey; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@InitiatingFlow(protocol = "create-and-issue-apple-stamp") +public class CreateAndIssueAppleStampFlow implements ClientStartableFlow { + + @CordaInject + public FlowMessaging flowMessaging; + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public MemberLookup memberLookup; + + @CordaInject + NotaryLookup notaryLookup; + + @CordaInject + UtxoLedgerService utxoLedgerService; + + public CreateAndIssueAppleStampFlow() {} + + @Suspendable + @Override + @NotNull + public String call(@NotNull ClientRequestBody requestBody) { + + CreateAndIssueAppleStampRequest request = requestBody.getRequestBodyAs(jsonMarshallingService, CreateAndIssueAppleStampRequest.class); + String stampDescription = request.getStampDescription(); + MemberX500Name holderName = request.getHolder(); + + final NotaryInfo notaryInfo = notaryLookup.lookup(request.getNotary()); + if (notaryInfo == null) { + throw new IllegalArgumentException("Notary " + request.getNotary() + " not found"); + } + + PublicKey issuer = memberLookup.myInfo().getLedgerKeys().get(0); + + final MemberInfo holderInfo = memberLookup.lookup(holderName); + if (holderInfo == null) { + throw new IllegalArgumentException(String.format("The holder %s does not exist within the network", holderName)); + } + + final PublicKey holder; + try { + holder = holderInfo.getLedgerKeys().get(0); + } catch (Exception e) { + throw new IllegalArgumentException(String.format("The holder %s has no ledger key", holderName)); + } + + AppleStamp newStamp = new AppleStamp( + UUID.randomUUID(), + stampDescription, + issuer, + holder, + List.of(issuer, holder) + ); + + UtxoSignedTransaction transaction = utxoLedgerService.createTransactionBuilder() + .setNotary(notaryInfo.getName()) + .addOutputState(newStamp) + .addCommand(new AppleCommands.Issue()) + .setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS)) + .addSignatories(List.of(issuer, holder)) + .toSignedTransaction(); + + FlowSession session = flowMessaging.initiateFlow(holderName); + + try { + // Send the transaction and state to the counterparty and let them sign it + // Then notarise and record the transaction in both parties' vaults. + utxoLedgerService.finalize(transaction, List.of(session)); + return newStamp.getId().toString(); + } catch (Exception e) { + return String.format("Flow failed, message: %s", e.getMessage()); + } + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampRequest.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampRequest.java new file mode 100644 index 0000000..7781a81 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampRequest.java @@ -0,0 +1,32 @@ +package com.r3.developers.apples.workflows; + +import net.corda.v5.base.types.MemberX500Name; + +public class CreateAndIssueAppleStampRequest { + + private String stampDescription; + + private MemberX500Name holder; + private MemberX500Name notary; + + // The JSON Marshalling Service, which handles serialisation, needs this constructor. + public CreateAndIssueAppleStampRequest() {} + + public CreateAndIssueAppleStampRequest(String stampDescription, MemberX500Name holder, MemberX500Name notary) { + this.stampDescription = stampDescription; + this.holder = holder; + this.notary = notary; + } + + public String getStampDescription() { + return stampDescription; + } + + public MemberX500Name getHolder() { + return holder; + } + + public MemberX500Name getNotary() { + return notary; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.java new file mode 100644 index 0000000..0c013bc --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.java @@ -0,0 +1,33 @@ +package com.r3.developers.apples.workflows; + +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatedBy; +import net.corda.v5.application.flows.ResponderFlow; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.ledger.utxo.FinalizationResult; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import org.jetbrains.annotations.NotNull; + +@InitiatedBy(protocol = "create-and-issue-apple-stamp") +public class CreateAndIssueAppleStampResponderFlow implements ResponderFlow { + + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Override + @Suspendable + public void call(@NotNull FlowSession session) { + // Receive, verify, validate, sign and record the transaction sent from the initiator + FinalizationResult transaction = utxoLedgerService.receiveFinality(session, _transaction -> { + /* + * [receiveFinality] will automatically verify the transaction and its signatures before signing it. + * However, just because a transaction is contractually valid doesn't mean we necessarily want to sign. + * What if we don't want to deal with the counterparty in question, or the value is too high, + * or we're not happy with the transaction's structure? [UtxoTransactionValidator] (the lambda created + * here) allows us to define the additional checks. If any of these conditions are not met, + * we will not sign the transaction - even if the transaction and its signatures are contractually valid. + */ + }); + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesFlow.java new file mode 100644 index 0000000..1ad749a --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesFlow.java @@ -0,0 +1,80 @@ +package com.r3.developers.apples.workflows; + +import com.r3.developers.apples.contracts.AppleCommands; +import com.r3.developers.apples.states.BasketOfApples; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.ledger.common.NotaryLookup; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.membership.NotaryInfo; +import org.jetbrains.annotations.NotNull; +import java.security.PublicKey; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.List; + +public class PackageApplesFlow implements ClientStartableFlow { + + @CordaInject + JsonMarshallingService jsonMarshallingService; + + @CordaInject + MemberLookup memberLookup; + + @CordaInject + NotaryLookup notaryLookup; + + @CordaInject + UtxoLedgerService utxoLedgerService; + + public PackageApplesFlow() {} + + @Suspendable + @Override + @NotNull + public String call(@NotNull ClientRequestBody requestBody) { + + PackageApplesRequest request = requestBody.getRequestBodyAs(jsonMarshallingService, PackageApplesRequest.class); + String appleDescription = request.getAppleDescription(); + int weight = request.getWeight(); + + final NotaryInfo notary = notaryLookup.lookup(request.getNotary()); + if (notary == null) { + throw new IllegalArgumentException("Notary " + request.getNotary() + " not found"); + } + + PublicKey myKey = memberLookup.myInfo().getLedgerKeys().get(0); + + // Building the output BasketOfApples state + BasketOfApples basket = new BasketOfApples( + appleDescription, + myKey, + myKey, + weight, + List.of(myKey) + ); + + // Create the transaction + UtxoSignedTransaction transaction = utxoLedgerService.createTransactionBuilder() + .setNotary(notary.getName()) + .addOutputState(basket) + .addCommand(new AppleCommands.PackBasket()) + .setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS)) + .addSignatories(List.of(myKey)) + .toSignedTransaction(); + + try { + // Record the transaction, no sessions are passed in as the transaction is only being + // recorded locally + return utxoLedgerService.finalize(transaction, Collections.emptyList()).getTransaction().getId().toString(); + } catch (Exception e) { + return String.format("Flow failed, message: %s", e.getMessage()); + } + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesRequest.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesRequest.java new file mode 100644 index 0000000..b032886 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/PackageApplesRequest.java @@ -0,0 +1,32 @@ +package com.r3.developers.apples.workflows; + +import net.corda.v5.base.types.MemberX500Name; + +public class PackageApplesRequest { + + private String appleDescription; + private MemberX500Name notary; + + private int weight; + + // The JSON Marshalling Service, which handles serialisation, needs this constructor. + public PackageApplesRequest() {} + + public PackageApplesRequest(String appleDescription, int weight, MemberX500Name notary) { + this.appleDescription = appleDescription; + this.weight = weight; + this.notary = notary; + } + + public MemberX500Name getNotary() { + return notary; + } + + public String getAppleDescription() { + return appleDescription; + } + + public int getWeight() { + return weight; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesFlow.java new file mode 100644 index 0000000..70c2d41 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesFlow.java @@ -0,0 +1,130 @@ +package com.r3.developers.apples.workflows; + +import com.r3.developers.apples.contracts.AppleCommands; +import com.r3.developers.apples.states.AppleStamp; +import com.r3.developers.apples.states.BasketOfApples; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatingFlow; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.application.messaging.FlowMessaging; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.common.NotaryLookup; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.membership.MemberInfo; +import net.corda.v5.membership.NotaryInfo; +import org.jetbrains.annotations.NotNull; +import java.security.PublicKey; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@InitiatingFlow(protocol = "redeem-apples") +public class RedeemApplesFlow implements ClientStartableFlow { + + @CordaInject + FlowMessaging flowMessaging; + + @CordaInject + JsonMarshallingService jsonMarshallingService; + + @CordaInject + MemberLookup memberLookup; + + @CordaInject + NotaryLookup notaryLookup; + + @CordaInject + UtxoLedgerService utxoLedgerService; + + public RedeemApplesFlow() {} + + @Suspendable + @Override + @NotNull + public String call(@NotNull ClientRequestBody requestBody) { + + RedeemApplesRequest request = requestBody.getRequestBodyAs(jsonMarshallingService, RedeemApplesRequest.class); + MemberX500Name buyerName = request.getBuyer(); + UUID stampId = request.getStampId(); + + // Retrieve the notaries public key (this will change) + final NotaryInfo notaryInfo = notaryLookup.lookup(request.getNotary()); + if (notaryInfo == null) { + throw new IllegalArgumentException("Notary " + request.getNotary() + " not found"); + } + + PublicKey myKey = memberLookup.myInfo().getLedgerKeys().get(0); + + final MemberInfo buyerInfo = memberLookup.lookup(buyerName); + if (buyerInfo == null) { + throw new IllegalArgumentException("The buyer does not exist within the network"); + } + + final PublicKey buyer; + try { + buyer = buyerInfo.getLedgerKeys().get(0); + } catch (Exception e) { + throw new IllegalArgumentException("Buyer " + buyerName + " has no ledger key"); + } + + StateAndRef appleStampStateAndRef; + try { + appleStampStateAndRef = utxoLedgerService + .findUnconsumedStatesByExactType(AppleStamp.class, 100, Instant.now()).getResults() + .stream() + .filter(stateAndRef -> stateAndRef.getState().getContractState().getId().equals(stampId)) + .iterator() + .next(); + } catch (Exception e) { + throw new IllegalArgumentException("There are no eligible basket of apples"); + } + + StateAndRef basketOfApplesStampStateAndRef; + try { + basketOfApplesStampStateAndRef = utxoLedgerService + .findUnconsumedStatesByExactType(BasketOfApples.class, 100, Instant.now()).getResults() + .stream() + .filter( + stateAndRef -> stateAndRef.getState().getContractState().getOwner().equals( + appleStampStateAndRef.getState().getContractState().getIssuer() + ) + ) + .iterator() + .next(); + } catch (Exception e) { + throw new IllegalArgumentException("There are no eligible baskets of apples"); + } + + BasketOfApples originalBasketOfApples = basketOfApplesStampStateAndRef.getState().getContractState(); + + BasketOfApples updatedBasket = originalBasketOfApples.changeOwner(buyer); + + //Create the transaction + UtxoSignedTransaction transaction = utxoLedgerService.createTransactionBuilder() + .setNotary(notaryInfo.getName()) + .addInputStates(appleStampStateAndRef.getRef(), basketOfApplesStampStateAndRef.getRef()) + .addOutputState(updatedBasket) + .addCommand(new AppleCommands.Redeem()) + .setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS)) + .addSignatories(List.of(myKey, buyer)) + .toSignedTransaction(); + + FlowSession session = flowMessaging.initiateFlow(buyerName); + + try { + // Send the transaction and state to the counterparty and let them sign it + // Then notarise and record the transaction in both parties' vaults. + return utxoLedgerService.finalize(transaction, List.of(session)).getTransaction().getId().toString(); + } catch (Exception e) { + return String.format("Flow failed, message: %s", e.getMessage()); + } + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesRequest.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesRequest.java new file mode 100644 index 0000000..4c960d1 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesRequest.java @@ -0,0 +1,34 @@ +package com.r3.developers.apples.workflows; + +import net.corda.v5.base.types.MemberX500Name; + +import java.util.UUID; + +public class RedeemApplesRequest { + + private MemberX500Name buyer; + private MemberX500Name notary; + + private UUID stampId; + + // The JSON Marshalling Service, which handles serialisation, needs this constructor. + public RedeemApplesRequest() {} + + public RedeemApplesRequest(MemberX500Name buyer, MemberX500Name notary, UUID stampId) { + this.buyer = buyer; + this.notary = notary; + this.stampId = stampId; + } + + public MemberX500Name getNotary() { + return notary; + } + + public MemberX500Name getBuyer() { + return buyer; + } + + public UUID getStampId() { + return stampId; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.java new file mode 100644 index 0000000..2039880 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.java @@ -0,0 +1,32 @@ +package com.r3.developers.apples.workflows; + +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatedBy; +import net.corda.v5.application.flows.ResponderFlow; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import org.jetbrains.annotations.NotNull; + +@InitiatedBy(protocol = "redeem-apples") +public class RedeemApplesResponderFlow implements ResponderFlow { + + @CordaInject + UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public void call(@NotNull FlowSession session) { + // Receive, verify, validate, sign and record the transaction sent from the initiator + utxoLedgerService.receiveFinality(session, _transaction -> { + /* + * [receiveFinality] will automatically verify the transaction and its signatures before signing it. + * However, just because a transaction is contractually valid doesn't mean we necessarily want to sign. + * What if we don't want to deal with the counterparty in question, or the value is too high, + * or we're not happy with the transaction's structure? [UtxoTransactionValidator] (the lambda created + * here) allows us to define the additional checks. If any of these conditions are not met, + * we will not sign the transaction - even if the transaction and its signatures are contractually valid. + */ + }); + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ChatStateResults.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ChatStateResults.java new file mode 100644 index 0000000..4bb6213 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ChatStateResults.java @@ -0,0 +1,41 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import java.util.UUID; + +// Class to hold the ListChatFlow results. +// The ChatState(s) cannot be returned directly as the JsonMarshallingService can only serialize simple classes +// that the underlying Jackson serializer recognises, hence creating a DTO style object which consists only of Strings +// and a UUID. It is possible to create custom serializers for the JsonMarshallingService, but this beyond the scope +// of this simple example. +public class ChatStateResults { + + private UUID id; + private String chatName; + private String messageFromName; + private String message; + + public ChatStateResults() {} + + public ChatStateResults(UUID id, String chatName, String messageFromName, String message) { + this.id = id; + this.chatName = chatName; + this.messageFromName = messageFromName; + this.message = message; + } + + public UUID getId() { + return id; + } + + public String getChatName() { + return chatName; + } + + public String getMessageFromName() { + return messageFromName; + } + + public String getMessage() { + return message; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.java new file mode 100644 index 0000000..e76ac52 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.java @@ -0,0 +1,118 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract; +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.application.flows.*; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.common.NotaryLookup; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionBuilder; +import net.corda.v5.membership.MemberInfo; +import net.corda.v5.membership.NotaryInfo; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.UUID; + +import static java.util.Objects.*; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class CreateNewChatFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(CreateNewChatFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public MemberLookup memberLookup; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + @CordaInject + public NotaryLookup notaryLookup; + + // FlowEngine service is required to run SubFlows. + @CordaInject + public FlowEngine flowEngine; + + + @Suspendable + @Override + @NotNull + public String call(@NotNull ClientRequestBody requestBody) { + + log.info("CreateNewChatFlow.call() called"); + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + CreateNewChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, CreateNewChatFlowArgs.class); + + // Get MemberInfos for the Vnode running the flow and the otherMember. + MemberInfo myInfo = memberLookup.myInfo(); + MemberInfo otherMember = requireNonNull( + memberLookup.lookup(MemberX500Name.parse(flowArgs.getOtherMember())), + "MemberLookup can't find otherMember specified in flow arguments." + ); + + // Create the ChatState from the input arguments and member information. + ChatState chatState = new ChatState( + UUID.randomUUID(), + flowArgs.getChatName(), + myInfo.getName(), + flowArgs.getMessage(), + Arrays.asList(myInfo.getLedgerKeys().get(0), otherMember.getLedgerKeys().get(0)) + ); + + // Obtain the Notary name and public key. + NotaryInfo notary = notaryLookup.getNotaryServices().iterator().next(); + + // Use UTXOTransactionBuilder to build up the draft transaction. + UtxoTransactionBuilder txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.getName()) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(new ChatContract.Create()) + .addSignatories(chatState.getParticipants()); + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(); + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(new FinalizeChatSubFlow(signedTransaction, otherMember.getName())); + } + // Catch any exceptions, log them and rethrow the exception. + catch (Exception e) { + log.warn("Failed to process utxo flow for request body " + requestBody + " because: " + e.getMessage()); + throw new CordaRuntimeException(e.getMessage()); + } + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} + */ diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlowArgs.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlowArgs.java new file mode 100644 index 0000000..9c37e74 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlowArgs.java @@ -0,0 +1,30 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +// A class to hold the deserialized arguments required to start the flow. +public class CreateNewChatFlowArgs{ + + // Serialisation service requires a default constructor + public CreateNewChatFlowArgs() {} + + private String chatName; + private String message; + private String otherMember; + + public CreateNewChatFlowArgs(String chatName, String message, String otherMember) { + this.chatName = chatName; + this.message = message; + this.otherMember = otherMember; + } + + public String getChatName() { + return chatName; + } + + public String getMessage() { + return message; + } + + public String getOtherMember() { + return otherMember; + } +} \ No newline at end of file diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatResponderFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatResponderFlow.java new file mode 100644 index 0000000..b68d3b6 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatResponderFlow.java @@ -0,0 +1,75 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatedBy; +import net.corda.v5.application.flows.ResponderFlow; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; + + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +//@InitiatingBy declares the protocol which will be used to link the initiator to the responder. +@InitiatedBy(protocol = "finalize-chat-protocol") +public class FinalizeChatResponderFlow implements ResponderFlow { + private final static Logger log = LoggerFactory.getLogger(FinalizeChatResponderFlow.class); + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public void call(FlowSession session) { + + log.info("FinalizeChatResponderFlow.call() called"); + + try { + // Defines the lambda validator used in receiveFinality below. + UtxoTransactionValidator txValidator = ledgerTransaction -> { + ChatState state = (ChatState) ledgerTransaction.getOutputContractStates().get(0); + // Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions + // to check whether to sign the transaction. + if (checkForBannedWords(state.getMessage()) || !checkMessageFromMatchesCounterparty(state, session.getCounterparty())) { + throw new CordaRuntimeException("Failed verification"); + } + log.info("Verified the transaction - " + ledgerTransaction.getId()); + }; + + // Calls receiveFinality() function which provides the responder to the finalise() function + // in the Initiating Flow. Accepts a lambda validator containing the business logic to decide whether + // responder should sign the Transaction. + UtxoSignedTransaction finalizedSignedTransaction = utxoLedgerService.receiveFinality(session, txValidator).getTransaction(); + log.info("Finished responder flow - " + finalizedSignedTransaction.getId()); + } + // Soft fails the flow and log the exception. + catch(Exception e) + { + log.warn("Exceptionally finished responder flow", e); + } + } + + + @Suspendable + Boolean checkForBannedWords(String str) { + List bannedWords = Arrays.asList("banana", "apple", "pear"); + return bannedWords.stream().anyMatch(str::contains); + } + + @Suspendable + Boolean checkMessageFromMatchesCounterparty(ChatState state, MemberX500Name otherMember) { + return state.getMessageFrom().equals(otherMember); + } + +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.java new file mode 100644 index 0000000..351f999 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.java @@ -0,0 +1,71 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import net.corda.v5.application.flows.*; +import net.corda.v5.application.messaging.FlowMessaging; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +// @InitiatingFlow declares the protocol which will be used to link the initiator to the responder. +@InitiatingFlow(protocol = "finalize-chat-protocol") +public class FinalizeChatSubFlow implements SubFlow { + + private final static Logger log = LoggerFactory.getLogger(FinalizeChatSubFlow.class); + private final UtxoSignedTransaction signedTransaction; + private final MemberX500Name otherMember; + + public FinalizeChatSubFlow(UtxoSignedTransaction signedTransaction, MemberX500Name otherMember) { + this.signedTransaction = signedTransaction; + this.otherMember = otherMember; + } + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + @CordaInject + public FlowMessaging flowMessaging; + + @Override + @Suspendable + public String call() { + + log.info("FinalizeChatFlow.call() called"); + + // Initiates a session with the other Member. + FlowSession session = flowMessaging.initiateFlow(otherMember); + + // Calls the Corda provided finalise() function which gather signatures from the counterparty, + // notarises the transaction and persists the transaction to each party's vault. + // On success returns the id of the transaction created. (This is different to the ChatState id) + String result; + try { + List sessionsList = Arrays.asList(session); + + UtxoSignedTransaction finalizedSignedTransaction = ledgerService.finalize( + signedTransaction, + sessionsList + ).getTransaction(); + + result = finalizedSignedTransaction.getId().toString(); + log.info("Success! Response: " + result); + + } + // Soft fails the flow and returns the error message without throwing a flow exception. + catch (Exception e) { + log.warn("Finality failed", e); + result = "Finality failed, " + e.getMessage(); + } + // Returns the transaction id converted as a string + return result; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.java new file mode 100644 index 0000000..7fe2ebe --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.java @@ -0,0 +1,120 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.crypto.SecureHash; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import static java.util.Objects.*; +import static java.util.stream.Collectors.toList; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class GetChatFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(GetChatFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + @Override + @Suspendable + public String call(ClientRequestBody requestBody) { + + // Obtain the deserialized input arguments to the flow from the requestBody. + GetChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, GetChatFlowArgs.class); + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + List> chatStateAndRefs = ledgerService.findUnconsumedStatesByExactType(ChatState.class, 100, Instant.now()).getResults(); + List> chatStateAndRefsWithId = chatStateAndRefs.stream() + .filter(sar -> sar.getState().getContractState().getId().equals(flowArgs.getId())).collect(toList()); + if (chatStateAndRefsWithId.size() != 1) throw new CordaRuntimeException("Multiple or zero Chat states with id " + flowArgs.getId() + " found"); + StateAndRef chatStateAndRef = chatStateAndRefsWithId.get(0); + + // Calls resolveMessagesFromBackchain() which retrieves the chat history from the backchain. + return jsonMarshallingService.format(resolveMessagesFromBackchain(chatStateAndRef, flowArgs.getNumberOfRecords() )); + } + + // resoveMessageFromBackchain() starts at the stateAndRef provided, which represents the unconsumed head of the + // backchain for this particular chat, then walks the chain backwards for the number of transaction specified in + // the numberOfRecords argument. For each transaction it adds the MessageAndSender representing the + // message and who sent it to a list which is then returned. + @Suspendable + private List resolveMessagesFromBackchain(StateAndRef stateAndRef, int numberOfRecords) { + + // Set up a Mutable List to collect the MessageAndSender(s) + List messages = new LinkedList<>(); + + // Set up initial conditions for walking the backchain. + StateAndRef currentStateAndRef = stateAndRef; + int recordsToFetch = numberOfRecords; + boolean moreBackchain = true; + + // Continue to loop until the start of the backchain or enough records have been retrieved. + while (moreBackchain) { + + // Obtain the transaction id from the current StateAndRef and fetch the transaction from the vault. + SecureHash transactionId = currentStateAndRef.getRef().getTransactionId(); + UtxoLedgerTransaction transaction = requireNonNull( + ledgerService.findLedgerTransaction(transactionId), + "Transaction " + transactionId + " not found." + ); + + // Get the output state from the transaction and use it to create a MessageAndSender Object which + // is appended to the mutable list. + List chatStates = transaction.getOutputStates(ChatState.class); + if (chatStates.size() != 1) throw new CordaRuntimeException( + "Expecting one and only one ChatState output for transaction " + transactionId + "."); + ChatState output = chatStates.get(0); + + messages.add(new MessageAndSender(output.getMessageFrom().toString(), output.getMessage())); + // Decrement the number of records to fetch. + recordsToFetch--; + + // Get the reference to the input states. + List> inputStateAndRefs = transaction.getInputStateAndRefs(); + + // Check if there are no more input states (start of chain) or we have retrieved enough records. + // Check the transaction is not malformed by having too many input states. + // Set the currentStateAndRef to the input StateAndRef, then repeat the loop. + if (inputStateAndRefs.isEmpty() || recordsToFetch == 0) { + moreBackchain = false; + } else if (inputStateAndRefs.size() > 1) { + throw new CordaRuntimeException("More than one input state found for transaction " + transactionId + "."); + } else { + currentStateAndRef = inputStateAndRefs.get(0); + } + } + return messages; + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.GetChatFlow", + "requestBody": { + "id":"** fill in id **", + "numberOfRecords":"4" + } +} + */ + diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlowArgs.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlowArgs.java new file mode 100644 index 0000000..826d7e2 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlowArgs.java @@ -0,0 +1,24 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import java.util.UUID; + +// A class to hold the deserialized arguments required to start the flow. +public class GetChatFlowArgs { + + private UUID id; + private int numberOfRecords; + public GetChatFlowArgs() {} + + public GetChatFlowArgs(UUID id, int numberOfRecords ) { + this.id = id; + this.numberOfRecords = numberOfRecords; + } + + public UUID getId() { + return id; + } + + public int getNumberOfRecords() { + return numberOfRecords; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.java new file mode 100644 index 0000000..c381189 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.java @@ -0,0 +1,68 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.persistence.PagedQuery; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class ListChatsByCustomQueryFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(ListChatsFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + + log.info("ListChatsByCustomQueryFlow.call() called"); + + // Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO. + PagedQuery.ResultSet resultSet = utxoLedgerService.query("GET_MSG_FROM", StateAndRef.class) + .setParameter("nameOfSender", "CN=Alice, OU=Test Dept, O=R3, L=London, C=GB") + .setCreatedTimestampLimit(Instant.now()).setLimit(1000) + .execute(); + + List resultState = resultSet.getResults().stream().map( + it -> (ChatState) it.getState().getContractState()) + .collect(Collectors.toList()); + + List results = resultState.stream().map( it -> + new ChatStateResults( + it.getId(), + it.getChatName(), + it.getMessageFrom().toString(), + it.getMessage() + ) + ).collect(Collectors.toList()); + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results); + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "customlist-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.ListChatsByCustomQueryFlow", + "requestBody": {} +} +*/ \ No newline at end of file diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.java new file mode 100644 index 0000000..d6bbc1f --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.java @@ -0,0 +1,59 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class ListChatsFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(ListChatsFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + + log.info("ListChatsFlow.call() called"); + + // Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO. + List> states = utxoLedgerService.findUnconsumedStatesByExactType(ChatState.class, 100, Instant.now()).getResults(); + List results = states.stream().map( stateAndRef -> + new ChatStateResults( + stateAndRef.getState().getContractState().getId(), + stateAndRef.getState().getContractState().getChatName(), + stateAndRef.getState().getContractState().getMessageFrom().toString(), + stateAndRef.getState().getContractState().getMessage() + ) + ).collect(Collectors.toList()); + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results); + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.ListChatsFlow", + "requestBody": {} +} +*/ \ No newline at end of file diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/MessageAndSender.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/MessageAndSender.java new file mode 100644 index 0000000..00905c8 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/MessageAndSender.java @@ -0,0 +1,22 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +// A class to pair the messageFrom and message together. +public class MessageAndSender { + + private String messageFrom; + private String message; + public MessageAndSender() {} + + public MessageAndSender(String messageFrom, String message) { + this.messageFrom = messageFrom; + this.message = message; + } + + public String getMessageFrom() { + return messageFrom; + } + + public String getMessage() { + return message; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.java new file mode 100644 index 0000000..965c2fc --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.java @@ -0,0 +1,120 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract; +import com.r3.developers.cordapptemplate.customJSON.states.ChatState; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.FlowEngine; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionBuilder; +import net.corda.v5.membership.MemberInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static java.util.Objects.*; +import static java.util.stream.Collectors.toList; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class UpdateChatFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(UpdateChatFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public MemberLookup memberLookup; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + // FlowEngine service is required to run SubFlows. + @CordaInject + public FlowEngine flowEngine; + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + + log.info("UpdateNewChatFlow.call() called"); + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + UpdateChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, UpdateChatFlowArgs.class); + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + List> chatStateAndRefs = ledgerService.findUnconsumedStatesByExactType(ChatState.class, 100, Instant.now()).getResults(); + List> chatStateAndRefsWithId = chatStateAndRefs.stream() + .filter(sar -> sar.getState().getContractState().getId().equals(flowArgs.getId())).collect(toList()); + if (chatStateAndRefsWithId.size() != 1) throw new CordaRuntimeException("Multiple or zero Chat states with id " + flowArgs.getId() + " found"); + StateAndRef chatStateAndRef = chatStateAndRefsWithId.get(0); + + // Get MemberInfos for the Vnode running the flow and the otherMember. + MemberInfo myInfo = memberLookup.myInfo(); + ChatState state = chatStateAndRef.getState().getContractState(); + + List members = state.getParticipants().stream().map( + it -> requireNonNull(memberLookup.lookup(it), "Member not found from public Key "+ it + ".") + ).collect(toList()); + members.remove(myInfo); + if(members.size() != 1) throw new RuntimeException("Should be only one participant other than the initiator"); + MemberInfo otherMember = members.get(0); + + // Create a new ChatState using the updateMessage helper function. + ChatState newChatState = state.updateMessage(myInfo.getName(), flowArgs.getMessage()); + + // Use UTXOTransactionBuilder to build up the draft transaction. + UtxoTransactionBuilder txBuilder = ledgerService.createTransactionBuilder() + .setNotary(chatStateAndRef.getState().getNotaryName()) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(newChatState) + .addInputState(chatStateAndRef.getRef()) + .addCommand(new ChatContract.Update()) + .addSignatories(newChatState.getParticipants()); + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(); + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(new FinalizeChatSubFlow(signedTransaction, otherMember.getName())); + + + } + // Catch any exceptions, log them and rethrow the exception. + catch (Exception e) { + log.warn("Failed to process utxo flow for request body " + requestBody + " because: " + e.getMessage()); + throw e; + } + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "update-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.UpdateChatFlow", + "requestBody": { + "id":" ** fill in id **", + "message": "How are you today?" + } +} + */ \ No newline at end of file diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlowArgs.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlowArgs.java new file mode 100644 index 0000000..a208255 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlowArgs.java @@ -0,0 +1,24 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows; + +import java.util.UUID; + +// A class to hold the deserialized arguments required to start the flow. +public class UpdateChatFlowArgs { + public UpdateChatFlowArgs() {} + + private UUID id; + private String message; + + public UpdateChatFlowArgs(UUID id, String message) { + this.id = id; + this.message = message; + } + + public UUID getId() { + return id; + } + + public String getMessage() { + return message; + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/Message.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/Message.java new file mode 100644 index 0000000..3ddfe54 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/Message.java @@ -0,0 +1,28 @@ +package com.r3.developers.cordapptemplate.flowexample.workflows; + +import net.corda.v5.base.annotations.CordaSerializable; +import net.corda.v5.base.types.MemberX500Name; + +// Where a class contains a message, mark it with @CordaSerializable to enable Corda to +// send it from one virtual node to another. +@CordaSerializable +public class Message { + + private MemberX500Name sender; + private String message; + + public Message(MemberX500Name sender, String message) { + this.sender = sender; + this.message = message; + } + + public MemberX500Name getSender() { + return sender; + } + + public String getMessage() { + return message; + } + + +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.java new file mode 100644 index 0000000..da6f5e1 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.java @@ -0,0 +1,96 @@ +package com.r3.developers.cordapptemplate.flowexample.workflows; + +import net.corda.v5.application.flows.*; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.application.messaging.FlowMessaging; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.types.MemberX500Name; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +// MyFirstFlow is an initiating flow, its corresponding responder flow is called MyFirstFlowResponder (defined below) +// to link the two sides of the flow together they need to have the same protocol. +@InitiatingFlow(protocol = "my-first-flow") +// MyFirstFlow should inherit from ClientStartableFlow, which tells Corda it can be started via an REST call +public class MyFirstFlow implements ClientStartableFlow { + + // Log messages from the flows for debugging. + private final static Logger log = LoggerFactory.getLogger(MyFirstFlow.class); + + // Corda has a set of injectable services which are injected into the flow at runtime. + // Flows declare them with @CordaInjectable, then the flows have access to their services. + + // JsonMarshallingService provides a service for manipulating JSON. + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + // FlowMessaging provides a service that establishes flow sessions between virtual nodes + // that send and receive payloads between them. + @CordaInject + public FlowMessaging flowMessaging; + + // MemberLookup provides a service for looking up information about members of the virtual network which + // this CorDapp operates in. + @CordaInject + public MemberLookup memberLookup; + + public MyFirstFlow() {} + + // When a flow is invoked its call() method is called. + // Call() methods must be marked as @Suspendable, this allows Corda to pause mid-execution to wait + // for a response from the other flows and services. + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + + // Follow what happens in the console or logs. + log.info("MFF: MyFirstFlow.call() called"); + + // Show the requestBody in the logs - this can be used to help establish the format for starting a flow on Corda. + log.info("MFF: requestBody: " + requestBody.getRequestBody()); + + // Deserialize the Json requestBody into the MyfirstFlowStartArgs class using the JsonSerialisation service. + MyFirstFlowStartArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, MyFirstFlowStartArgs.class); + + // Obtain the MemberX500Name of the counterparty. + MemberX500Name otherMember = flowArgs.getOtherMember(); + + // Get our identity from the MemberLookup service. + MemberX500Name ourIdentity = memberLookup.myInfo().getName(); + + // Create the message payload using the MessageClass we defined. + Message message = new Message(otherMember, "Hello from " + ourIdentity + "."); + + // Log the message to be sent. + log.info("MFF: message.message: " + message.getMessage()); + + // Start a flow session with the otherMember using the FlowMessaging service. + // The otherMember's virtual node will run the corresponding MyFirstFlowResponder responder flow. + FlowSession session = flowMessaging.initiateFlow(otherMember); + + // Send the Payload using the send method on the session to the MyFirstFlowResponder responder flow. + session.send(message); + + // Receive a response from the responder flow. + Message response = session.receive(Message.class); + + // The return value of a ClientStartableFlow must always be a String. This will be passed + // back as the REST response when the status of the flow is queried on Corda. + return response.getMessage(); + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "r1", + "flowClassName": "com.r3.developers.cordapptemplate.flowexample.workflows.MyFirstFlow", + "requestBody": { + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB" + } +} + */ diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowResponder.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowResponder.java new file mode 100644 index 0000000..8ed9947 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowResponder.java @@ -0,0 +1,62 @@ +package com.r3.developers.cordapptemplate.flowexample.workflows; + +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatedBy; +import net.corda.v5.application.flows.ResponderFlow; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.types.MemberX500Name; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +// MyFirstFlowResponder is a responder flow, its corresponding initiating flow is called MyFirstFlow (defined in MyFirstFlow.java) +// to link the two sides of the flow together they need to have the same protocol. +@InitiatedBy(protocol = "my-first-flow") +// Responder flows must inherit from ResponderFlow +public class MyFirstFlowResponder implements ResponderFlow { + + // Log messages from the flows for debugging. + private final static Logger log = LoggerFactory.getLogger(MyFirstFlowResponder.class); + + // MemberLookup looks for information about members of the virtual network which + // this CorDapp operates in. + @CordaInject + public MemberLookup memberLookup; + + public MyFirstFlowResponder() {} + + // Responder flows are invoked when an initiating flow makes a call via a session set up with the virtual + // node hosting the responder flow. When a responder flow is invoked its call() method is called. + // call() methods must be marked as @Suspendable, this allows Corda to pause mid-execution to wait + // for a response from the other flows and services. + // The call() method has the flow session passed in as a parameter by Corda so the session is available to + // responder flow code, you don't need to inject the FlowMessaging service. + @Suspendable + @Override + public void call(FlowSession session) { + + // Follow what happens in the console or logs. + log.info("MFF: MyFirstResponderFlow.call() called"); + + // Receive the payload and deserialize it into a message class. + Message receivedMessage = session.receive(Message.class); + + // Log the message as a proxy for performing some useful operation on it. + log.info("MFF: Message received from " + receivedMessage.getSender() + ":" + receivedMessage.getMessage()); + + // Get our identity from the MemberLookup service. + MemberX500Name ourIdentity = memberLookup.myInfo().getName(); + + // Create a message to greet the sender. + Message response = new Message(ourIdentity, + "Hello " + session.getCounterparty().getCommonName() + ", best wishes from " + ourIdentity.getCommonName()); + + // Log the response to be sent. + log.info("MFF: response.message: " + response.getMessage()); + + // Send the response via the send method on the flow session + session.send(response); + } +} diff --git a/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowStartArgs.java b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowStartArgs.java new file mode 100644 index 0000000..e6e9be8 --- /dev/null +++ b/java-samples/customJSON/workflows/src/main/java/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlowStartArgs.java @@ -0,0 +1,19 @@ +package com.r3.developers.cordapptemplate.flowexample.workflows; + +import net.corda.v5.base.types.MemberX500Name; + +// A class to hold the arguments required to start the flow +public class MyFirstFlowStartArgs { + private MemberX500Name otherMember; + + // The JSON Marshalling Service, which handles serialisation, needs this constructor. + public MyFirstFlowStartArgs() {} + + public MyFirstFlowStartArgs(MemberX500Name otherMember) { + this.otherMember = otherMember; + } + + public MemberX500Name getOtherMember() { + return otherMember; + } +} diff --git a/kotlin-samples/customJSON/.ci/Jenkinsfile b/kotlin-samples/customJSON/.ci/Jenkinsfile new file mode 100644 index 0000000..560ec37 --- /dev/null +++ b/kotlin-samples/customJSON/.ci/Jenkinsfile @@ -0,0 +1,11 @@ +@Library('corda-shared-build-pipeline-steps@5.2') _ + +cordaPipeline( + publishRepoPrefix: '', + slimBuild: true, + runUnitTests: false, + dedicatedJobForSnykDelta: false, + slackChannel: '#corda-corda5-dev-ex-build-notifications', + gitHubComments: false, + javaVersion: '17' + ) diff --git a/kotlin-samples/customJSON/.ci/nightly/JenkinsfileSnykScan b/kotlin-samples/customJSON/.ci/nightly/JenkinsfileSnykScan new file mode 100644 index 0000000..c2ae031 --- /dev/null +++ b/kotlin-samples/customJSON/.ci/nightly/JenkinsfileSnykScan @@ -0,0 +1,6 @@ +@Library('corda-shared-build-pipeline-steps@5.1') _ + +cordaSnykScanPipeline ( + snykTokenId: 'r3-snyk-corda5', + snykAdditionalCommands: "--all-sub-projects -d" +) diff --git a/kotlin-samples/customJSON/.gitignore b/kotlin-samples/customJSON/.gitignore new file mode 100644 index 0000000..d2879c4 --- /dev/null +++ b/kotlin-samples/customJSON/.gitignore @@ -0,0 +1,86 @@ + +# Eclipse, ctags, Mac metadata, log files +.classpath +.project +.settings +tags +.DS_Store +*.log +*.orig + +# Created by .ignore support plugin (hsz.mobi) + +.gradle +local.properties +.gradletasknamecache + +# General build files +**/build/* + +lib/quasar.jar + +**/logs/* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/*.xml +.idea/.name +.idea/copyright +.idea/inspectionProfiles +.idea/libraries +.idea/shelf +.idea/dataSources +.idea/markdown-navigator +.idea/runConfigurations +.idea/dictionaries + + +# Include the -parameters compiler option by default in IntelliJ required for serialization. +!.idea/codeStyleSettings.xml + + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +**/out/ +/classes/ + + + +# vim +*.swp +*.swn +*.swo + + + +# Directory generated during Resolve and TestOSGi gradle tasks +bnd/ + +# Ignore Gradle build output directory +build +/.idea/codeStyles/codeStyleConfig.xml +/.idea/codeStyles/Project.xml + + + +# Ignore Visual studio directory +bin/ + + + +*.cpi +*.cpb +*.cpk +workspace/** + +# ingore temporary data files +*.dat \ No newline at end of file diff --git a/kotlin-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml b/kotlin-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml new file mode 100644 index 0000000..1d8da82 --- /dev/null +++ b/kotlin-samples/customJSON/.run/runConfigurations/DebugCorDapp.run.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/kotlin-samples/customJSON/.snyk b/kotlin-samples/customJSON/.snyk new file mode 100644 index 0000000..c6be1c6 --- /dev/null +++ b/kotlin-samples/customJSON/.snyk @@ -0,0 +1,14 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.25.0 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JAVA-ORGJETBRAINSKOTLIN-2393744: + - '*': + reason: >- + This vulnerability relates to information exposure via creation of + temporary files (via Kotlin functions) with insecure permissions. + Corda does not use any of the vulnerable functions so it is not + susceptible to this vulnerability + expires: 2023-10-19T17:08:41.029Z + created: 2023-02-02T17:08:41.032Z +patch: {} diff --git a/kotlin-samples/customJSON/FlowManagementUI/Dockerfile b/kotlin-samples/customJSON/FlowManagementUI/Dockerfile new file mode 100644 index 0000000..016bc21 --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/Dockerfile @@ -0,0 +1,5 @@ +FROM python +WORKDIR /app +COPY . /app +RUN pip install -r requirements.txt +CMD ["python3", "app.py"] \ No newline at end of file diff --git a/kotlin-samples/customJSON/FlowManagementUI/README.md b/kotlin-samples/customJSON/FlowManagementUI/README.md new file mode 100644 index 0000000..fffff29 --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/README.md @@ -0,0 +1,84 @@ +# Corda 5 CorDapp Flow Management Tool + + +This user guide provides step-by-step instructions on using the Corda 5 flow management tool. This article will help you learn how to connect the running corDapp, make flow calls, configure flow queries, and retrieve results. + +## Prerequisites +* Install and run Python and Flask framework. link. + +* Prepare your local Corda 5 environment. (By default, the Flow Management Tool is looking to connect to https://localhost:8888/api/v5_2/swagger#/ with Login: Admin and Password: Admin.) + +* Clong the Flow Management Tool repository. FlowManagementUI: main + +## Set Up + +1. Assuming your local Corda 5 environment is populated and the swagger endpoint is at: https://localhost:8888/api/v5_2/swagger#/ + +2. Navigate to where you downloaded the Corda 5 Flow Management Tool + +3. To run the framework + * Navigate to the file name using cd command. + * use the python app.py command to run it. + ![image](https://github.com/parisyup/FlowManagementUI/assets/51169685/f0c3bf59-8180-48a0-91cc-80f2d260e530) + + * Later on, click on the IP Address which will open the Interface: + +![image(4)](https://github.com/parisyup/FlowManagementUI/assets/66366646/8d88e37c-edbb-4d6d-8bcd-d773e818a106) + + +The Flow Management Tool should be automatically connected with the CorDapp running locally from your CSDE. You can test the connection by click on the dropdown list at the Flow Initiator section. You should be able to see the vNodes of your started CorDapp from CSDE. + +![image](https://github.com/parisyup/FlowManagementUI/assets/51169685/5a2356f2-cd14-489c-abd0-4afe0bf0d251) + +## Set Up With Docker + +1- Open up Command Prompt + +2- Navigate to the application folder using the CD commands + +3- Ensure that Docker application is open and build the image using the following command: +`docker build -t your-image-name .` + +Make sure to include the dot at the end of the command + +the `your-image-name` at the end of the command can be whatever you like but make sure to use the same name in the next step + +4- Run the docker image using the following command: +`docker run --rm -it --expose 8888 -p 5000:5000 your-image-name` + +5- You can access the website by using https://localhost:5000 or https://127.0.0.1:5000 + +## Using the Flow Management Tool + +### Selecting the Flow Initiator + +As the first step of using the Flow Management Tool, you would need to select the Flow Initiator. The Flow Initiator indicates which vNode will be triggering the flow. If you wish to have Alice to run a transaction to Bob, select the X500Name of Alice. The selected vNode’s shortHash (Corda 5 Network participant identifier) will also be shown below the dropdown list to signify your selection. + +### Function 1: To Make a Flow Call + +1. Click on "Flow Call" tab in the application. +2. Paste the your JSON format request body into the request input box. +3. Click Post button to trigger the call. + +![image(5)](https://github.com/parisyup/FlowManagementUI/assets/66366646/c65195a6-0a70-4354-804e-37884f657746) + + +### Function 2: To Configure Flow Query + +1. Click on the “Flow Query” tab. +2. Choose whether to query a single flow or all flows at the selected Flow Initiator. +3. If you choose to query all of the flows, select “Query All Flows“ then click “Get“. + +![image](https://github.com/parisyup/FlowManagementUI/assets/51169685/0482cfa4-7ee1-42f2-8786-2d8ad80b2936) +4. If you choose to query a single flow, select “Query Single Flow“, please add the ClientID in specified filed. +5. Click on “Get” to retrieve the result. + +![image(6)](https://github.com/parisyup/FlowManagementUI/assets/66366646/13e979b0-f76e-4f2c-9d55-81be8880890b) + +If you have any suggestions or questions, feel free to give us your feedback through Github for a better experience in the future! + +## Conclusion +In summary, our project introduces a specialized flow management layer on top of Swagger for Corda developers. We understand the challenges developers face in testing Corda applications due to the complexity of commands, our solution focuses on simplifying the process. + +Our all-in-one flow management system provides developers with a unified platform, streamlining development and enhancing efficiency. A key feature allows developers to run flows directly from an externally developed website and monitor their status in real-time, offering a user-friendly and practical solution for Corda developers. Overall, our project aims to make Corda development more accessible and tailored to the specific needs of flow management. + diff --git a/kotlin-samples/customJSON/FlowManagementUI/app.py b/kotlin-samples/customJSON/FlowManagementUI/app.py new file mode 100644 index 0000000..5d3e3f6 --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/app.py @@ -0,0 +1,12 @@ +from flask import Flask +from flask import render_template +app = Flask(__name__) + + +@app.route('/') +def home(): # put application's code here + return render_template("index.html") + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0') + diff --git a/kotlin-samples/customJSON/FlowManagementUI/requirements.txt b/kotlin-samples/customJSON/FlowManagementUI/requirements.txt new file mode 100644 index 0000000..8ab6294 --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/requirements.txt @@ -0,0 +1 @@ +flask \ No newline at end of file diff --git a/kotlin-samples/customJSON/FlowManagementUI/static/Scripts/script.js b/kotlin-samples/customJSON/FlowManagementUI/static/Scripts/script.js new file mode 100644 index 0000000..8fd4ec0 --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/static/Scripts/script.js @@ -0,0 +1,322 @@ +// This script contains functions for making API requests, handling data, and updating the UI. + +// Variable to store the selected X500Name from the dropdown +let selectedX500Name; + +// Variable to indicate whether data is currently being loaded from the pull request +let loading = false; + +// Function to make a GET request to an external API and return the data +function getData() { + // Replace the URL with the actual API endpoint + return fetch('https://jsonplaceholder.typicode.com/todos/1') + .then(response => response.json()) + .then(data => { + console.log('API Result:', data); + + // Return specific data fields + return { + id: data.id, + title: data.title + }; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to get the data and display it on the page +function getDataAndDisplay() { + getData() + .then(result => { + // Update the result div with the data + document.getElementById('result').innerHTML = ` +

ID: ${result.id}

+

Title: ${result.title}

+ `; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to make a GET request to retrieve CPI data +function getCPI() { + const url = 'https://localhost:8888/api/v1/cpi'; + // Perform the GET request with authorization headers + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + console.log('API Result:', data); + // Further processing of the data can be done here + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to get all virtual nodes and populate a dropdown with the data +function getAllVirtualNodes() { + const url = 'https://localhost:8888/api/v1/virtualnode'; + + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + // Extract virtualNodes from the API response + const virtualNodes = data.virtualNodes; + console.log('API Result:', virtualNodes); + + // Process the data and populate the dropdown + if (Array.isArray(virtualNodes)) { + const dropdown = document.getElementById('itemDropdown'); + dropdown.innerHTML = ''; + dropdown.innerHTML += ''; + + virtualNodes.forEach(item => { + // Display each item on the console + console.log('Item X500Name:', item.holdingIdentity.x500Name); + console.log('Item ShortHash:', item.holdingIdentity.shortHash); + + // Create an option element and add it to the dropdown + const option = document.createElement('option'); + option.value = item.holdingIdentity.shortHash; + option.text = item.holdingIdentity.x500Name; + dropdown.appendChild(option); + }); + + // Add event listener to the dropdown to detect changes + dropdown.addEventListener('change', function () { + selectedX500Name = this.value; + console.log('Selected ShortHash:', selectedX500Name); + + // Call a function or update a variable based on the selected item + handleDropdownChange(selectedX500Name); + }); + + } else { + console.warn('API Result is not an array.'); + } + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Initialize the virtual nodes dropdown on page load +getAllVirtualNodes(); + +// function to handle dropdown change +function handleDropdownChange(selectedX500Name) { + console.log('Handling dropdown change for Item ID:', selectedX500Name); + getSelectedVNode(); + // Additional actions based on the selected item can be performed here +} + +// Function to get the selected virtual node and display it +function getSelectedVNode() { + const displayElement = document.getElementById('selectedX500Display'); + displayElement.textContent = `Selected X500: ${selectedX500Name}`; +} + +// Function to get all flow results based on the selected virtual node +function getAllFlowResult() { + const url = `https://localhost:8888/api/v1/flow/${selectedX500Name}`; + + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + const flowStatusResponses = data.flowStatusResponses; + console.log('API Result:', flowStatusResponses); + + // Convert the JSON object to a string for display + const jsonString = JSON.stringify(flowStatusResponses, null, 2); + + // Display the JSON string in the result div + document.getElementById('idResutl').innerHTML = `
${jsonString}
`; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to make a POST request with a request body +async function postCallFlow() { + let postBtn = $('#postBtn'); + const url = `https://localhost:8888/api/v1/flow/${selectedX500Name}`; + + // Change the button text to indicate loading + postBtn.html('Loading...'); + + try { + // Perform the POST request with the provided request body + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=', + 'Content-Type': 'application/json' + }, + body: `${document.getElementById('requestBody').value}` + }); + + // Parse the response as JSON + const data = await response.json(); + + if (!data.ok) { + console.log(data.status); + + // Check if the response status is not OK (2xx range) + if (data.status == "409" || data.status == "400") { + let flowStatusResponse = "title: " + data.title + "\nStatus: " + data.status; + + // Display additional details for 400 status + if (data.status == "400") { + flowStatusResponse += "\nDetails: \n Cause: " + data.details.cause + "\n Reason: " + data.details.reason; + } + + document.getElementById('queryResult').innerHTML = `
${flowStatusResponse}
`; + return; + } + } + + let msg = "null"; + let typ = "null"; + + // Construct a string with the flow status responses + let flowStatusResponses = "client request ID: " + data.clientRequestId + + "\nFlow Result " + data.flowResult + + "\nFlow Error Message: " + msg + + "\nflow error type: " + typ + + "\nFlow ID: " + data.flowId + + "\nFlow status: " + data.flowStatus + + "\nHolding identity short hash: " + data.holdingIdentityShortHash + + "\nTime stamp: " + data.timestamp; + + console.log('API Result:', flowStatusResponses); + + // Display the flow status responses in the result div + document.getElementById('queryResult').innerHTML = `
${flowStatusResponses}
`; + } catch (error) { + console.log('Error:', error); + } finally { + // Restore the button text after the operation is complete + postBtn.html('Post'); + } +} + +// Function to display an item on the page +function displayItemOnPage(item) { + const resultDiv = document.getElementById('queryResult'); + + // Create a new element to display the item + const itemElement = document.createElement('div'); + itemElement.innerHTML = ` +

QueryResult: ${item}

+ `; + + // Append the new element to the result div + resultDiv.appendChild(itemElement); +} + +// Function to open a specific tab by hiding/showing content +function openTab(tabName) { + // Hide all tab content + var tabContents = document.getElementsByClassName("tab-content"); + for (var i = 0; i < tabContents.length; i++) { + tabContents[i].style.display = "none"; + document.getElementById(tabContents[i].id + "-tab").style.backgroundColor = "rgb(52,152,219)"; + } + + // Show the selected tab content + var selectedTab = document.getElementById(tabName); + if (selectedTab) { + selectedTab.style.display = "block"; + document.getElementById(tabName + "-tab").style.backgroundColor = "rgb(173,216,230)"; + } +} + +// Function to display a flow for a specific virtual node +function oneFlow() { + const url = `https://localhost:8888/api/v1/flow/${selectedX500Name}/${document.getElementById('clientID').value}`; + + // Validate clientID input + if (document.getElementById('clientID').value == "") { + alert("Please input a clientId"); + return; + } + + // Perform a GET request to display a flow + return fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic YWRtaW46YWRtaW4=' + } + }) + .then(response => response.json()) + .then(data => { + let msg = "null"; + let typ = "null"; + + // Check if the flow status is "FAILED" and extract error details + if (data.flowStatus == "FAILED") { + msg = data.flowError.message; + typ = data.flowError.type; + } + + // Construct a string with the flow status responses + const flowStatusResponses = "client request ID: " + data.clientRequestId + + "\nFlow Result " + data.flowResult + + "\nFlow Error Message: " + msg + + "\nflow error type: " + typ + + "\nFlow ID: " + data.flowId + + "\nFlow status: " + data.flowStatus + + "\nHolding identity short hash: " + data.holdingIdentityShortHash + + "\nTime stamp: " + data.timestamp; + + console.log('API Result:', flowStatusResponses); + + // Display the flow status responses in the result div + document.getElementById('idResutl').innerHTML = `
${flowStatusResponses}
`; + }) + .catch(error => { + console.error('Error:', error); + }); +} + +// Function to determine which flow-related action to execute based on user input +function executeButtonFlow() { + // Check the value of the dropdown to determine which action to perform + if (document.getElementById("dropdown").value == "option1") { + getAllFlowResult(); + } else { + oneFlow(); + } +} + +function queryDropDownChange(){ + if (document.getElementById("dropdown").value == "option1") { + document.getElementById("clientID").style.display = 'none'; + }else{ + document.getElementById("clientID").style.display = 'block'; + + } +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/FlowManagementUI/static/css/main.css b/kotlin-samples/customJSON/FlowManagementUI/static/css/main.css new file mode 100644 index 0000000..5e4d849 --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/static/css/main.css @@ -0,0 +1,202 @@ +body { + font-family: 'Roboto', sans-serif; + background-color: #f4f4f4; + color: #333; + margin: 50px; /* Add margin to the entire body */ + padding: 0; +} + +h1 { + text-align: center; + color: #0e0c0c; +} + +/* Style for the label */ +label { + display: block; + margin-bottom: 10px; + font-weight: bold; + color: #333; +} +/* Style for the dropdown button */ +select { + width: 20%; + padding: 8px; + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 14px; + color: #555; +} + +#itemDropdown { + width: 40%; + padding: 10px; + box-sizing: border-box; +} + +#clientID{ + padding: 8px; + margin-bottom: 15px; + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 14px; + color: #555; +} + +#OneFlowButon{ + padding: 8px; + margin-bottom: 15px; + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-size: 14px; +} + +#result { + margin-bottom: 10px; +} + +/* Button styling */ +button { + background-color: #3498db; + color: #fff; + padding: 10px 25px; + font-size: 16px; + border: 2px; + border-radius: 15px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +button:hover { + background-color: #2980b9; +} + +/* Tab styling */ +.tab { + list-style-type: none; /* Remove default list styles */ + display: inline-block; /* Display tabs inline */ + padding: 2px 00px; /* Add padding to the tabs */ + margin: 0 1px; /* Add margin between tabs */ + cursor: pointer; /* Change cursor to pointer on hover */ +} + + +.tab li { + flex: 1; + text-align: center; + padding: 10px; + background-color: #3498db; + color: #fff; + border-radius: 8px 15px 0 0; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.tab li:hover { + background-color: #2980b9; +} + +/* Tab content styling */ +.tab-content { + display: none; + padding: 20px; + border: 1px solid #3498db; + border-radius: 0 0 5px 5px; + background-color: #fff; +} + +.styled-input { + width: 100%; + padding: 10px; + margin-bottom: 10px; + box-sizing: border-box; +} + +.output { + border: 1px solid #3498db; + padding: 10px; + border-radius: 5px; + background-color: #fff; + box-sizing: border-box; +} + +#idResutl { + border: 1px solid #3498db; + padding: 10px; + border-radius: 5px; + background-color: #fff; + margin-top: 10px; + height: 290px; + max-height: 290px; /* Set a maximum height for the scroll box */ + overflow-y: auto; /* Enable vertical scrolling if content exceeds the box height */ +} + +/* Responsive design */ +@media screen and (max-width: 600px) { + .tab li { + border-radius: 5px; + margin-bottom: 5px; + } + .tab-content { + border-radius: 5px; + } +} +#call { + display: -ms-inline-flexbox; + flex-wrap: wrap; + +} + +/* Style for side-by-side input boxes */ +.flowcall-container { + display: flex; +} + +.input-box, .text-box { + width: 150px; /* Set the desired width */ + margin-right: 10px; /* Optional: Add margin for spacing between input boxes */ + border-radius: 5px; + border: 1px solid #3498db; +} + +.queryoption-container{ + display: flex; +} + + + +#requestBody { + flex: 1; + box-sizing: border-box; + width: 100px; /* Set the desired width */ + height: 300px; /* Set the desired height */ + padding: 10px; /* Optional: Add padding for better aesthetics */ + margin-right: 10px; /* Add some margin between the input and button */ +} + +#postBtn { + flex: 0 0 auto; /* Don't allow the button to grow or shrink */ + margin-top: 10px; /* Add some margin between the button and result box */ + margin-right: 10px; /* Add some margin between the button and result box */ + width: 500px; /* Set the desired width */ + height: 50px; /* Set the desired height */ +} + +#queryResult { + flex: 1; + box-sizing: border-box; + width: 100px; /* Set the desired width */ + height: 300px; /* Set the desired height */ + padding: 10px; /* Optional: Add padding for better aesthetics */ +} +.content-box{ + padding: 10px; /* Add padding to the content boxes */ +} + + diff --git a/kotlin-samples/customJSON/FlowManagementUI/templates/index.html b/kotlin-samples/customJSON/FlowManagementUI/templates/index.html new file mode 100644 index 0000000..44df7ac --- /dev/null +++ b/kotlin-samples/customJSON/FlowManagementUI/templates/index.html @@ -0,0 +1,87 @@ + + + + + + + + + + Flask Frontend Example + + + + + + + + + + + + +

Flow Management APIs

+
+ + + + + + +

Please select a flow initiator

+ + +
    + +
  • Flow Call
  • +
  • Flow Query
  • + + +
    +
+ + +
+ +
+ + + + +
+
+ + +
Result will be displayed here
+
+ +
+
+ + +
+ + + +
+ + + + + + + + +
+ + + + + +
Result will be displayed here
+
+ + diff --git a/kotlin-samples/customJSON/README.md b/kotlin-samples/customJSON/README.md new file mode 100644 index 0000000..e6f2b46 --- /dev/null +++ b/kotlin-samples/customJSON/README.md @@ -0,0 +1,122 @@ +# cordapp-template-kotlin (Corda v5.2) + +## This template repository provides: + +- A pre-setup Cordapp Project which you can use as a starting point to develop your own prototypes. + +- A base Gradle configuration which brings in the dependencies you need to write and test a Corda 5 Cordapp. + +- A set of Gradle helper tasks, provided by the [Corda runtime gradle plugin](https://github.com/corda/corda-runtime-os/tree/release/os/5.2/tools/corda-runtime-gradle-plugin#readme), which speed up and simplify the development and deployment process. + +- Debug configuration for debugging a local Corda cluster. + +- The MyFirstFlow code which forms the basis of this getting started documentation, this is located in package com.r3.developers.cordapptemplate.flowexample + +- A UTXO example in package com.r3.developers.cordapptemplate.customJSON packages + +- Ability to configure the Members of the Local Corda Network. + +To find out how to use the template, please refer to the *CorDapp Template* subsection within the *Developing Applications* section in the latest Corda 5 documentation at https://docs.r3.com/ + +## Prerequisite +1. Java 17 +2. Corda-cli (v5.2), Download [here](https://github.com/corda/corda-runtime-os/releases/tag/release-5.2.0.0). You need to install Java 17 first. +3. Docker Desktop + +## Setting up + +1. We will begin our test deployment with clicking the `startCorda`. This task will load up the combined Corda workers in docker. + A successful deployment will allow you to open the REST APIs at: https://localhost:8888/api/v5_2/swagger#. You can test out some of the + functions to check connectivity. (GET /cpi function call should return an empty list as for now.) +2. We will now deploy the cordapp with a click of `vNodeSetup` task. Upon successful deployment of the CPI, the GET /cpi function call should now return the meta data of the cpi you just upload + +## Flow Management Tool[Optional] +We had developed a simple GUI for you to interact with the cordapp. You can access the website by using https://localhost:5000 or https://127.0.0.1:5000. The Flow Management Tool will automatically connect with the CorDapp running locally from your Corda cluster. You can test the connection by click on the dropdown list at the Flow Initiator section. You should be able to see the vNodes of your started CorDapp. You can easily trigger and query a Corda flow. + + + +![image](https://github.com/corda/cordapp-template-kotlin/assets/51169685/88e6568e-49b4-46a8-b1e1-34140bcf03a9) + + +## Running the Chat app +We have built a simple one to one chat app to demo some functionalities of the next gen Corda platform. + +In this app you can: +1. Create a new chat with a counterparty. `CreateNewChatFlow` +2. List out the chat entries you had. `ListChatsFlow` +3. Individually query out the history of one chat entry. `GetChatFlowArgs` +4. Continue chatting within the chat entry with the counterparty. `UpdateChatFlow` + + + + +### Running the chat app + +In Corda 5, flows will be triggered via `POST /flow/{holdingidentityshorthash}` and flow result will need to be view at `GET /flow/{holdingidentityshorthash}/{clientrequestid}` +* holdingidentityshorthash: the id of the network participants, ie Bob, Alice, Charlie. You can view all the short hashes of the network member with another gradle task called `listVNodes` +* clientrequestid: the id you specify in the flow requestBody when you trigger a flow. + +#### Step 1: Create Chat Entry +Pick a VNode identity to initiate the chat, and get its short hash. (Let's pick Alice. Dont pick Bob because Bob is the person who we will have the chat with). + +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} +``` + +After trigger the create-chat flow, hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the short hash(Alice's hash) and clientrequestid to view the flow result + +#### Step 2: List the chat +In order to continue the chat, we would need the chat ID. This step will bring out all the chat entries this entity (Alice) has. +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.ListChatsFlow", + "requestBody": {} +} +``` +After trigger the list-chats flow, again, we need to hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and check the result. As the screenshot shows, in the response body, +we will see a list of chat entries, but it currently only has one entry. And we can see the id of the chat entry. Let's record that id. + + +#### Step 3: Continue the chat with `UpdateChatFlow` +In this step, we will continue the chat between Alice and Bob. +Goto `POST /flow/{holdingidentityshorthash}`, enter the identity short hash and request body. Note that here we can have either Alice or Bob's short hash. If you enter Alice's hash, +this message will be recorded as a message from Alice, vice versa. And the id field is the chat entry id we got from the previous step. +``` +{ + "clientRequestId": "update-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.UpdateChatFlow", + "requestBody": { + "id":" ** fill in id **", + "message": "How are you today?" + } +} +``` +And as for the result of this flow, go to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the required fields. + +#### Step 4: See the whole chat history of one chat entry +After a few back and forth of the messaging, you can view entire chat history by calling GetChatFlow. + +``` +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.cordapptemplate.customJSON.workflows.GetChatFlow", + "requestBody": { + "id":" ** fill in id **", + "numberOfRecords":"4" + } +} +``` +And as for the result, you need to go to the Get API again and enter the short hash and client request ID. + +Thus, we have concluded a full run through of the chat app. diff --git a/kotlin-samples/customJSON/build.gradle b/kotlin-samples/customJSON/build.gradle new file mode 100644 index 0000000..e9af621 --- /dev/null +++ b/kotlin-samples/customJSON/build.gradle @@ -0,0 +1,88 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +import static org.gradle.api.JavaVersion.VERSION_17 + +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'net.corda.cordapp.cordapp-configuration' + id 'org.jetbrains.kotlin.plugin.jpa' + id 'java' + id 'maven-publish' + id 'net.corda.gradle.plugin' +} + +allprojects { + group 'com.r3.developers.cordapptemplate' + version '1.0-SNAPSHOT' + + def javaVersion = VERSION_17 + + // Configure Corda runtime gradle plugin + cordaRuntimeGradlePlugin { + notaryVersion = cordaNotaryPluginsVersion + notaryCpiName = "NotaryServer" + corDappCpiName = "MyCorDapp" + cpiUploadTimeout = "30000" + vnodeRegistrationTimeout = "60000" + cordaProcessorTimeout = "300000" + workflowsModuleName = "workflows" + cordaClusterURL = "https://localhost:8888" + cordaRestUser = "admin" + cordaRestPasswd ="admin" + composeFilePath = "config/combined-worker-compose.yaml" + networkConfigFile = "config/static-network-config.json" + r3RootCertFile = "config/r3-ca-key.pem" + skipTestsDuringBuildCpis = "false" + cordaRuntimePluginWorkspaceDir = "workspace" + cordaBinDir = "${System.getProperty("user.home")}/.corda/corda5" + cordaCliBinDir = "${System.getProperty("user.home")}/.corda/cli" + } + + // Declare the set of Kotlin compiler options we need to build a CorDapp. + tasks.withType(KotlinCompile).configureEach { + kotlinOptions { + allWarningsAsErrors = false + + // Specify the version of Kotlin that we are that we will be developing. + languageVersion = '1.7' + // Specify the Kotlin libraries that code is compatible with + apiVersion = '1.7' + // Note that we Need to use a version of Kotlin that will be compatible with the Corda API. + // Currently that is developed in Kotlin 1.7 so picking the same version ensures compatibility with that. + + // Specify the version of Java to target. + jvmTarget = javaVersion + + // Needed for reflection to work correctly. + javaParameters = true + + // -Xjvm-default determines how Kotlin supports default methods. + // JetBrains currently recommends developers use -Xjvm-default=all + // https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/-jvm-default/ + freeCompilerArgs += [ + "-Xjvm-default=all" + ] + } + } + + repositories { + // All dependencies are held in Maven Central + mavenLocal() + mavenCentral() + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } + +} + +publishing { + publications { + maven(MavenPublication) { + artifactId "corda-dev-template-kotlin-sample" + groupId project.group + artifact jar + } + } +} diff --git a/kotlin-samples/customJSON/config/combined-worker-compose.yaml b/kotlin-samples/customJSON/config/combined-worker-compose.yaml new file mode 100644 index 0000000..da2495d --- /dev/null +++ b/kotlin-samples/customJSON/config/combined-worker-compose.yaml @@ -0,0 +1,87 @@ +version: '2' +services: + postgresql: + image: postgres:14.10 + restart: unless-stopped + tty: true + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=cordacluster + ports: + - 5432:5432 + + kafka: + image: confluentinc/cp-kafka:7.6.0 + ports: + - 9092:9092 + environment: + KAFKA_NODE_ID: 1 + CLUSTER_ID: ZDFiZmU3ODUyMzRiNGI3NG + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,DOCKER_INTERNAL://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,DOCKER_INTERNAL://kafka:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,DOCKER_INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER_INTERNAL + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_DEFAULT_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + + kafka-create-topics: + image: openjdk:17-jdk + depends_on: + - kafka + volumes: + - ${CORDA_CLI:-~/.corda/cli}:/opt/corda-cli + working_dir: /opt/corda-cli + command: [ + "java", + "-jar", + "corda-cli.jar", + "topic", + "-b=kafka:29092", + "create", + "connect" + ] + + corda: + image: corda/corda-os-combined-worker-kafka:5.2.0.0 + depends_on: + - postgresql + - kafka + - kafka-create-topics + volumes: + - ../config:/config + - ../logs:/logs + environment: + JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + LOG4J_CONFIG_FILE: config/log4j2.xml + CONSOLE_LOG_LEVEL: info + ENABLE_LOG4J2_DEBUG: false + command: [ + "-mbus.busType=KAFKA", + "-mbootstrap.servers=kafka:29092", + "-spassphrase=password", + "-ssalt=salt", + "-ddatabase.user=user", + "-ddatabase.pass=password", + "-ddatabase.jdbc.url=jdbc:postgresql://postgresql:5432/cordacluster", + "-ddatabase.jdbc.directory=/opt/jdbc-driver/" + ] + ports: + - 8888:8888 + - 7004:7004 + - 5005:5005 + + flow-management-tool: + depends_on: + - corda + build: + context: ../FlowManagementUI + dockerfile: Dockerfile + ports: + - 5000:5000 \ No newline at end of file diff --git a/kotlin-samples/customJSON/config/gradle-plugin-default-key.pem b/kotlin-samples/customJSON/config/gradle-plugin-default-key.pem new file mode 100644 index 0000000..5294bbd --- /dev/null +++ b/kotlin-samples/customJSON/config/gradle-plugin-default-key.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC +MQ8wDQYDVQQHDAZMb25kb24xDjAMBgNVBAoMBUNvcmRhMQswCQYDVQQLDAJSMzEe +MBwGA1UEAwwVQ29yZGEgRGV2IENvZGUgU2lnbmVyMB4XDTIwMDYyNTE4NTI1NFoX +DTMwMDYyMzE4NTI1NFowWzELMAkGA1UEBhMCR0IxDzANBgNVBAcTBkxvbmRvbjEO +MAwGA1UEChMFQ29yZGExCzAJBgNVBAsTAlIzMR4wHAYDVQQDExVDb3JkYSBEZXYg +Q29kZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQDjSJtzQ+ldDFt +pHiqdSJebOGPZcvZbmC/PIJRsZZUF1bl3PfMqyG3EmAe0CeFAfLzPQtf2qTAnmJj +lGTkkQhxo0MwQTATBgNVHSUEDDAKBggrBgEFBQcDAzALBgNVHQ8EBAMCB4AwHQYD +VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh +ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL +zU+Rc5yMtcOY4/moZUq36r0Ilg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/kotlin-samples/customJSON/config/log4j2.xml b/kotlin-samples/customJSON/config/log4j2.xml new file mode 100644 index 0000000..4e297be --- /dev/null +++ b/kotlin-samples/customJSON/config/log4j2.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/kotlin-samples/customJSON/config/r3-ca-key.pem b/kotlin-samples/customJSON/config/r3-ca-key.pem new file mode 100644 index 0000000..a803613 --- /dev/null +++ b/kotlin-samples/customJSON/config/r3-ca-key.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y +ithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If +xp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV +ySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO +DCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ +jdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/ +CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi +EhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM +fRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY +uKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK +chYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t +9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +hjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2 +SV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd ++SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc +fFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa +sjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N +cCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N +0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie +4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI +r/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1 +/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm +gKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/kotlin-samples/customJSON/config/static-network-config.json b/kotlin-samples/customJSON/config/static-network-config.json new file mode 100644 index 0000000..b0f2519 --- /dev/null +++ b/kotlin-samples/customJSON/config/static-network-config.json @@ -0,0 +1,23 @@ +[ + { + "x500Name" : "CN=Alice, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=Charlie, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=Dave, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "MyCorDapp" + }, + { + "x500Name" : "CN=NotaryRep1, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "NotaryServer", + "serviceX500Name": "CN=NotaryService, OU=Test Dept, O=R3, L=London, C=GB" + } +] diff --git a/kotlin-samples/customJSON/contracts/build.gradle b/kotlin-samples/customJSON/contracts/build.gradle new file mode 100644 index 0000000..7a4d466 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/build.gradle @@ -0,0 +1,88 @@ + +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:$slf4jVersion" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by example tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" +// testImplementation "com.r3.corda.ledger.utxo:contract-testing:$contractTestingVersion" +// testImplementation "com.r3.corda.ledger.utxo:contract-testing-kotlin:$contractTestingVersion" +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + contract { + name contractsModule + versionId 1 + licence cordappLicense + vendor cordappVendorName + } +} + +// Use the name of the contract module as the name of the generated CPK and CPB. +archivesBaseName = cordapp.contract.name.isPresent() ? cordapp.contract.name.get() : contractsModule + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleCommands.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleCommands.kt new file mode 100644 index 0000000..d6eb117 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleCommands.kt @@ -0,0 +1,9 @@ +package com.r3.developers.apples.contracts + +import net.corda.v5.ledger.utxo.Command + +interface AppleCommands : Command { + class Issue : AppleCommands + class Redeem : AppleCommands + class PackBasket : AppleCommands +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleStampContract.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleStampContract.kt new file mode 100644 index 0000000..301a211 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/AppleStampContract.kt @@ -0,0 +1,31 @@ +package com.r3.developers.apples.contracts + +import com.r3.developers.apples.states.AppleStamp +import net.corda.v5.ledger.utxo.Contract +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction + +class AppleStampContract : Contract { + override fun verify(transaction: UtxoLedgerTransaction) { + // Extract the command from the transaction + // Verify the transaction according to the intention of the transaction + when (val command = transaction.commands.first()) { + is AppleCommands.Issue -> { + val output = transaction.getOutputStates(AppleStamp::class.java).first() + require(transaction.outputContractStates.size == 1) { + "This transaction should only have one AppleStamp state as output" + } + require(output.stampDesc.isNotBlank()) { + "The output AppleStamp state should have clear description of the type of redeemable goods" + } + } + is AppleCommands.Redeem -> { + // Transaction verification will happen in BasketOfApplesContract + } + else -> { + // Unrecognised Command type + throw IllegalArgumentException("Incorrect type of AppleStamp commands: ${command::class.java.name}") + } + } + } + +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/BasketOfApplesContract.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/BasketOfApplesContract.kt new file mode 100644 index 0000000..28bf490 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/contracts/BasketOfApplesContract.kt @@ -0,0 +1,51 @@ +package com.r3.developers.apples.contracts + +import com.r3.developers.apples.states.AppleStamp +import com.r3.developers.apples.states.BasketOfApples +import net.corda.v5.ledger.utxo.Contract +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction + +class BasketOfApplesContract : Contract { + + override fun verify(transaction: UtxoLedgerTransaction) { + // Extract the command from the transaction + when (val command = transaction.commands.first()) { + is AppleCommands.PackBasket -> { + // Retrieve the output state of the transaction + val output = transaction.getOutputStates(BasketOfApples::class.java).first() + require(transaction.outputContractStates.size == 1) { + "This transaction should only output one BasketOfApples state" + } + require(output.description.isNotBlank()) { + "The output BasketOfApples state should have clear description of Apple product" + } + require(output.weight > 0) { + "The output BasketOfApples state should have non zero weight" + } + } + is AppleCommands.Redeem -> { + require(transaction.inputContractStates.size == 2) { + "This transaction should consume two states" + } + + // Retrieve the inputs to this transaction, which should be exactly one AppleStamp + // and one BasketOfApples + val stampInputs = transaction.getInputStates(AppleStamp::class.java) + val basketInputs = transaction.getInputStates(BasketOfApples::class.java) + + require(stampInputs.isNotEmpty() && basketInputs.isNotEmpty()) { + "This transaction should have exactly one AppleStamp and one BasketOfApples input state" + } + require(stampInputs.single().issuer == basketInputs.single().farm) { + "The issuer of the Apple stamp should be the producing farm of this basket of apple" + } + require(basketInputs.single().weight > 0) { + "The basket of apple has to weigh more than 0" + } + } + else -> { + throw IllegalArgumentException("Incorrect type of BasketOfApples commands: ${command::class.java.name}") + } + } + } +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/AppleStamp.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/AppleStamp.kt new file mode 100644 index 0000000..3db4b6a --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/AppleStamp.kt @@ -0,0 +1,19 @@ +package com.r3.developers.apples.states + +import com.r3.developers.apples.contracts.AppleStampContract +import net.corda.v5.ledger.utxo.BelongsToContract +import net.corda.v5.ledger.utxo.ContractState +import java.security.PublicKey +import java.util.* + +@BelongsToContract(AppleStampContract::class) +class AppleStamp( + val id: UUID, + val stampDesc: String, + val issuer: PublicKey, + val holder: PublicKey, + private val participants: List +) : ContractState { + override fun getParticipants(): List = participants + +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/BasketOfApples.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/BasketOfApples.kt new file mode 100644 index 0000000..d6f82a4 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/apples/states/BasketOfApples.kt @@ -0,0 +1,22 @@ +package com.r3.developers.apples.states + +import com.r3.developers.apples.contracts.BasketOfApplesContract +import net.corda.v5.ledger.utxo.BelongsToContract +import net.corda.v5.ledger.utxo.ContractState +import java.security.PublicKey + +@BelongsToContract(BasketOfApplesContract::class) +class BasketOfApples( + val description: String, + val farm: PublicKey, + val owner: PublicKey, + val weight: Int, + private val participants: List +) : ContractState { + override fun getParticipants(): List = participants + + fun changeOwner(buyer: PublicKey): BasketOfApples { + val participants = listOf(farm, buyer) + return BasketOfApples(description, farm, buyer, weight, participants) + } +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.kt new file mode 100644 index 0000000..8516ee1 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/contracts/ChatContract.kt @@ -0,0 +1,87 @@ +package com.r3.developers.cordapptemplate.customJSON.contracts + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.ledger.utxo.Command +import net.corda.v5.ledger.utxo.Contract +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction + +class ChatContract: Contract { + + // Use an internal scoped constant to hold the error messages + // This allows the tests to use them, meaning if they are updated you won't need to fix tests just because the wording was updated + internal companion object { + + const val REQUIRE_SINGLE_COMMAND = "Requires a single command." + const val UNKNOWN_COMMAND = "Command not allowed." + const val OUTPUT_STATE_SHOULD_ONLY_HAVE_TWO_PARTICIPANTS = "The output state should have two and only two participants." + const val TRANSACTION_SHOULD_BE_SIGNED_BY_ALL_PARTICIPANTS = "The transaction should have been signed by both participants." + + const val CREATE_COMMAND_SHOULD_HAVE_NO_INPUT_STATES = "When command is Create there should be no input states." + const val CREATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE = "When command is Create there should be one and only one output state." + + const val UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_INPUT_STATE = "When command is Update there should be one and only one input state." + const val UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE = "When command is Update there should be one and only one output state." + const val UPDATE_COMMAND_ID_SHOULD_NOT_CHANGE = "When command is Update id must not change." + const val UPDATE_COMMAND_CHATNAME_SHOULD_NOT_CHANGE = "When command is Update chatName must not change." + const val UPDATE_COMMAND_PARTICIPANTS_SHOULD_NOT_CHANGE = "When command is Update participants must not change." + } + + // Command Class used to indicate that the transaction should start a new chat. + class Create: Command + // Command Class used to indicate that the transaction should append a new ChatState to an existing chat. + class Update: Command + + // verify() function is used to apply contract rules to the transaction. + override fun verify(transaction: UtxoLedgerTransaction) { + + // Ensures that there is only one command in the transaction + val command = transaction.commands.singleOrNull() ?: throw CordaRuntimeException(REQUIRE_SINGLE_COMMAND) + + // Applies a universal constraint (applies to all transactions irrespective of command) + OUTPUT_STATE_SHOULD_ONLY_HAVE_TWO_PARTICIPANTS using { + val output = transaction.outputContractStates.first() as ChatState + output.participants.size== 2 + } + + TRANSACTION_SHOULD_BE_SIGNED_BY_ALL_PARTICIPANTS using { + val output = transaction.outputContractStates.first() as ChatState + transaction.signatories.containsAll(output.participants) + } + + // Switches case based on the command + when(command) { + // Rules applied only to transactions with the Create Command. + is Create -> { + CREATE_COMMAND_SHOULD_HAVE_NO_INPUT_STATES using (transaction.inputContractStates.isEmpty()) + CREATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE using (transaction.outputContractStates.size == 1) + } + // Rules applied only to transactions with the Update Command. + is Update -> { + UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_INPUT_STATE using (transaction.inputContractStates.size == 1) + UPDATE_COMMAND_SHOULD_HAVE_ONLY_ONE_OUTPUT_STATE using (transaction.outputContractStates.size == 1) + + val input = transaction.inputContractStates.single() as ChatState + val output = transaction.outputContractStates.single() as ChatState + UPDATE_COMMAND_ID_SHOULD_NOT_CHANGE using (input.id == output.id) + UPDATE_COMMAND_CHATNAME_SHOULD_NOT_CHANGE using (input.chatName == output.chatName) + UPDATE_COMMAND_PARTICIPANTS_SHOULD_NOT_CHANGE using ( + input.participants.toSet().intersect(output.participants.toSet()).size == 2) + } + else -> { + throw CordaRuntimeException(UNKNOWN_COMMAND) + } + } + } + + // Helper function to allow writing constraints in the Corda 4 '"text" using (boolean)' style + private infix fun String.using(expr: Boolean) { + if (!expr) throw CordaRuntimeException("Failed requirement: $this") + } + + // Helper function to allow writing constraints in '"text" using {lambda}' style where the last expression + // in the lambda is a boolean. + private infix fun String.using(expr: () -> Boolean) { + if (!expr.invoke()) throw CordaRuntimeException("Failed requirement: $this") + } +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.kt new file mode 100644 index 0000000..85d72b1 --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatJsonFactory.kt @@ -0,0 +1,34 @@ +package com.r3.developers.cordapptemplate.customJSON.states + +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.ledger.utxo.query.json.ContractStateVaultJsonFactory + +// This class represents a custom JSON factory for serializing/deserializing ChatState objects to JSON format after each use. +class ChatJsonFactory : ContractStateVaultJsonFactory { + + // This function specifies the type of state this factory is responsible for. + override fun getStateType(): Class = ChatState::class.java + + // Companion object containing constants used for JSON key names. + companion object { + const val ID = "Id" + const val CHATNAME = "chatName" + const val MESSAGE = "messageContent" + const val MESSAGEFROM = "messageContentFrom" + } + + // This function creates a JSON representation of a ChatState object. + override fun create(state: ChatState, jsonMarshallingService: JsonMarshallingService): String { + + // Constructs a map representing the ChatState object using the provided constants. + val jsonMap = mapOf( + Pair(ID, state.id), + Pair(CHATNAME, state.chatName), + Pair(MESSAGE, state.message), + Pair(MESSAGEFROM, state.messageFrom) + ) + + // Uses the JsonMarshallingService's format() function to serialize the map to JSON. + return jsonMarshallingService.format(jsonMap) + } +} diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatState.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatState.kt new file mode 100644 index 0000000..2c4e8fd --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/ChatState.kt @@ -0,0 +1,37 @@ +package com.r3.developers.cordapptemplate.customJSON.states + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.utxo.BelongsToContract +import net.corda.v5.ledger.utxo.ContractState +import java.security.PublicKey +import java.util.* + + +// The ChatState represents data stored on ledger. A chat consists of a linear series of messages between two +// participants and is represented by a UUID. Any given pair of participants can have multiple chats +// Each ChatState stores one message between the two participants in the chat. The backchain of ChatStates +// represents the history of the chat. + +@BelongsToContract(ChatContract::class) +data class ChatState( + // Unique identifier for the chat. + val id : UUID = UUID.randomUUID(), + // Non-unique name for the chat. + val chatName: String, + // The MemberX500Name of the participant who sent the message. + val messageFrom: MemberX500Name, + // The message + val message: String, + // The participants to the chat, represented by their public key. + private val participants: List) : ContractState { + + override fun getParticipants(): List { + return participants + } + + // Helper function to create a new ChatState from the previous (input) ChatState. + fun updateMessage(messageFrom: MemberX500Name, message: String) = + copy(messageFrom = messageFrom, message = message) +} + diff --git a/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.kt b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.kt new file mode 100644 index 0000000..22953fc --- /dev/null +++ b/kotlin-samples/customJSON/contracts/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/states/CustomChatQuery.kt @@ -0,0 +1,20 @@ +package com.r3.developers.cordapptemplate.customJSON.states + +import net.corda.v5.ledger.utxo.query.VaultNamedQueryFactory +import net.corda.v5.ledger.utxo.query.registration.VaultNamedQueryBuilderFactory + +class ChatCustomQueryFactory : VaultNamedQueryFactory { + override fun create(vaultNamedQueryBuilderFactory: VaultNamedQueryBuilderFactory) { + vaultNamedQueryBuilderFactory.create("GET_ALL_MSG") + .whereJson( + "WHERE visible_states.custom_representation ? 'com.r3.developers.cordapptemplate.utxoexample.states.ChatState' " + ) + .register() + + vaultNamedQueryBuilderFactory.create("GET_MSG_FROM") + .whereJson( + "WHERE visible_states.custom_representation -> 'com.r3.developers.cordapptemplate.utxoexample.states.ChatState' ->> 'messageContentFrom' = :nameOfSender" + ) + .register() + } +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/gradle.properties b/kotlin-samples/customJSON/gradle.properties new file mode 100644 index 0000000..dc9c265 --- /dev/null +++ b/kotlin-samples/customJSON/gradle.properties @@ -0,0 +1,73 @@ +kotlin.code.style=official + +# Specify the version of the Corda-API to use. +# This needs to match the version supported by the Corda Cluster the CorDapp will run on. +cordaApiVersion=5.2.0.52 + +# Specify the version of the notary plugins to use. +# Currently packaged as part of corda-runtime-os, so should be set to a corda-runtime-os version. +cordaNotaryPluginsVersion=5.2.0.0 + +# Specify the version of the cordapp-cpb and cordapp-cpk plugins +cordaPluginsVersion=7.0.4 + +# Specify the version of the Corda runtime Gradle plugin to use +cordaGradlePluginVersion=5.2.0.0 + +# Specify the name of the workflows module +# This will be the name of the generated cpk and cpb files +workflowsModule=workflows + +# Specify the name of the contracts module +# This will be the name of the generated cpk and cpb files +contractsModule=contracts + +# Specify the location of where Corda 5 binaries can be downloaded +# Relative path from user.home +cordaBinariesDirectory = .corda/corda5 + +# Specify the location of where Corda 5 CLI binaries can be downloaded +# Relative path from user.home +cordaCliBinariesDirectory = .corda/cli + +# Metadata for the CorDapp. +cordappLicense="Apache License, Version 2.0" +cordappVendorName="R3" + +# For the time being this just needs to be set to a dummy value. +platformVersion = 999 + +# Version of Kotlin to use. +# We recommend using a version close to that used by Corda-API. +kotlinVersion = 1.7.21 + +# Do not use default dependencies. +kotlin.stdlib.default.dependency=false + +# Test Tooling Dependency Versions +junitVersion = 5.10.0 +mockitoKotlinVersion=4.0.0 +mockitoVersion=4.6.1 +hamcrestVersion=2.2 +assertjVersion = 3.24.1 +contractTestingVersion=1.0.0-beta-+ +jacksonVersion=2.15.2 +slf4jVersion=1.7.36 + +# Specify the maximum amount of time allowed for the CPI upload +# As your CorDapp grows you might need to increase this +# Value is in milliseconds +cpiUploadDefault=10000 + +# Specify the length of time, in milliseconds, that Corda waits for an individual event to process. +# Keep at -1 to use the default. Refer to the Corda Api Docs for the exact value. +processorTimeout=-1 + +# Specify the maximum amount of time allowed to check all vNodes are registered +# Value is in milliseconds +vnodeRegistrationTimeoutDefault=30000 + +# Specify if you want to run the contracts and workflows tests as part of the corda-runtime-plugin-cordapp > buildCpis task +# False by default, will execute the tests every time you stand the template up - gives extra protection +# Set to true to skip the tests, making the launching process quicker. You will be responsible for running workflow tests yourself +skipTestsDuringBuildCpis=false diff --git a/kotlin-samples/customJSON/gradle/wrapper/gradle-wrapper.jar b/kotlin-samples/customJSON/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..033e24c4cdf41af1ab109bc7f253b2b887023340 GIT binary patch literal 63375 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≫L+crb^Bz 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kotlin-samples/customJSON/gradlew.bat b/kotlin-samples/customJSON/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/kotlin-samples/customJSON/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin-samples/customJSON/settings.gradle b/kotlin-samples/customJSON/settings.gradle new file mode 100644 index 0000000..9c62177 --- /dev/null +++ b/kotlin-samples/customJSON/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + // Declare the repositories where plugins are stored. + repositories { + gradlePluginPortal() + mavenCentral() + mavenLocal() + } + + // The plugin dependencies with versions of the plugins congruent with the specified CorDapp plugin version, + // Corda API version, and Kotlin version. + plugins { + id 'net.corda.plugins.cordapp-cpk2' version cordaPluginsVersion + id 'net.corda.plugins.cordapp-cpb2' version cordaPluginsVersion + id 'net.corda.cordapp.cordapp-configuration' version cordaApiVersion + id 'org.jetbrains.kotlin.jvm' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.jpa' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.allopen' version kotlinVersion + id 'net.corda.gradle.plugin' version cordaGradlePluginVersion + } +} + +// Root project name, used in naming the project as a whole and used in naming objects built by the project. +rootProject.name = 'customJSON' +include ':workflows' +include ':contracts' + diff --git a/kotlin-samples/customJSON/workflows/build.gradle b/kotlin-samples/customJSON/workflows/build.gradle new file mode 100644 index 0000000..761e10c --- /dev/null +++ b/kotlin-samples/customJSON/workflows/build.gradle @@ -0,0 +1,98 @@ +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + constraints { + testImplementation('org.slf4j:slf4j-api') { + version { + // Corda cannot use SLF4J 2.x yet. + strictly '1.7.36' + } + } + } + + // From other subprojects: + cordapp project(':contracts') + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:$slf4jVersion" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by example tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" + testImplementation "org.assertj:assertj-core:$assertjVersion" + testImplementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + workflow { + name workflowsModule + versionId 1 + licence cordappLicense + vendor cordappVendorName + } +} + +// Use the name of the workflow module as the name of the generated CPK and CPB. +archivesBaseName = cordapp.workflow.name.isPresent() ? cordapp.workflow.name.get() : workflowsModule + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.kt new file mode 100644 index 0000000..8f5538d --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampFlow.kt @@ -0,0 +1,87 @@ +package com.r3.developers.apples.workflows + +import com.r3.developers.apples.contracts.AppleCommands +import com.r3.developers.apples.states.AppleStamp +import net.corda.v5.application.flows.ClientRequestBody +import net.corda.v5.application.flows.ClientStartableFlow +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.flows.InitiatingFlow +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.application.messaging.FlowMessaging +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.utxo.UtxoLedgerService +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +@InitiatingFlow(protocol = "create-and-issue-apple-stamp") +class CreateAndIssueAppleStampFlow : ClientStartableFlow { + + internal data class CreateAndIssueAppleStampRequest( + val stampDescription: String, + val holder: MemberX500Name, + val notary: MemberX500Name + ) + + @CordaInject + lateinit var flowMessaging: FlowMessaging + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + @CordaInject + lateinit var utxoLedgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + val request = requestBody.getRequestBodyAs( + jsonMarshallingService, + CreateAndIssueAppleStampRequest::class.java) + val stampDescription = request.stampDescription + val holderName = request.holder + + val notaryInfo = notaryLookup.lookup(request.notary) + ?: throw IllegalArgumentException("Notary ${request.notary} not found") + + val issuer = memberLookup.myInfo().ledgerKeys.first() + val holder = memberLookup.lookup(holderName)?.ledgerKeys?.first() + ?: throw IllegalArgumentException("The holder $holderName does not exist within the network") + + // Building the output AppleStamp state + val newStamp = AppleStamp( + id = UUID.randomUUID(), + stampDesc = stampDescription, + issuer = issuer, + holder = holder, + participants = listOf(issuer, holder) + ) + + val transaction = utxoLedgerService.createTransactionBuilder() + .setNotary(notaryInfo.name) + .addOutputState(newStamp) + .addCommand(AppleCommands.Issue()) + .setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS)) + .addSignatories(listOf(issuer, holder)) + .toSignedTransaction() + + val session = flowMessaging.initiateFlow(holderName) + + return try { + // Send the transaction and state to the counterparty and let them sign it + // Then notarise and record the transaction in both parties' vaults. + utxoLedgerService.finalize(transaction, listOf(session)) + newStamp.id.toString() + } catch (e: Exception) { + "Flow failed, message: ${e.message}" + } + } +} diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.kt new file mode 100644 index 0000000..6bb19dd --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/CreateAndIssueAppleStampResponderFlow.kt @@ -0,0 +1,30 @@ +package com.r3.developers.apples.workflows + +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.flows.InitiatedBy +import net.corda.v5.application.flows.ResponderFlow +import net.corda.v5.application.messaging.FlowSession +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.ledger.utxo.UtxoLedgerService + +@InitiatedBy(protocol = "create-and-issue-apple-stamp") +class CreateAndIssueAppleStampResponderFlow : ResponderFlow { + + @CordaInject + lateinit var utxoLedgerService: UtxoLedgerService + + @Suspendable + override fun call(session: FlowSession) { + // Receive, verify, validate, sign and record the transaction sent from the initiator + utxoLedgerService.receiveFinality(session) { + /* + * [receiveFinality] will automatically verify the transaction and its signatures before signing it. + * However, just because a transaction is contractually valid doesn't mean we necessarily want to sign. + * What if we don't want to deal with the counterparty in question, or the value is too high, + * or we're not happy with the transaction's structure? [UtxoTransactionValidator] (the lambda created + * here) allows us to define the additional checks. If any of these conditions are not met, + * we will not sign the transaction - even if the transaction and its signatures are contractually valid. + */ + } + } +} diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/PackageApplesFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/PackageApplesFlow.kt new file mode 100644 index 0000000..495e267 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/PackageApplesFlow.kt @@ -0,0 +1,68 @@ +package com.r3.developers.apples.workflows + +import com.r3.developers.apples.contracts.AppleCommands +import com.r3.developers.apples.states.BasketOfApples +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.flows.ClientRequestBody +import net.corda.v5.application.flows.ClientStartableFlow +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.utxo.UtxoLedgerService +import java.time.Instant +import java.time.temporal.ChronoUnit + +class PackageApplesFlow : ClientStartableFlow { + + internal data class PackApplesRequest(val appleDescription: String, val weight: Int, val notary: MemberX500Name) + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + @CordaInject + lateinit var utxoLedgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + val request = requestBody.getRequestBodyAs(jsonMarshallingService, PackApplesRequest::class.java) + val appleDescription = request.appleDescription + val weight = request.weight + val notary = notaryLookup.lookup(request.notary) + ?: throw IllegalArgumentException("Notary ${request.notary} not found") + val myKey = memberLookup.myInfo().ledgerKeys.first() + + // Building the output BasketOfApples state + val basket = BasketOfApples( + description = appleDescription, + farm = myKey, + owner = myKey, + weight = weight, + participants = listOf(myKey) + ) + + // Create the transaction + val transaction = utxoLedgerService.createTransactionBuilder() + .setNotary(notary.name) + .addOutputState(basket) + .addCommand(AppleCommands.PackBasket()) + .setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS)) + .addSignatories(listOf(myKey)) + .toSignedTransaction() + + return try { + // Record the transaction, no sessions are passed in as the transaction is only being + // recorded locally + utxoLedgerService.finalize(transaction, emptyList()).transaction.id.toString() + } catch (e: Exception) { + "Flow failed, message: ${e.message}" + } + } +} diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesFlow.kt new file mode 100644 index 0000000..6fddbd4 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesFlow.kt @@ -0,0 +1,90 @@ +package com.r3.developers.apples.workflows + +import com.r3.developers.apples.contracts.AppleCommands +import com.r3.developers.apples.states.AppleStamp +import com.r3.developers.apples.states.BasketOfApples +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.flows.InitiatingFlow +import net.corda.v5.application.flows.ClientRequestBody +import net.corda.v5.application.flows.ClientStartableFlow +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.application.messaging.FlowMessaging +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.utxo.UtxoLedgerService +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +@InitiatingFlow(protocol = "redeem-apples") +class RedeemApplesFlow : ClientStartableFlow { + + internal data class RedeemApplesRequest(val buyer: MemberX500Name, val notary: MemberX500Name, val stampId: UUID) + + @CordaInject + lateinit var flowMessaging: FlowMessaging + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + @CordaInject + lateinit var utxoLedgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + val request = requestBody.getRequestBodyAs(jsonMarshallingService, RedeemApplesRequest::class.java) + val buyerName = request.buyer + val stampId = request.stampId + + // Retrieve the notary's public key (this will change) + val notaryInfo = notaryLookup.lookup(request.notary) + ?: throw IllegalArgumentException("Notary ${request.notary} not found") + + val myKey = memberLookup.myInfo().ledgerKeys.first() + + val buyer = memberLookup.lookup(buyerName)?.ledgerKeys?.first() + ?: throw IllegalArgumentException("The buyer does not exist within the network") + + val appleStampStateAndRef = utxoLedgerService.findUnconsumedStatesByExactType(AppleStamp::class.java, 100, Instant.now()) + .results.firstOrNull { stateAndRef -> stateAndRef.state.contractState.id == stampId } + ?: throw IllegalArgumentException("No apple stamp matching the stamp id $stampId") + + val basketOfApplesStampStateAndRef = utxoLedgerService.findUnconsumedStatesByExactType(BasketOfApples::class.java, 100, Instant.now()) + .results.firstOrNull { basketStateAndRef -> basketStateAndRef.state.contractState.owner == + appleStampStateAndRef.state.contractState.issuer } + ?: throw IllegalArgumentException("There are no eligible baskets of apples") + + + val originalBasketOfApples = basketOfApplesStampStateAndRef.state.contractState + + val updatedBasket = originalBasketOfApples.changeOwner(buyer) + + // Create the transaction + val transaction = utxoLedgerService.createTransactionBuilder() + .setNotary(notaryInfo.name) + .addInputStates(appleStampStateAndRef.ref, basketOfApplesStampStateAndRef.ref) + .addOutputState(updatedBasket) + .addCommand(AppleCommands.Redeem()) + .setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS)) + .addSignatories(listOf(myKey, buyer)) + .toSignedTransaction() + + val session = flowMessaging.initiateFlow(buyerName) + + return try { + // Send the transaction and state to the counterparty and let them sign it + // Then notarise and record the transaction in both parties' vaults. + utxoLedgerService.finalize(transaction, listOf(session)).transaction.id.toString() + } catch (e: Exception) { + "Flow failed, message: ${e.message}" + } + } +} diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.kt new file mode 100644 index 0000000..d53889e --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/apples/workflows/RedeemApplesResponderFlow.kt @@ -0,0 +1,30 @@ +package com.r3.developers.apples.workflows + +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.flows.InitiatedBy +import net.corda.v5.application.flows.ResponderFlow +import net.corda.v5.application.messaging.FlowSession +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.ledger.utxo.UtxoLedgerService + +@InitiatedBy(protocol = "redeem-apples") +class RedeemApplesResponderFlow : ResponderFlow { + + @CordaInject + lateinit var utxoLedgerService: UtxoLedgerService + + @Suspendable + override fun call(session: FlowSession) { + // Receive, verify, validate, sign and record the transaction sent from the initiator + utxoLedgerService.receiveFinality(session) { + /* + * [receiveFinality] will automatically verify the transaction and its signatures before signing it. + * However, just because a transaction is contractually valid doesn't mean we necessarily want to sign. + * What if we don't want to deal with the counterparty in question, or the value is too high, + * or we're not happy with the transaction's structure? [UtxoTransactionValidator] (the lambda created + * here) allows us to define the additional checks. If any of these conditions are not met, + * we will not sign the transaction - even if the transaction and its signatures are contractually valid. + */ + } + } +} diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.kt new file mode 100644 index 0000000..1650306 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/CreateNewChatFlow.kt @@ -0,0 +1,112 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.* +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.Instant + +// A class to hold the deserialized arguments required to start the flow. +data class CreateNewChatFlowArgs(val chatName: String, val message: String, val otherMember: String) + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class CreateNewChatFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + // FlowEngine service is required to run SubFlows. + @CordaInject + lateinit var flowEngine: FlowEngine + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("CreateNewChatFlow.call() called") + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, CreateNewChatFlowArgs::class.java) + + // Get MemberInfos for the Vnode running the flow and the otherMember. + // Good practice in Kotlin CorDapps is to only throw RuntimeException. + // Note, in Java CorDapps only unchecked RuntimeExceptions can be thrown not + // declared checked exceptions as this changes the method signature and breaks override. + val myInfo = memberLookup.myInfo() + val otherMember = memberLookup.lookup(MemberX500Name.parse(flowArgs.otherMember)) ?: + throw CordaRuntimeException("MemberLookup can't find otherMember specified in flow arguments.") + + // Create the ChatState from the input arguments and member information. + val chatState = ChatState( + chatName = flowArgs.chatName, + messageFrom = myInfo.name, + message = flowArgs.message, + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + // Obtain the notary. + val notary = notaryLookup.notaryServices.single() + + // Use UTXOTransactionBuilder to build up the draft transaction. + val txBuilder= ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + val signedTransaction = txBuilder.toSignedTransaction() + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(FinalizeChatSubFlow(signedTransaction, otherMember.name)) + + + } + // Catch any exceptions, log them and rethrow the exception. + catch (e: Exception) { + log.warn("Failed to process utxo flow for request body '$requestBody' because:'${e.message}'") + throw e + } + } +} + + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} + */ \ No newline at end of file diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.kt new file mode 100644 index 0000000..5dc3a30 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/FinalizeChatSubFlow.kt @@ -0,0 +1,103 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.* +import net.corda.v5.application.messaging.FlowMessaging +import net.corda.v5.application.messaging.FlowSession +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.utxo.UtxoLedgerService +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction +import org.slf4j.LoggerFactory + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +// @InitiatingFlow declares the protocol which will be used to link the initiator to the responder. +@InitiatingFlow(protocol = "finalize-chat-protocol") +class FinalizeChatSubFlow(private val signedTransaction: UtxoSignedTransaction, private val otherMember: MemberX500Name): SubFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @CordaInject + lateinit var flowMessaging: FlowMessaging + + @Suspendable + override fun call(): String { + + log.info("FinalizeChatFlow.call() called") + + // Initiates a session with the other Member. + val session = flowMessaging.initiateFlow(otherMember) + + return try { + // Calls the Corda provided finalise() function which gather signatures from the counterparty, + // notarises the transaction and persists the transaction to each party's vault. + // On success returns the id of the transaction created. (This is different to the ChatState id) + val finalizedSignedTransaction = ledgerService.finalize( + signedTransaction, + listOf(session) + ) + // Returns the transaction id converted to a string. + finalizedSignedTransaction.transaction.id.toString().also { + log.info("Success! Response: $it") + } + } + // Soft fails the flow and returns the error message without throwing a flow exception. + catch (e: Exception) { + log.warn("Finality failed", e) + "Finality failed, ${e.message}" + } + } +} + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +//@InitiatingBy declares the protocol which will be used to link the initiator to the responder. +@InitiatedBy(protocol = "finalize-chat-protocol") +class FinalizeChatResponderFlow: ResponderFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(session: FlowSession) { + + log.info("FinalizeChatResponderFlow.call() called") + + try { + // Calls receiveFinality() function which provides the responder to the finalise() function + // in the Initiating Flow. Accepts a lambda validator containing the business logic to decide whether + // responder should sign the Transaction. + val finalizedSignedTransaction = ledgerService.receiveFinality(session) { ledgerTransaction -> + + // Note, this exception will only be shown in the logs if Corda Logging is set to debug. + val state = ledgerTransaction.getOutputStates(ChatState::class.java).singleOrNull() ?: + throw CordaRuntimeException("Failed verification - transaction did not have exactly one output ChatState.") + + // Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions + // to check whether to sign the transaction. + checkForBannedWords(state.message) + checkMessageFromMatchesCounterparty(state, session.counterparty) + + log.info("Verified the transaction- ${ledgerTransaction.id}") + } + log.info("Finished responder flow - ${finalizedSignedTransaction.transaction.id}") + } + // Soft fails the flow and log the exception. + catch (e: Exception) { + log.warn("Exceptionally finished responder flow", e) + } + } +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.kt new file mode 100644 index 0000000..68fc067 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/GetChatFlow.kt @@ -0,0 +1,117 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.ClientRequestBody +import net.corda.v5.application.flows.ClientStartableFlow +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.ledger.utxo.StateAndRef +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Instant +import java.util.* + +// A class to hold the deserialized arguments required to start the flow. +data class GetChatFlowArgs(val id: UUID, val numberOfRecords: Int) + +// A class to pair the messageFrom and message together. +data class MessageAndSender(val messageFrom: String, val message: String) + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class GetChatFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("GetChatFlow.call() called") + + // Obtain the deserialized input arguments to the flow from the requestBody. + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, GetChatFlowArgs::class.java) + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + val states = ledgerService.findUnconsumedStatesByExactType(ChatState::class.java, 100, Instant.now()).results + val state = states.singleOrNull {it.state.contractState.id == flowArgs.id} + ?: throw CordaRuntimeException("Did not find an unique unconsumed ChatState with id ${flowArgs.id}") + + // Calls resolveMessagesFromBackchain() which retrieves the chat history from the backchain. + return jsonMarshallingService.format(resolveMessagesFromBackchain(state, flowArgs.numberOfRecords )) + } + + // resoveMessageFromBackchain() starts at the stateAndRef provided, which represents the unconsumed head of the + // backchain for this particular chat, then walks the chain backwards for the number of transaction specified in + // the numberOfRecords argument. For each transaction it adds the MessageAndSender representing the + // message and who sent it to a list which is then returned. + @Suspendable + private fun resolveMessagesFromBackchain(stateAndRef: StateAndRef, numberOfRecords: Int): List{ + + // Set up a MutableList to collect the MessageAndSender(s) + val messages = mutableListOf() + + // Set up initial conditions for walking the backchain. + var currentStateAndRef = stateAndRef + var recordsToFetch = numberOfRecords + var moreBackchain = true + + // Continue to loop until the start of the backchain or enough records have been retrieved. + while (moreBackchain) { + + // Obtain the transaction id from the current StateAndRef and fetch the transaction from the vault. + val transactionId = currentStateAndRef.ref.transactionId + val transaction = ledgerService.findLedgerTransaction(transactionId) + ?: throw CordaRuntimeException("Transaction $transactionId not found.") + + // Get the output state from the transaction and use it to create a MessageAndSender Object which + // is appended to the mutable list. + val output = transaction.getOutputStates(ChatState::class.java).singleOrNull() + ?: throw CordaRuntimeException("Expecting one and only one ChatState output for transaction $transactionId.") + messages.add(MessageAndSender(output.messageFrom.toString(), output.message)) + // Decrement the number of records to fetch. + recordsToFetch-- + + // Get the reference to the input states. + val inputStateAndRefs = transaction.inputStateAndRefs + + // Check if there are no more input states (start of chain) or we have retrieved enough records. + // Check the transaction is not malformed by having too many input states. + // Set the currentStateAndRef to the input StateAndRef, then repeat the loop. + if (inputStateAndRefs.isEmpty() || recordsToFetch == 0) { + moreBackchain = false + } else if (inputStateAndRefs.size > 1) { + throw CordaRuntimeException("More than one input state found for transaction $transactionId.") + } else { + @Suppress("UNCHECKED_CAST") + currentStateAndRef = inputStateAndRefs.single() as StateAndRef + } + } + // Convert to an immutable List. + return messages.toList() + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.GetChatFlow", + "requestBody": { + "id":"** fill in id **", + "numberOfRecords":"4" + } +} + */ \ No newline at end of file diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.kt new file mode 100644 index 0000000..9c26d88 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsByCustomQueryFlow.kt @@ -0,0 +1,66 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.ClientRequestBody +import net.corda.v5.application.flows.ClientStartableFlow +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.ledger.utxo.StateAndRef +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Instant + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class ListChatsByCustomQueryFlow : ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("ListChatsByCustomQueryFlow.call() called") + + //this is our custom query + val resultSet = ledgerService.query("GET_MSG_FROM", StateAndRef::class.java) + .setParameter("nameOfSender", "CN=Alice, OU=Test Dept, O=R3, L=London, C=GB") + .setCreatedTimestampLimit(Instant.now()).setLimit(1000) + .execute() + + //from custom query to states, we can operate + val resultState = resultSet.results.map {it.state.contractState as ChatState} + + //from the states -> human-readable. + val results = resultState.map { + ChatStateResults( + it.id, + it.chatName, + it.messageFrom.toString(), + it.message) } + log.info("-------------results will be printed------") + log.warn(results.toString()) + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results.toString()) + } +} + + + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "customlist-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.ListChatsByCustomQueryFlow", + "requestBody": {} +} +*/ diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.kt new file mode 100644 index 0000000..4b8a125 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ListChatsFlow.kt @@ -0,0 +1,62 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.ClientRequestBody +import net.corda.v5.application.flows.ClientStartableFlow +import net.corda.v5.application.flows.CordaInject +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Instant +import java.util.* + + +// Data class to hold the Flow results. +// The ChatState(s) cannot be returned directly as the JsonMarshallingService can only serialize simple classes +// that the underlying Jackson serializer recognises, hence creating a DTO style object which consists only of Strings +// and a UUID. It is possible to create custom serializers for the JsonMarshallingService, but this beyond the scope +// of this simple example. +data class ChatStateResults(val id: UUID, val chatName: String,val messageFromName: String, val message: String) + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class ListChatsFlow : ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("ListChatsFlow.call() called") + + // Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO. + val states = ledgerService.findUnconsumedStatesByExactType(ChatState::class.java, 100, Instant.now()).results + val results = states.map { + ChatStateResults( + it.state.contractState.id, + it.state.contractState.chatName, + it.state.contractState.messageFrom.toString(), + it.state.contractState.message) } + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results) + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.ListChatsFlow", + "requestBody": {} +} +*/ diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ResponderValidations.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ResponderValidations.kt new file mode 100644 index 0000000..6a790c9 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/ResponderValidations.kt @@ -0,0 +1,24 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name + +// Note, these exceptions will only be visible in the logs if Corda logging is set to debug. + +// Checks that the message does not contain banned words and throws and exception if it does. +@Suspendable +fun checkForBannedWords(str: String) { + val bannedWords = listOf("banana", "apple", "pear") + if (bannedWords.any { str.contains(it) }) + throw CordaRuntimeException("Failed verification - message contains banned words") +} + +// Checks that the messageFrom field in the ChatState matches the initiators (otherMember) +// memberX500Name, if not it throws an exception. +@Suspendable +fun checkMessageFromMatchesCounterparty(state: ChatState, otherMember: MemberX500Name) { + if( state.messageFrom != otherMember) + throw CordaRuntimeException("Failed verification - messageFrom does not equal flow initiator memberX500Name") +} \ No newline at end of file diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/TestContractFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/TestContractFlow.kt new file mode 100644 index 0000000..94fe32c --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/TestContractFlow.kt @@ -0,0 +1,492 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.* +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.utxo.Command +import net.corda.v5.ledger.utxo.StateRef +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.Instant +import java.util.* + + +data class TestContractFlowArgs(val otherMember: String) + +class TestContractFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + @CordaInject + lateinit var flowEngine: FlowEngine + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + + val results = mutableMapOf() + + log.info("TestContractFlow.call() called") + + class FakeCommand : Command + + try { + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, TestContractFlowArgs::class.java) + + val myInfo = memberLookup.myInfo() + + val otherMember = memberLookup.lookup(MemberX500Name.parse(flowArgs.otherMember)) ?: + throw CordaRuntimeException("MemberLookup can't find otherMember specified in flow arguments.") + + // Obtain the Notary name and public key. + val notary = notaryLookup.notaryServices.first() + + // Create a well formed transaction with an output State which can be referenced + // as an input StateRef in the tests + lateinit var inputStateRef: StateRef + lateinit var chatId: UUID + + try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + chatId = chatState.id + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + inputStateRef = StateRef(signedTransaction.id, 0) + flowEngine.subFlow(FinalizeChatSubFlow(signedTransaction, otherMember.name)) + + } catch (e:Exception) { + throw CordaRuntimeException("Set up transaction could not be created because of exception: ${e.message}") + } + + + + + // ************* START TESTS **************** + + // Multiple Commands not permitted + results["Multiple Commands not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addCommand(FakeCommand()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("Requires a single command.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // ChatState with 3 Participants not permitted + results["ChatState with 3 Participants not permitted"] = try { + + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("The output state should have two and only two participants.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Input State on Create not permitted + results["Input State on Create not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Create there should be no input states.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + // Zero output States on Create not permitted + + // Test omitted as it would fail on + // "The output state should have two and only two participants." first + + + // Two output States on Create not permitted + results["Two output States on Create not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Create there should be one and only one output state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Zero input State on Update not permitted + results["Zero input State on Update not permitted"] = try { + + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update there should be one and only one input state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Two Input State on Update not permitted + results["Two Input State on Update not permitted"] = try { + + log.info("MB: test change") + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update there should be one and only one input state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Zero output States on Update not permitted + + // Test omitted as it would fail on + // "The output state should have two and only two participants." first + + + // Two output States on Update not permitted + results["Two output States on Update not permitted"] = try { + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update there should be one and only one output state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // On Update id must not change + results["On Update id must not change"] = try { + val chatState = ChatState( + id = UUID.randomUUID(), + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update id must not change")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // On Update chatName must not change + results["On Update chatName must not change"] = try { + val chatState = ChatState( + id = chatId, + chatName = "DummyChat Name has changed", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update chatName must not change.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // On Update participants must not change + results["On Update participants must not change"] = try { + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), myInfo.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update participants must not change.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // FakeCommand not permitted + results["FakeCommand not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + + // Use UTXOTransactionBuilder to build up the draft transaction. + val txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.name) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(FakeCommand()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("Command not allowed.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + + return results.toString() + + // Catch any exceptions, log them and rethrow the exception. + } catch (e: Exception) { + log.warn("Failed to process utxo flow for request body '$requestBody' because:'${e.message}'") + throw e + } + } + +} +/* +{ + "clientRequestId": "dummy-1", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.TestContractFlow", + "requestBody": { + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB" + } +} + + */ \ No newline at end of file diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.kt new file mode 100644 index 0000000..83c88c1 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/customJSON/workflows/UpdateChatFlow.kt @@ -0,0 +1,109 @@ +package com.r3.developers.cordapptemplate.customJSON.workflows + +import com.r3.developers.cordapptemplate.customJSON.contracts.ChatContract +import com.r3.developers.cordapptemplate.customJSON.states.ChatState +import net.corda.v5.application.flows.* +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.Instant +import java.util.* + +// A class to hold the deserialized arguments required to start the flow. +data class UpdateChatFlowArgs(val id: UUID, val message: String) + + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class UpdateChatFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + // FlowEngine service is required to run SubFlows. + @CordaInject + lateinit var flowEngine: FlowEngine + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("UpdateNewChatFlow.call() called") + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, UpdateChatFlowArgs::class.java) + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + val stateAndRef = ledgerService.findUnconsumedStatesByExactType(ChatState::class.java, 100, Instant.now()).results.singleOrNull { + it.state.contractState.id == flowArgs.id + } ?: throw CordaRuntimeException("Multiple or zero Chat states with id ${flowArgs.id} found.") + + // Get MemberInfos for the Vnode running the flow and the otherMember. + val myInfo = memberLookup.myInfo() + val state = stateAndRef.state.contractState + + val members = state.participants.map { + memberLookup.lookup(it) ?: throw CordaRuntimeException("Member not found from public key $it.")} + val otherMember = (members - myInfo).singleOrNull() + ?: throw CordaRuntimeException("Should be only one participant other than the initiator.") + + // Create a new ChatState using the updateMessage helper function. + val newChatState = state.updateMessage(myInfo.name, flowArgs.message) + + // Use UTXOTransactionBuilder to build up the draft transaction. + val txBuilder= ledgerService.createTransactionBuilder() + .setNotary(stateAndRef.state.notaryName) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(newChatState) + .addInputState(stateAndRef.ref) + .addCommand(ChatContract.Update()) + .addSignatories(newChatState.participants) + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + val signedTransaction = txBuilder.toSignedTransaction() + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(FinalizeChatSubFlow(signedTransaction, otherMember.name)) + + + } + // Catch any exceptions, log them and rethrow the exception. + catch (e: Exception) { + log.warn("Failed to process utxo flow for request body '$requestBody' because:'${e.message}'") + throw e + } + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "update-2", + "flowClassName": "com.r3.developers.cordapptemplate.utxoexample.workflows.UpdateChatFlow", + "requestBody": { + "id":"** fill in id **", + "message": "How are you today?" + } +} + */ \ No newline at end of file diff --git a/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.kt b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.kt new file mode 100644 index 0000000..47e46d3 --- /dev/null +++ b/kotlin-samples/customJSON/workflows/src/main/kotlin/com/r3/developers/cordapptemplate/flowexample/workflows/MyFirstFlow.kt @@ -0,0 +1,154 @@ +package com.r3.developers.cordapptemplate.flowexample.workflows + +import net.corda.v5.application.flows.* +import net.corda.v5.application.marshalling.JsonMarshallingService +import net.corda.v5.application.membership.MemberLookup +import net.corda.v5.application.messaging.FlowMessaging +import net.corda.v5.application.messaging.FlowSession +import net.corda.v5.base.annotations.CordaSerializable +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.types.MemberX500Name +import org.slf4j.LoggerFactory + +// A class to hold the deserialized arguments required to start the flow. +class MyFirstFlowStartArgs(val otherMember: MemberX500Name) + + +// A class which will contain a message, It must be marked with @CordaSerializable for Corda +// to be able to send from one virtual node to another. +@CordaSerializable +class Message(val sender: MemberX500Name, val message: String) + + +// MyFirstFlow is an initiating flow, it's corresponding responder flow is called MyFirstFlowResponder (defined below) +// to link the two sides of the flow together they need to have the same protocol. +@InitiatingFlow(protocol = "my-first-flow") +// MyFirstFlow should inherit from ClientStartableFlow, which tells Corda it can be started via an REST call from a client +class MyFirstFlow: ClientStartableFlow { + + // It is useful to be able to log messages from the flows for debugging. + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + // Corda has a set of injectable services which are injected into the flow at runtime. + // Flows declare them with @CordaInjectable, then the flows have access to their services. + + // JsonMarshallingService provides a Service for manipulating json + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + // FlowMessaging provides a service for establishing flow sessions between Virtual Nodes and + // sending and receiving payloads between them + @CordaInject + lateinit var flowMessaging: FlowMessaging + + // MemberLookup provides a service for looking up information about members of the Virtual Network which + // this CorDapp is operating in. + @CordaInject + lateinit var memberLookup: MemberLookup + + + + // When a flow is invoked its call() method is called. + // call() methods must be marked as @Suspendable, this allows Corda to pause mid-execution to wait + // for a response from the other flows and services. + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + // Useful logging to follow what's happening in the console or logs + log.info("MFF: MyFirstFlow.call() called") + + // Show the requestBody in the logs - this can be used to help establish the format for starting a flow on corda + log.info("MFF: requestBody: ${requestBody.getRequestBody()}") + + // Deserialize the Json requestBody into the MyfirstFlowStartArgs class using the JsonSerialisation Service + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, MyFirstFlowStartArgs::class.java) + + // Obtain the MemberX500Name of counterparty + val otherMember = flowArgs.otherMember + + // Get our identity from the MemberLookup service. + val ourIdentity = memberLookup.myInfo().name + + // Create the message payload using the MessageClass we defined. + val message = Message(otherMember, "Hello from $ourIdentity.") + + // Log the message to be sent. + log.info("MFF: message.message: ${message.message}") + + // Start a flow session with the otherMember using the FlowMessaging service + // The otherMember's Virtual Node will run the corresponding MyFirstFlowResponder responder flow + val session = flowMessaging.initiateFlow(otherMember) + + // Send the Payload using the send method on the session to the MyFirstFlowResponder Responder flow + session.send(message) + + // Receive a response from the Responder flow + val response = session.receive(Message::class.java) + + // The return value of a ClientStartableFlow must always be a String, this String will be passed + // back as the REST response when the status of the flow is queried on Corda. + return response.message + } +} + +// MyFirstFlowResponder is a responder flow, it's corresponding initiating flow is called MyFirstFlow (defined above) +// to link the two sides of the flow together they need to have the same protocol. +@InitiatedBy(protocol = "my-first-flow") +// Responder flows must inherit from ResponderFlow +class MyFirstFlowResponder: ResponderFlow { + + // It is useful to be able to log messages from the flows for debugging. + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + // MemberLookup provides a service for looking up information about members of the Virtual Network which + // this CorDapp is operating in. + @CordaInject + lateinit var memberLookup: MemberLookup + + + // Responder flows are invoked when an initiating flow makes a call via a session set up with the Virtual + // node hosting the Responder flow. When a responder flow is invoked, its call() method is called. + // call() methods must be marked as @Suspendable, this allows Corda to pause mid-execution to wait + // for a response from the other flows and services/ + // The Call method has the flow session passed in as a parameter by Corda so the session is available to + // responder flow code, you don't need to inject the FlowMessaging service. + @Suspendable + override fun call(session: FlowSession) { + + // Useful logging to follow what's happening in the console or logs + log.info("MFF: MyFirstResponderFlow.call() called") + + // Receive the payload and deserialize it into a Message class + val receivedMessage = session.receive(Message::class.java) + + // Log the message as a proxy for performing some useful operation on it. + log.info("MFF: Message received from ${receivedMessage.sender}: ${receivedMessage.message} ") + + // Get our identity from the MemberLookup service. + val ourIdentity = memberLookup.myInfo().name + + // Create a response to greet the sender + val response = Message(ourIdentity, + "Hello ${session.counterparty.commonName}, best wishes from ${ourIdentity.commonName}") + + // Log the response to be sent. + log.info("MFF: response.message: ${response.message}") + + // Send the response via the send method on the flow session + session.send(response) + } +} +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "r1", + "flowClassName": "com.r3.developers.cordapptemplate.flowexample.workflows.MyFirstFlow", + "requestBody": { + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB" + } +} + */ \ No newline at end of file