diff --git a/corda-common/src/main/kotlin/tech/b180/cordaptor/corda/CordaNodeState.kt b/corda-common/src/main/kotlin/tech/b180/cordaptor/corda/CordaNodeState.kt index b1ed05f..e02472a 100644 --- a/corda-common/src/main/kotlin/tech/b180/cordaptor/corda/CordaNodeState.kt +++ b/corda-common/src/main/kotlin/tech/b180/cordaptor/corda/CordaNodeState.kt @@ -17,6 +17,7 @@ import net.corda.core.node.services.vault.QueryCriteria import net.corda.core.node.services.vault.Sort import net.corda.core.transactions.SignedTransaction import tech.b180.cordaptor.kernel.ModuleAPI +import java.io.InputStream import java.security.PublicKey import java.time.Instant import java.util.* @@ -99,6 +100,8 @@ interface CordaNodeState : PartyLocator { fun trackStates(query: CordaVaultQuery): CordaDataFeed fun initiateFlow(instruction: CordaFlowInstruction>): CordaFlowHandle + + fun createAttachment(attachment: CordaNodeAttachment): SecureHash } /** @@ -157,6 +160,14 @@ data class CordaFlowInstruction>( ) } +@ModuleAPI(since = "0.1") +data class CordaNodeAttachment( + val inputStream: InputStream, + val uploader: String, + val filename: String, + val dataType: String +) + /** * Container for a result of executing Corda flow, which may be either * an object or an exception, alongside an [Instant] when the result was captured. diff --git a/corda-rpc-client/src/main/kotlin/tech/b180/cordaptor/rpc/ClientNodeState.kt b/corda-rpc-client/src/main/kotlin/tech/b180/cordaptor/rpc/ClientNodeState.kt index c51c1b0..417b5cd 100644 --- a/corda-rpc-client/src/main/kotlin/tech/b180/cordaptor/rpc/ClientNodeState.kt +++ b/corda-rpc-client/src/main/kotlin/tech/b180/cordaptor/rpc/ClientNodeState.kt @@ -27,7 +27,14 @@ import org.slf4j.Logger import tech.b180.cordaptor.corda.* import tech.b180.cordaptor.kernel.CordaptorComponent import tech.b180.cordaptor.kernel.loggerFor +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.Paths import java.security.PublicKey +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream /** * Implementation of [CordaNodeState] interface providing access to a state @@ -105,6 +112,26 @@ class ClientNodeStateImpl : CordaNodeStateInner, CordaptorComponent, CordaNodeVa return get>().initiateFlow(instruction) } + + override fun createAttachment(attachment: CordaNodeAttachment): SecureHash { + val zipName = "${attachment.filename}-${UUID.randomUUID()}.zip" + FileOutputStream(zipName).use { fileOutputStream -> + ZipOutputStream(fileOutputStream).use { zipOutputStream -> + val zipEntry = ZipEntry(attachment.filename) + zipOutputStream.putNextEntry(zipEntry) + attachment.inputStream.copyTo(zipOutputStream, 1024) + } + } + val inputStream = FileInputStream(zipName) + val hash = rpc.uploadAttachmentWithMetadata( + jar = inputStream, + uploader = attachment.dataType, + filename = attachment.filename + ) + inputStream.close() + Files.deleteIfExists(Paths.get(zipName)) + return hash + } } /** @@ -131,7 +158,7 @@ class RPCFlowInitiator : FlowInitiator(), Cordaptor val flowClass = instruction.flowClass.java logger.debug("Preparing to initiate flow {} over Corda RPC connection", flowClass) - val typeInfo: LocalTypeInformation = localTypeModel.inspect(flowClass); + val typeInfo: LocalTypeInformation = localTypeModel.inspect(flowClass) val constructorParameters = when (typeInfo){ is LocalTypeInformation.Composable -> (typeInfo as? LocalTypeInformation.Composable)?.constructor?.parameters diff --git a/corda-service/src/main/kotlin/tech/b180/cordaptor/cordapp/InternalNodeState.kt b/corda-service/src/main/kotlin/tech/b180/cordaptor/cordapp/InternalNodeState.kt index 963afa4..c6b349d 100644 --- a/corda-service/src/main/kotlin/tech/b180/cordaptor/cordapp/InternalNodeState.kt +++ b/corda-service/src/main/kotlin/tech/b180/cordaptor/cordapp/InternalNodeState.kt @@ -25,7 +25,14 @@ import org.slf4j.Logger import tech.b180.cordaptor.corda.* import tech.b180.cordaptor.kernel.CordaptorComponent import tech.b180.cordaptor.kernel.loggerFor +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.Paths import java.security.PublicKey +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream /** * Implementation of [CordaNodeState] interface providing access to a state @@ -99,6 +106,26 @@ class CordaNodeStateImpl : CordaNodeStateInner, CordaptorComponent, CordaNodeVau return get>().initiateFlow(instruction) } + + override fun createAttachment(attachment: CordaNodeAttachment): SecureHash { + val zipName = "${attachment.filename}-${UUID.randomUUID()}.zip" + FileOutputStream(zipName).use { fileOutputStream -> + ZipOutputStream(fileOutputStream).use { zipOutputStream -> + val zipEntry = ZipEntry(attachment.filename) + zipOutputStream.putNextEntry(zipEntry) + attachment.inputStream.copyTo(zipOutputStream, 1024) + } + } + val inputStream = FileInputStream(zipName) + val hash = appServiceHub.attachments.importAttachment( + jar = inputStream, + uploader = attachment.dataType, + filename = attachment.filename + ) + inputStream.close() + Files.deleteIfExists(Paths.get(zipName)) + return hash + } } /** diff --git a/reference-cordapp/src/genericApiTest/kotlin/tech/b180/cordaptor_test/CordaptorAPITestSuite.kt b/reference-cordapp/src/genericApiTest/kotlin/tech/b180/cordaptor_test/CordaptorAPITestSuite.kt index 0168129..61caeba 100644 --- a/reference-cordapp/src/genericApiTest/kotlin/tech/b180/cordaptor_test/CordaptorAPITestSuite.kt +++ b/reference-cordapp/src/genericApiTest/kotlin/tech/b180/cordaptor_test/CordaptorAPITestSuite.kt @@ -3,11 +3,15 @@ package tech.b180.cordaptor_test import net.corda.core.contracts.StateRef import net.corda.core.crypto.SecureHash import org.eclipse.jetty.client.HttpClient +import org.eclipse.jetty.client.util.MultiPartContentProvider +import org.eclipse.jetty.client.util.PathContentProvider import org.eclipse.jetty.client.util.StringContentProvider import org.eclipse.jetty.http.HttpHeader +import org.junit.jupiter.api.assertDoesNotThrow import tech.b180.ref_cordapp.DelayedProgressFlow import tech.b180.ref_cordapp.SimpleFlow import java.io.StringReader +import java.nio.file.Paths import java.time.Duration import java.time.Instant import javax.json.Json @@ -41,6 +45,7 @@ class CordaptorAPITestSuite( testStateQuery(client, stateRef) testVaultQueryViaGET(client) testVaultQueryViaPOST(client) + testNodeAttachmentViaPOST(client) } private fun testOpenAPISpecification(client: HttpClient) { @@ -211,9 +216,30 @@ class CordaptorAPITestSuite( assertEquals(1, page.getInt("totalStatesAvailable")) } + private fun testNodeAttachmentViaPOST(client: HttpClient) { + val req = client.POST( + "$baseUrl/node/uploadNodeAttachment") + + val multiPartContentProvider = MultiPartContentProvider() + + multiPartContentProvider.addFieldPart("filename", StringContentProvider("testData.csv"), null) + multiPartContentProvider.addFieldPart("dataType", StringContentProvider("testDataType"), null) + multiPartContentProvider.addFieldPart("uploader", StringContentProvider("User"), null) + multiPartContentProvider.addFilePart("data", "testData.csv", + PathContentProvider(Paths.get(CordaptorAPITestSuite::class.java.classLoader.getResource("testData.csv").toURI())), null) + + multiPartContentProvider.close() + req.content(multiPartContentProvider) + val response = req.send() + + assertEquals("application/json", response.mediaType) + assertEquals(HttpServletResponse.SC_OK, response.status) + assertDoesNotThrow { SecureHash.parse(response.contentAsString.replace("\"", "")) } + } + private fun testVaultQueryViaPOST(client: HttpClient) { val req = client.POST( - "$baseUrl/node/reference/SimpleLinearState/query") + "$baseUrl/node/reference/SimpleLinearState/query") val content = """{ |"contractStateClass":"tech.b180.ref_cordapp.SimpleLinearState", diff --git a/reference-cordapp/src/genericApiTest/resources/ReferenceCordapp.api.json b/reference-cordapp/src/genericApiTest/resources/ReferenceCordapp.api.json index d6dadc4..8bbc527 100644 --- a/reference-cordapp/src/genericApiTest/resources/ReferenceCordapp.api.json +++ b/reference-cordapp/src/genericApiTest/resources/ReferenceCordapp.api.json @@ -2063,6 +2063,52 @@ ] } }, + "/node/uploadNodeAttachment": { + "post": { + "operationId": "uploadNodeAttachment", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "uploader": { + "type": "string", + "minLength": 1 + }, + "dataType": { + "type": "string", + "minLength": 1 + }, + "data": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CordaSecureHash" + } + } + }, + "description": "Attachment uploaded successfully and its result is available" + }, + "403": { + "description": "Permission denied" + } + }, + "summary": "Uploads Corda attachment with given parameters", + "tags": ["nodeAttachment"] + } + }, "/node/version": { "get": { "operationId": "getNodeVersion", diff --git a/reference-cordapp/src/genericApiTest/resources/testData.csv b/reference-cordapp/src/genericApiTest/resources/testData.csv new file mode 100644 index 0000000..cf46503 --- /dev/null +++ b/reference-cordapp/src/genericApiTest/resources/testData.csv @@ -0,0 +1,5 @@ +sec,at,country,cur,time,dur,mat,cr +RESIDENTIAL_MORTGAGE,B,US,USD,2,2,2,AAA +RESIDENTIAL_MORTGAGE,B,US,USD,1,1,1,AAA +RESIDENTIAL_MORTGAGE,B,US,USD,2,2,2,AAA +RESIDENTIAL_MORTGAGE,B,US,USD,2,2,26,AAA diff --git a/rest-endpoint/build.gradle b/rest-endpoint/build.gradle index 87934c6..30b1a2a 100644 --- a/rest-endpoint/build.gradle +++ b/rest-endpoint/build.gradle @@ -27,6 +27,8 @@ dependencies { implementation "org.koin:koin-core:$koin_version" implementation "io.reactivex.rxjava3:rxjava:$rxjava3_version" + implementation 'org.apache.httpcomponents:httpmime:4.5.13' + compileOnly "net.corda:corda-core:$corda_core_release_version" compileOnly "net.corda:corda-serialization:$corda_core_release_version" diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Constants.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Constants.kt index b7e18d6..b23bee5 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Constants.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Constants.kt @@ -8,6 +8,7 @@ const val OPERATION_GET_FLOW_SNAPSHOT = "getFlowSnapshot" const val OPERATION_GET_STATE_BY_REF = "getStateByRef" const val OPERATION_QUERY_STATES = "queryStates" const val OPERATION_GET_TX_BY_HASH = "getTransactionByHash" +const val OPERATION_UPLOAD_NODE_ATTACHMENT = "uploadNodeAttachment" // constants used as Koin qualifiers for security configuration factories const val SECURITY_CONFIGURATION_NONE = "none" diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/CordaTypesSerializers.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/CordaTypesSerializers.kt index 090eb37..2007006 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/CordaTypesSerializers.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/CordaTypesSerializers.kt @@ -1,5 +1,6 @@ package tech.b180.cordaptor.rest +import io.undertow.server.handlers.form.FormData import net.corda.core.contracts.* import net.corda.core.crypto.SecureHash import net.corda.core.crypto.TransactionSignature @@ -13,18 +14,13 @@ import net.corda.core.transactions.CoreTransaction import net.corda.core.transactions.SignedTransaction import net.corda.core.transactions.WireTransaction import net.corda.core.utilities.OpaqueBytes -import net.corda.core.utilities.ProgressTracker import net.corda.core.utilities.toBase58 import net.corda.core.utilities.toSHA256Bytes -import net.corda.serialization.internal.model.LocalConstructorParameterInformation import net.corda.serialization.internal.model.LocalTypeInformation -import net.corda.serialization.internal.model.PropertyName import tech.b180.cordaptor.corda.CordaFlowInstruction +import tech.b180.cordaptor.corda.CordaNodeAttachment import tech.b180.cordaptor.corda.CordaNodeState -import tech.b180.cordaptor.shaded.javax.json.JsonNumber -import tech.b180.cordaptor.shaded.javax.json.JsonObject -import tech.b180.cordaptor.shaded.javax.json.JsonString -import tech.b180.cordaptor.shaded.javax.json.JsonValue +import tech.b180.cordaptor.shaded.javax.json.* import tech.b180.cordaptor.shaded.javax.json.stream.JsonGenerator import java.math.BigDecimal import java.math.RoundingMode @@ -100,6 +96,43 @@ class BigDecimalSerializer } } +class CordaNodeAttachmentSerializer : MultiPartFormDataSerializer { + override fun fromJson(value: JsonValue): CordaNodeAttachment { + throw UnsupportedOperationException("Don't know not to restore an untyped object from JSON") + } + + override fun toJson(obj: CordaNodeAttachment, generator: JsonGenerator) { + throw UnsupportedOperationException("Don't know not to restore an untyped object from JSON") + } + + override fun fromMultiPartFormData(data: FormData): CordaNodeAttachment { + val file = data.getFirst("data") + if (file.isFileItem && file.fileItem != null) { + return CordaNodeAttachment( + dataType = data.getFirst("dataType").value, + uploader = data.getFirst("uploader").value, + filename = file.fileItem.file.fileName.toString(), + inputStream = file.fileItem.inputStream) + } + else{ + throw SerializationException("Exception during multipart form data deserialization") + } + } + + override val valueType: SerializerKey + get() = SerializerKey.forType(CordaNodeAttachment::class.java) + + override fun generateSchema(generator: JsonSchemaGenerator): JsonObject = + Json.createObjectBuilder(). + add("type", "object"). + addObject("properties"){ + add("uploader", OpenAPI.PrimitiveTypes.NON_EMPTY_STRING). + add("dataType", OpenAPI.PrimitiveTypes.NON_EMPTY_STRING). + add("data", OpenAPI.PrimitiveTypes.BINARY_STRING) + }.build() + +} + /** * Serializes a [Currency] as a JSON string representing its ISO code. * Mainly used as part of the implementation for serializer of [Amount], but @@ -418,7 +451,7 @@ class CordaLinearPointerSerializer( ) override fun initializeInstance(values: Map): LinearPointer<*> { - val pointer = (values["pointer"] as? UniqueIdentifier) ?: throw AssertionError("Missing value in mandatory field 'pointer'"); + val pointer = (values["pointer"] as? UniqueIdentifier) ?: throw AssertionError("Missing value in mandatory field 'pointer'") return LinearPointer(pointer = pointer , type = LinearState::class.java) } } diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/GenericEndpoints.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/GenericEndpoints.kt index 83a013b..e495ad4 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/GenericEndpoints.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/GenericEndpoints.kt @@ -4,6 +4,7 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.internal.operators.single.SingleJust import io.undertow.server.HttpServerExchange +import io.undertow.server.handlers.form.FormParserFactory import io.undertow.util.* import tech.b180.cordaptor.kernel.CordaptorComponent import tech.b180.cordaptor.kernel.ModuleAPI @@ -11,9 +12,11 @@ import tech.b180.cordaptor.kernel.loggerFor import tech.b180.cordaptor.shaded.javax.json.Json import tech.b180.cordaptor.shaded.javax.json.stream.JsonParsingException import java.beans.Transient +import java.io.IOException import java.io.OutputStreamWriter import java.io.StringReader + @ModuleAPI(since = "0.1") enum class OperationErrorType(val protocolStatusCode: Int) { GENERIC_ERROR(StatusCodes.INTERNAL_SERVER_ERROR), @@ -591,27 +594,63 @@ class OperationEndpointHandler( if (tryHandlingAsQuery(exchange, subject)) { return } - // we have to use non-blocking IO to read request payload and not switch to a blocking mode // because API operations are performed asynchronously - val asyncReceiver = exchange.requestReceiver - asyncReceiver.receiveFullString({ e, s -> receiveRequestPayload(subject, e, s) }, Charsets.UTF_8) + // however, if the request is of MultiPart Form Data type then parsing is blocking in Undertow + val mimeType = exchange.requestHeaders.getFirst(Headers.CONTENT_TYPE) + if (mimeType != null && mimeType.startsWith(OpenAPI.MULTI_PART_FORM_DATA_CONTENT_TYPE)) { + receiveFormDataRequestPayload(subject, exchange) + } + else{ + val receiver = exchange.requestReceiver + receiver.receiveFullString({ e, s -> receiveJsonRequestPayload(subject, e, s) }, Charsets.UTF_8) + } + } + + private fun receiveFormDataRequestPayload(subject: Subject, exchange: HttpServerExchange) { + try { + exchange.startBlocking() + val parser = FormParserFactory.builder().build().createParser(exchange) + val formData = parser.parseBlocking() + val endpointRequest = HttpRequestWithPayload(exchange, subject, + (requestSerializer as MultiPartFormDataSerializer).fromMultiPartFormData(formData)) + executeOperationFromEndpointRequest(exchange, endpointRequest) + } catch (e: IOException) { + + logger.debug("Error parsing multipart/form-data request, which will be returned to the client", e) + sendError(exchange, EndpointErrorMessage("Malformed form data in the request payload", + cause = e, errorType = OperationErrorType.BAD_REQUEST)) + return + } catch (e: SerializationException) { + + logger.debug("Exception during payload deserialization, which will be returned to the client", e) + sendError(exchange, EndpointErrorMessage("Unable to deserialize the request payload", + cause = e, errorType = OperationErrorType.BAD_REQUEST)) + return + } catch (e: java.lang.UnsupportedOperationException) { + + logger.debug("Exception during payload deserialization, which will be returned to the client", e) + sendError(exchange, EndpointErrorMessage("Request Content-Type is Unsupported", + cause = e, errorType = OperationErrorType.BAD_REQUEST)) + return + } } - private fun receiveRequestPayload(subject: Subject, exchange: HttpServerExchange, payloadString: String) { + private fun receiveJsonRequestPayload(subject: Subject, exchange: HttpServerExchange, payloadString: String) { if (payloadString.isEmpty()) { sendError(exchange, EndpointErrorMessage( "Empty request payload", errorType = OperationErrorType.BAD_REQUEST)) return } - - val endpointRequest = try { - - val requestJsonPayload = Json.createReader(StringReader(payloadString)).readObject() - val requestPayload = requestSerializer.fromJson(requestJsonPayload) - - HttpRequestWithPayload(exchange, subject, requestPayload) - + try { + val requestJsonPayload = Json.createReader( + StringReader( + payloadString + ) + ).readObject() + val endpointRequest = HttpRequestWithPayload(exchange, subject, requestSerializer.fromJson(requestJsonPayload)) + logger.debug("Invoking operation with request: {}", endpointRequest) + executeOperationFromEndpointRequest(exchange, endpointRequest) } catch (e: JsonParsingException) { logger.debug("JSON parsing exception, which will be returned to the client", e) @@ -624,10 +663,17 @@ class OperationEndpointHandler( sendError(exchange, EndpointErrorMessage("Unable to deserialize the request payload", cause = e, errorType = OperationErrorType.BAD_REQUEST)) return + } catch (e: java.lang.UnsupportedOperationException) { + + logger.debug("Exception during payload deserialization, which will be returned to the client", e) + sendError(exchange, EndpointErrorMessage("Request Content-Type is Unsupported", + cause = e, errorType = OperationErrorType.BAD_REQUEST)) + return } + } - // invoke operation in the worker thread - logger.debug("Invoking operation with request: {}", endpointRequest) + private fun executeOperationFromEndpointRequest(exchange: HttpServerExchange, + endpointRequest: HttpRequestWithPayload){ val endpointResponse = endpoint.executeOperation(endpointRequest) @@ -652,7 +698,7 @@ class OperationEndpointHandler( sendResponse(exchange, response) } else if (error != null) { sendError(exchange, (error as? EndpointOperationException)?.toErrorMessage() - ?: EndpointErrorMessage("Unexpected internal error", error)) + ?: EndpointErrorMessage("Unexpected internal error", error)) } } }) @@ -673,4 +719,5 @@ class OperationEndpointHandler( return "OperationEndpointHandler(requestType=${endpoint.requestType}, " + "responseType=${endpoint.responseType}, endpoint=${endpoint})" } + } diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/KoinModule.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/KoinModule.kt index cab12a5..cb79f91 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/KoinModule.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/KoinModule.kt @@ -90,6 +90,7 @@ class RestEndpointModuleProvider : ModuleProvider { single { JsonObjectSerializer() } bind CustomSerializer::class single { CordaOpaqueBytesSerializer() } bind CustomSerializer::class single { JavaDurationSerializer() } bind CustomSerializer::class + single { CordaNodeAttachmentSerializer() } bind CustomSerializer::class single { CordaFlowInstructionSerializerFactory(get()) } bind CustomSerializerFactory::class single { CordaAmountSerializerFactory(get()) } bind CustomSerializerFactory::class diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/NodeStateEndpoints.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/NodeStateEndpoints.kt index fd454ab..38d87f8 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/NodeStateEndpoints.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/NodeStateEndpoints.kt @@ -24,6 +24,7 @@ import kotlin.reflect.KClass const val FLOW_INITIATION_TAG = "flowInitiation" const val VAULT_QUERY_TAG = "vaultQuery" +const val NODE_ATTACHMENT_TAG = "nodeAttachment" /** * Factory class for specific Jetty handlers created for flows and contract states of CorDapps found on the node. @@ -42,14 +43,25 @@ class NodeStateAPIProvider(contextPath: String) : EndpointProvider, CordaptorCom init { val flowSnapshotsEnabled = settings.isFlowSnapshotsEndpointEnabled + val nodeAttachmentEndpointEnabled = settings.isNodeAttachmentEndpointEnabled if (!flowSnapshotsEnabled) { logger.info("Flow snapshots endpoint is disabled. Flow initiation operations will never return a Location header") } + if (!nodeAttachmentEndpointEnabled) { + logger.info("Node Attachment endpoint is disabled.") + } + operationEndpoints = mutableListOf() queryEndpoints = mutableListOf() for (cordapp in nodeCatalog.cordapps) { + + if(nodeAttachmentEndpointEnabled){ + val handlerPath = "$contextPath/uploadNodeAttachment" + operationEndpoints.add(NodeAttachmentEndpoint(handlerPath)) + } + for (flowInfo in cordapp.flows) { val handlerPath = "$contextPath/${cordapp.shortName}/${flowInfo.flowClass.simpleName}" @@ -228,6 +240,59 @@ class FlowInitiationEndpoint( ) } +/** + * API endpoint handler allowing to upload attachment on corda node. + */ +class NodeAttachmentEndpoint( + contextPath: String +) : OperationEndpoint, CordaptorComponent, + ContextMappedResourceEndpoint(contextPath, true) { + + companion object { + private val logger = loggerFor() + } + + private val cordaNodeState: CordaNodeState by inject() + + override val responseType = SerializerKey(SecureHash::class) + override val requestType = SerializerKey(CordaNodeAttachment::class) + override val supportedMethods = OperationEndpoint.POST_ONLY + + override fun executeOperation( + request: RequestWithPayload + ): Single> { + if (!request.subject.isPermitted(OPERATION_UPLOAD_NODE_ATTACHMENT)) { + throw UnauthorizedOperationException(OPERATION_UPLOAD_NODE_ATTACHMENT) + } + + val attachmentInstruction = request.payload + logger.debug("Attachment instruction {}", attachmentInstruction) + + val handle = cordaNodeState.createAttachment(attachment = attachmentInstruction) + return Single.just(Response(handle, StatusCodes.OK, emptyList())) + } + + override fun generatePathInfoSpecification(schemaGenerator: JsonSchemaGenerator): OpenAPI.PathItem { + return OpenAPI.PathItem( + post = OpenAPI.Operation( + summary = "Uploads Corda attachment with given parameters", + operationId = "uploadNodeAttachment" + ).withRequestBody( + OpenAPI.RequestBody.createMultiPartFormDataRequest( + schemaGenerator.generateSchema(requestType), + required = true + ) + ).withResponse( + OpenAPI.HttpStatusCode.OK, + OpenAPI.Response.createJsonResponse( + description = "Attachment uploaded successfully and its result is available", + schema = schemaGenerator.generateSchema(responseType) + ) + ).withForbiddenResponse().withTags(NODE_ATTACHMENT_TAG) + ) + } +} + /** * API endpoint handler allowing to retrieve latest snapshot of a flow by its run id. * This call will fail to instantiate if no module implementing flow results cache is present. diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/OpenAPI.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/OpenAPI.kt index 5c49297..8da30ba 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/OpenAPI.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/OpenAPI.kt @@ -33,11 +33,10 @@ data class OpenAPI( val openapi = VERSION companion object { - const val VERSION = "3.0.3" - - const val JSON_CONTENT_TYPE: ContentType = "application/json" - - const val COMPONENTS_SCHEMA_PREFIX = "#/components/schemas/" + const val VERSION = "3.0.3" + const val JSON_CONTENT_TYPE: ContentType = "application/json" + const val MULTI_PART_FORM_DATA_CONTENT_TYPE: ContentType = "multipart/form-data" + const val COMPONENTS_SCHEMA_PREFIX = "#/components/schemas/" } /** [https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#infoObject] */ @@ -192,8 +191,11 @@ data class OpenAPI( val description: String? = null ) { companion object { - fun createJsonRequest(schema: JsonObject, required: Boolean) = + fun createJsonRequest(schema: JsonObject, required: Boolean) = RequestBody(content = sortedMapOf(JSON_CONTENT_TYPE to MediaType(schema)), required = required) + + fun createMultiPartFormDataRequest(schema: JsonObject, required: Boolean) = + RequestBody(content = sortedMapOf(MULTI_PART_FORM_DATA_CONTENT_TYPE to MediaType(schema)), required = required) } } @@ -275,5 +277,16 @@ data class OpenAPI( .add("type", "string") .add("format", "url") .build() + + val BINARY_STRING: JsonObject = Json.createObjectBuilder() + .add("type", "string") + .add("format", "binary") + .build() + + val BASE64_STRING: JsonObject = Json.createObjectBuilder() + .add("type", "string") + .add("format", "base64") + .build() + } } diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/SerializationFactory.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/SerializationFactory.kt index 44a17c6..ff85711 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/SerializationFactory.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/SerializationFactory.kt @@ -1,5 +1,6 @@ package tech.b180.cordaptor.rest +import io.undertow.server.handlers.form.FormData import net.corda.core.contracts.TransactionState import net.corda.serialization.internal.AllWhitelist import net.corda.serialization.internal.amqp.CachingCustomSerializerRegistry @@ -554,6 +555,10 @@ interface JsonSchemaGenerator { @ModuleAPI(since = "0.1") interface CustomSerializer : JsonSerializer +interface MultiPartFormDataSerializer: CustomSerializer{ + fun fromMultiPartFormData(data: FormData) : T +} + /** * Alternative to [CustomSerializer] when custom serializers need to be created for * parameterized types where parameters are determined at runtime and the schema diff --git a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Settings.kt b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Settings.kt index 4d4c4c8..aaf8162 100644 --- a/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Settings.kt +++ b/rest-endpoint/src/main/kotlin/tech/b180/cordaptor/rest/Settings.kt @@ -25,14 +25,16 @@ data class Settings( val isSwaggerUIEnabled: Boolean, val isFlowSnapshotsEndpointEnabled: Boolean, val maxFlowInitiationTimeout: Duration, - val maxVaultQueryPageSize: Int + val maxVaultQueryPageSize: Int, + val isNodeAttachmentEndpointEnabled: Boolean ) { constructor(ourConfig: Config) : this( isOpenAPISpecificationEnabled = ourConfig.getBoolean("spec.enabled"), isSwaggerUIEnabled = ourConfig.getBoolean("swaggerUI.enabled"), isFlowSnapshotsEndpointEnabled = ourConfig.getBoolean("flowSnapshots.enabled"), maxFlowInitiationTimeout = ourConfig.getDuration("flowInitiation.maxTimeout"), - maxVaultQueryPageSize = ourConfig.getInt("vaultQueries.maxPageSize") + maxVaultQueryPageSize = ourConfig.getInt("vaultQueries.maxPageSize"), + isNodeAttachmentEndpointEnabled = ourConfig.getBoolean("nodeAttachment.enabled") ) } diff --git a/rest-endpoint/src/main/resources/module-reference.conf b/rest-endpoint/src/main/resources/module-reference.conf index b86e83e..62256f8 100644 --- a/rest-endpoint/src/main/resources/module-reference.conf +++ b/rest-endpoint/src/main/resources/module-reference.conf @@ -104,4 +104,8 @@ openAPI { # Absolute maximum timeout for the request to avoid wasting server resources maxTimeout = 10m } + + nodeAttachment { + enabled = true + } } \ No newline at end of file diff --git a/rest-endpoint/src/test/kotlin/tech/b180/cordaptor/rest/CordaTypesTest.kt b/rest-endpoint/src/test/kotlin/tech/b180/cordaptor/rest/CordaTypesTest.kt index bed173a..8b3656b 100644 --- a/rest-endpoint/src/test/kotlin/tech/b180/cordaptor/rest/CordaTypesTest.kt +++ b/rest-endpoint/src/test/kotlin/tech/b180/cordaptor/rest/CordaTypesTest.kt @@ -2,6 +2,7 @@ package tech.b180.cordaptor.rest import io.mockk.every import io.mockk.mockkClass +import io.undertow.server.handlers.form.FormData import net.corda.core.contracts.Amount import net.corda.core.contracts.LinearPointer import net.corda.core.contracts.LinearState @@ -16,24 +17,24 @@ import net.corda.core.utilities.toBase58 import net.corda.core.utilities.toSHA256Bytes import net.corda.finance.flows.AbstractCashFlow import net.corda.testing.core.TestIdentity +import org.apache.commons.io.IOUtils import org.junit.Rule import org.koin.dsl.bind import org.koin.dsl.module import org.koin.test.KoinTest import org.koin.test.KoinTestRule -import tech.b180.cordaptor.corda.CordaFlowInstruction -import tech.b180.cordaptor.corda.CordaFlowProgress -import tech.b180.cordaptor.corda.CordaFlowSnapshot -import tech.b180.cordaptor.corda.CordaNodeState +import tech.b180.cordaptor.corda.* import tech.b180.cordaptor.kernel.lazyGetAll import java.math.BigDecimal import java.math.RoundingMode +import java.nio.file.Paths import java.time.Duration import java.time.Instant import java.util.* import kotlin.reflect.full.allSuperclasses import kotlin.test.* + class CordaTypesTest : KoinTest { companion object { @@ -44,6 +45,7 @@ class CordaTypesTest : KoinTest { // register custom serializers for the factory to discover single { BigDecimalSerializer() } bind CustomSerializer::class + single { CordaNodeAttachmentSerializer() } bind CustomSerializer::class single { CurrencySerializer() } bind CustomSerializer::class single { CordaUUIDSerializer() } bind CustomSerializer::class single { CordaSecureHashSerializer() } bind CustomSerializer::class @@ -245,15 +247,41 @@ class CordaTypesTest : KoinTest { @Test fun `test corda linear pointer serialization`() { - val serializer = getKoin().getSerializer(LinearPointer::class, SimpleLinearState::class); - val uuid = UniqueIdentifier(); - assertEquals("""{ - |"pointer": {"id": "$uuid"}, - |"type":"tech.b180.cordaptor.rest.SimpleLinearState"}""".trimMargin().asJsonValue(), - serializer.toJsonString(LinearPointer(pointer = uuid, type= SimpleLinearState::class.java)).asJsonValue()) - - assertEquals(LinearPointer(pointer = uuid, type = SimpleLinearState::class.java), - serializer.fromJson("""{"pointer": {"id": "$uuid"}, "type":"tech.b180.cordaptor.rest.SimpleLinearState"}""".asJsonObject())) + val serializer = getKoin().getSerializer(LinearPointer::class, SimpleLinearState::class) + val uuid = UniqueIdentifier() + assertEquals("""{ + |"pointer": {"id": "$uuid"}, + |"type":"tech.b180.cordaptor.rest.SimpleLinearState"}""".trimMargin().asJsonValue(), + serializer.toJsonString(LinearPointer(pointer = uuid, type= SimpleLinearState::class.java)).asJsonValue()) + + assertEquals(LinearPointer(pointer = uuid, type = SimpleLinearState::class.java), + serializer.fromJson("""{"pointer": {"id": "$uuid"}, "type":"tech.b180.cordaptor.rest.SimpleLinearState"}""".asJsonObject())) + } + + @Test + fun `test corda node attachment serialization`() { + val testInputStream = CordaTypesTest::class.java.classLoader.getResourceAsStream("testData.csv") + val testPath = Paths.get(CordaTypesTest::class.java.classLoader.getResource("testData.csv").toURI()) + val serializer = getKoin().getSerializer(CordaNodeAttachment::class) as MultiPartFormDataSerializer + + + val formData = FormData(4) + formData.add("dataType", "testDataType") + formData.add("filename", "testData.csv") + formData.add("uploader", "User") + formData.add("data", testPath, "testData.csv", null) + + val expectedCordaNodeAttachment = CordaNodeAttachment( + inputStream = testInputStream, + uploader= "User", + filename= "testData.csv", + dataType = "testDataType") + + val serializerOutputCordaNodeAttachment = serializer.fromMultiPartFormData(formData) + + assertEquals(true, + IOUtils.contentEquals(expectedCordaNodeAttachment.inputStream, + serializerOutputCordaNodeAttachment.inputStream)) } @Test diff --git a/rest-endpoint/src/test/resources/testData.csv b/rest-endpoint/src/test/resources/testData.csv new file mode 100644 index 0000000..36379ad --- /dev/null +++ b/rest-endpoint/src/test/resources/testData.csv @@ -0,0 +1,5 @@ +sec,at,country,cur,time,dur,mat,cr,demand_amount,held_value,portfolio,fund_name,pm_name,originator_name,client_name +RESIDENTIAL_MORTGAGE,B,US,USD,2,2,2,AAA,1562700.62,1562700618,P003,Global Opportunities 3,Sue Gamber,Alex Denton,Pension Co +RESIDENTIAL_MORTGAGE,B,US,USD,1,1,1,AAA,797469.18,797469178.3,P003,Global Opportunities 3,Sue Gamber,Alex Denton,Pension Co +RESIDENTIAL_MORTGAGE,B,US,USD,2,2,2,AAA,731693.05,731693046.2,P003,Global Opportunities 3,Sue Gamber,Alex Denton,Pension Co +RESIDENTIAL_MORTGAGE,B,US,USD,2,2,26,AAA,632034.14,632034140.7,P003,Global Opportunities 3,Sue Gamber,Alex Denton,Pension Co