From c310a7c5e8f178250be14e3d27d3fceb8f9a3edb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Feb 2023 13:41:52 +0000 Subject: [PATCH] Added "X-API-VERSION" header support in POST /transactions/process. Default is version "1". If version "2" is specified, the API will return the full transaction JSON on success, rather than just "true". Example usage: curl -X POST "http://localhost:12391/transactions/process" -H "X-API-VERSION: 2" -d "signedTransactionBytesHere" --- src/main/java/org/qortal/api/ApiRequest.java | 37 +++++++++++++++++-- src/main/java/org/qortal/api/ApiService.java | 18 +++++++++ .../api/resource/TransactionsResource.java | 35 ++++++++++++------ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/qortal/api/ApiRequest.java b/src/main/java/org/qortal/api/ApiRequest.java index 5517ff53..b9fbf1dc 100644 --- a/src/main/java/org/qortal/api/ApiRequest.java +++ b/src/main/java/org/qortal/api/ApiRequest.java @@ -3,6 +3,7 @@ package org.qortal.api; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; +import java.io.Writer; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.Socket; @@ -20,14 +21,12 @@ import javax.net.ssl.SNIHostName; import javax.net.ssl.SNIServerName; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; -import javax.xml.bind.JAXBContext; -import javax.xml.bind.JAXBException; -import javax.xml.bind.UnmarshalException; -import javax.xml.bind.Unmarshaller; +import javax.xml.bind.*; import javax.xml.transform.stream.StreamSource; import org.eclipse.persistence.exceptions.XMLMarshalException; import org.eclipse.persistence.jaxb.JAXBContextFactory; +import org.eclipse.persistence.jaxb.MarshallerProperties; import org.eclipse.persistence.jaxb.UnmarshallerProperties; public class ApiRequest { @@ -107,6 +106,36 @@ public class ApiRequest { } } + private static Marshaller createMarshaller(Class objectClass) { + try { + // Create JAXB context aware of object's class + JAXBContext jc = JAXBContextFactory.createContext(new Class[] { objectClass }, null); + + // Create marshaller + Marshaller marshaller = jc.createMarshaller(); + + // Set the marshaller media type to JSON + marshaller.setProperty(MarshallerProperties.MEDIA_TYPE, "application/json"); + + // Tell marshaller not to include JSON root element in the output + marshaller.setProperty(MarshallerProperties.JSON_INCLUDE_ROOT, false); + + return marshaller; + } catch (JAXBException e) { + throw new RuntimeException("Unable to create websocket marshaller", e); + } + } + + public static void marshall(Writer writer, Object object) throws IOException { + Marshaller marshaller = createMarshaller(object.getClass()); + + try { + marshaller.marshal(object, writer); + } catch (JAXBException e) { + throw new IOException("Unable to create marshall object for websocket", e); + } + } + public static String getParamsString(Map params) { StringBuilder result = new StringBuilder(); diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 4676fa49..79bfd216 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -13,6 +13,7 @@ import java.security.SecureRandom; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import javax.servlet.http.HttpServletRequest; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; @@ -50,6 +51,8 @@ public class ApiService { private Server server; private ApiKey apiKey; + public static final String API_VERSION_HEADER = "X-API-VERSION"; + private ApiService() { this.config = new ResourceConfig(); this.config.packages("org.qortal.api.resource", "org.qortal.api.restricted.resource"); @@ -229,4 +232,19 @@ public class ApiService { this.server = null; } + public static int getApiVersion(HttpServletRequest request) { + // Get API version + String apiVersionString = request.getHeader(API_VERSION_HEADER); + if (apiVersionString == null) { + // Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141 + apiVersionString = request.getParameter("apiVersion"); + } + + int apiVersion = 1; + if (apiVersionString != null) { + apiVersion = Integer.parseInt(apiVersionString); + } + return apiVersion; + } + } diff --git a/src/main/java/org/qortal/api/resource/TransactionsResource.java b/src/main/java/org/qortal/api/resource/TransactionsResource.java index 2b9b28a1..1311c4ad 100644 --- a/src/main/java/org/qortal/api/resource/TransactionsResource.java +++ b/src/main/java/org/qortal/api/resource/TransactionsResource.java @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import java.io.StringWriter; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -18,19 +20,12 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.QueryParam; +import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import org.qortal.account.PrivateKeyAccount; -import org.qortal.api.ApiError; -import org.qortal.api.ApiErrors; -import org.qortal.api.ApiException; -import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.*; import org.qortal.api.model.SimpleTransactionSignRequest; import org.qortal.controller.Controller; import org.qortal.controller.LiteNode; @@ -709,7 +704,7 @@ public class TransactionsResource { ), responses = { @ApiResponse( - description = "true if accepted, false otherwise", + description = "For API version 1, this returns true if accepted.\nFor API version 2, the transactionData is returned as a JSON string if accepted.", content = @Content( mediaType = MediaType.TEXT_PLAIN, schema = @Schema( @@ -722,7 +717,9 @@ public class TransactionsResource { @ApiErrors({ ApiError.BLOCKCHAIN_NEEDS_SYNC, ApiError.INVALID_SIGNATURE, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE }) - public String processTransaction(String rawBytes58) { + public String processTransaction(String rawBytes58, @HeaderParam(ApiService.API_VERSION_HEADER) String apiVersionHeader) { + int apiVersion = ApiService.getApiVersion(request); + // Only allow a transaction to be processed if our latest block is less than 60 minutes old // If older than this, we should first wait until the blockchain is synced final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); @@ -759,13 +756,27 @@ public class TransactionsResource { blockchainLock.unlock(); } - return "true"; + switch (apiVersion) { + case 1: + return "true"; + + case 2: + default: + // Marshall transactionData to string + StringWriter stringWriter = new StringWriter(); + ApiRequest.marshall(stringWriter, transactionData); + return stringWriter.toString(); + } + + } catch (NumberFormatException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } catch (InterruptedException e) { throw createTransactionInvalidException(request, ValidationResult.NO_BLOCKCHAIN_LOCK); + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); } }