From ad9fa9bf9d5dd96269bfd801ad20cea608d9e87f Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 4 Dec 2018 16:34:55 +0000 Subject: [PATCH] More work on API plus basic block explorer Added FATJAR packaging support to pom.xml Added some "summary" fields to API calls but more need doing. Corrected path clash from having unnecessary @OpenAPIDefinition annotations. Added API "tags" to group similar calls (address-based, block-related, etc.) Fixed addresses/lastreference/{address} Implemented addresses/lastreference/{address}/unconfirmed Implemented addresses/assets/{address} Added /admin/stop and /admin/uptime API calls. Moved general API info into new src/api/ApiDefinition.java Added CORS support to ApiService Added /transactions/address/{address} and /transactions/block/{signature} Replaced references to test.Common.* to do with repository factory. This fixes issues with building FATJAR due to references to test classes that are omitted from FATJAR. Changes to AccountBalanceData, BlockData and TransactionData to support JAX-RS rendering to JSON. Added getUnconfirmedLastReference() to Account. Added getAllBalances(address) to account repository - returns all asset balances for that address. Added getAllSignaturesInvolvingAddress(address) to account repository but currently only uses TransactionRecipients HSQLDB table. (And even that wasn't automatically populated). Included: very basic block explorer to be opened in browser as a file: block-explorer.html --- .settings/org.eclipse.core.resources.prefs | 4 + block-explorer.html | 789 ++++++++++++++++++ pom.xml | 39 +- src/api/AddressesResource.java | 87 +- src/api/AdminResource.java | 114 +++ src/api/AnnotationPostProcessor.java | 1 - src/api/ApiDefinition.java | 25 + src/api/ApiError.java | 3 +- src/api/ApiErrorFactory.java | 8 +- src/api/ApiService.java | 42 +- src/api/BlocksResource.java | 40 +- src/api/Security.java | 4 +- src/api/TransactionsResource.java | 160 ++++ src/blockgenerator.java | 9 +- src/data/account/AccountBalanceData.java | 16 +- src/data/block/BlockData.java | 9 +- src/data/transaction/TransactionData.java | 5 + src/database/NoDataFoundException.java | 1 + src/orphan.java | 9 +- src/qora/account/Account.java | 29 + src/repository/AccountRepository.java | 4 + src/repository/TransactionRepository.java | 2 + .../hsqldb/HSQLDBAccountRepository.java | 23 + .../HSQLDBTransactionRepository.java | 21 + src/txhex.java | 9 +- src/v1feeder.java | 8 +- 26 files changed, 1377 insertions(+), 84 deletions(-) create mode 100644 .settings/org.eclipse.core.resources.prefs create mode 100644 block-explorer.html create mode 100644 src/api/AdminResource.java create mode 100644 src/api/ApiDefinition.java create mode 100644 src/api/TransactionsResource.java diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 00000000..165066bb --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 +encoding/src=UTF-8 +encoding/tests=UTF-8 diff --git a/block-explorer.html b/block-explorer.html new file mode 100644 index 00000000..4e724fb4 --- /dev/null +++ b/block-explorer.html @@ -0,0 +1,789 @@ + + + + Block Explorer + + + + + Making initial call to API... +

+ If nothing happens then check API is running! + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 4b5d0394..4ce392db 100644 --- a/pom.xml +++ b/pom.xml @@ -5,6 +5,7 @@ qora-core 2.0.0-SNAPSHOT + UTF-8 3.19.0 @@ -19,6 +20,36 @@ 1.8 + + org.apache.maven.plugins + maven-assembly-plugin + + + package + + single + + + + + + Start + + true + + + . .. + + + + jar-with-dependencies + + Qora + false + + + + maven-dependency-plugin @@ -42,7 +73,7 @@ - + com.google.code.maven-replacer-plugin replacer @@ -154,6 +185,12 @@ 9.4.11.v20180605 jar + + + org.eclipse.jetty + jetty-servlets + 9.4.11.v20180605 + org.glassfish.jersey.inject jersey-hk2 diff --git a/src/api/AddressesResource.java b/src/api/AddressesResource.java index df4570b8..dad51a40 100644 --- a/src/api/AddressesResource.java +++ b/src/api/AddressesResource.java @@ -1,17 +1,19 @@ package api; -import data.account.AccountData; -import data.block.BlockData; import globalization.Translator; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + import java.math.BigDecimal; +import java.util.List; + import javax.servlet.http.HttpServletRequest; import javax.ws.rs.GET; import javax.ws.rs.Path; @@ -19,6 +21,8 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; + +import data.account.AccountBalanceData; import qora.account.Account; import qora.assets.Asset; import qora.crypto.Crypto; @@ -28,11 +32,11 @@ import utils.Base58; @Path("addresses") @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) -@OpenAPIDefinition( - extensions = @Extension(name = "translation", properties = { +@Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="/Api/AddressesResource") - }) + } ) +@Tag(name = "addresses") public class AddressesResource { @Context @@ -51,7 +55,8 @@ public class AddressesResource { @GET @Path("/lastreference/{address}") @Operation( - description = "Returns the 64-byte long base58-encoded signature of last transaction where the address is delivered as creator. Or the first incoming transaction. Returns \"false\" if there is no transactions.", + summary = "Fetch reference for next transaction to be created by address", + description = "Returns the 64-byte long base58-encoded signature of last transaction created by address, failing that: the first incoming transaction to address. Returns \"false\" if there is no transactions.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET lastreference:address"), @@ -82,16 +87,15 @@ public class AddressesResource { throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); byte[] lastReference = null; - try (final Repository repository = RepositoryManager.getRepository()) { + try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); - account.getLastReference(); - + lastReference = account.getLastReference(); } catch (ApiException e) { throw e; } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); - } - + throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); + } + if(lastReference == null || lastReference.length == 0) { return "false"; } else { @@ -102,7 +106,8 @@ public class AddressesResource { @GET @Path("/lastreference/{address}/unconfirmed") @Operation( - description = "Returns the 64-byte long base58-encoded signature of last transaction including unconfirmed where the address is delivered as creator. Or the first incoming transaction. Returns \\\"false\\\" if there is no transactions.", + summary = "Fetch reference for next transaction to be created by address, considering unconfirmed transactions", + description = "Returns the 64-byte long base58-encoded signature of last transaction, including unconfirmed, created by address, failing that: the first incoming transaction. Returns \\\"false\\\" if there is no transactions.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET lastreference:address:unconfirmed"), @@ -127,19 +132,36 @@ public class AddressesResource { public String getLastReferenceUnconfirmed(@PathParam("address") String address) { Security.checkApiCallAllowed("GET addresses/lastreference", request); - // XXX: is this method needed? - - throw new UnsupportedOperationException(); + if (!Crypto.isValidAddress(address)) + throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); + + byte[] lastReference = null; + try (final Repository repository = RepositoryManager.getRepository()) { + Account account = new Account(repository, address); + lastReference = account.getUnconfirmedLastReference(); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); + } + + if(lastReference == null || lastReference.length == 0) { + return "false"; + } else { + return Base58.encode(lastReference); + } } @GET @Path("/validate/{address}") @Operation( - description = "Validates the given address. Returns true/false.", + summary = "Validates the given address", + description = "Returns true/false.", extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET validate:address"), - @ExtensionProperty(name="description.key", value="operation:description") + @ExtensionProperty(name="summary.key", value="operation:summary"), + @ExtensionProperty(name="description.key", value="operation:description"), }) }, responses = { @@ -203,7 +225,7 @@ public class AddressesResource { } @GET - @Path("balance/{address}") + @Path("/balance/{address}") @Operation( description = "Returns the confirmed balance of the given address.", extensions = { @@ -218,7 +240,7 @@ public class AddressesResource { responses = { @ApiResponse( description = "the balance", - content = @Content(schema = @Schema(implementation = BigDecimal.class)), + content = @Content(schema = @Schema(name = "balance", type = "number")), extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="description.key", value="success_response:description") @@ -236,7 +258,6 @@ public class AddressesResource { try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); return account.getConfirmedBalance(Asset.QORA); - } catch (ApiException e) { throw e; } catch (Exception e) { @@ -245,7 +266,7 @@ public class AddressesResource { } @GET - @Path("assetbalance/{assetid}/{address}") + @Path("/assetbalance/{assetid}/{address}") @Operation( description = "Returns the confirmed balance of the given address for the given asset key.", extensions = { @@ -278,7 +299,6 @@ public class AddressesResource { try (final Repository repository = RepositoryManager.getRepository()) { Account account = new Account(repository, address); return account.getConfirmedBalance(assetid); - } catch (ApiException e) { throw e; } catch (Exception e) { @@ -287,7 +307,7 @@ public class AddressesResource { } @GET - @Path("assets/{address}") + @Path("/assets/{address}") @Operation( description = "Returns the list of assets for this address with balances.", extensions = { @@ -302,7 +322,7 @@ public class AddressesResource { responses = { @ApiResponse( description = "the list of assets", - content = @Content(schema = @Schema(implementation = String.class)), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = AccountBalanceData.class))), extensions = { @Extension(name = "translation", properties = { @ExtensionProperty(name="description.key", value="success_response:description") @@ -311,14 +331,23 @@ public class AddressesResource { ) } ) - public String getAssetBalance(@PathParam("address") String address) { + public List getAssets(@PathParam("address") String address) { Security.checkApiCallAllowed("GET addresses/assets", request); - throw new UnsupportedOperationException(); + if (!Crypto.isValidAddress(address)) + throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getAccountRepository().getAllBalances(address); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); + } } @GET - @Path("balance/{address}/{confirmations}") + @Path("/balance/{address}/{confirmations}") @Operation( description = "Calculates the balance of the given address after the given confirmations.", extensions = { diff --git a/src/api/AdminResource.java b/src/api/AdminResource.java new file mode 100644 index 00000000..6b904ff5 --- /dev/null +++ b/src/api/AdminResource.java @@ -0,0 +1,114 @@ +package api; + +import globalization.Translator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import repository.DataException; +import repository.RepositoryManager; + +@Path("admin") +@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) +@Extension(name = "translation", properties = { + @ExtensionProperty(name="path", value="/Api/AdminResource") + } +) +@Tag(name = "admin") +public class AdminResource { + + @Context + HttpServletRequest request; + + private static final long startTime = System.currentTimeMillis(); + + private ApiErrorFactory apiErrorFactory; + + public AdminResource() { + this(new ApiErrorFactory(Translator.getInstance())); + } + + public AdminResource(ApiErrorFactory apiErrorFactory) { + this.apiErrorFactory = apiErrorFactory; + } + + @GET + @Path("/uptime") + @Operation( + summary = "Fetch running time of server", + description = "Returns uptime in milliseconds", + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="operation:description") + }) + }, + responses = { + @ApiResponse( + description = "uptime in milliseconds", + content = @Content(schema = @Schema(implementation = String.class)), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public String uptime() { + Security.checkApiCallAllowed("GET admin/uptime", request); + + return Long.toString(System.currentTimeMillis() - startTime); + } + + @GET + @Path("/stop") + @Operation( + summary = "Shutdown", + description = "Shutdown", + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="operation:description") + }) + }, + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(schema = @Schema(implementation = String.class)), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public String shutdown() { + Security.checkApiCallAllowed("GET admin/stop", request); + + try { + RepositoryManager.closeRepositoryFactory(); + } catch (DataException e) { + e.printStackTrace(); + } + + new Thread(new Runnable() { + @Override + public void run() { + ApiService.getInstance().stop(); + } + }).start(); + + return "true"; + } + +} diff --git a/src/api/AnnotationPostProcessor.java b/src/api/AnnotationPostProcessor.java index 224c7a8e..f34413fe 100644 --- a/src/api/AnnotationPostProcessor.java +++ b/src/api/AnnotationPostProcessor.java @@ -1,4 +1,3 @@ - package api; import com.fasterxml.jackson.databind.node.ArrayNode; diff --git a/src/api/ApiDefinition.java b/src/api/ApiDefinition.java new file mode 100644 index 00000000..46e3bd1b --- /dev/null +++ b/src/api/ApiDefinition.java @@ -0,0 +1,25 @@ +package api; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.tags.Tag; + +@OpenAPIDefinition( + info = @Info( title = "Qora API", description = "NOTE: byte-arrays currently returned as Base64 but this is likely to change to Base58" ), + tags = { + @Tag(name = "addresses"), + @Tag(name = "admin"), + @Tag(name = "blocks"), + @Tag(name = "transactions") + }, + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="title.key", value="info:title") + }) + } +) +public class ApiDefinition { + +} diff --git a/src/api/ApiError.java b/src/api/ApiError.java index 3291fbdc..983e18bf 100644 --- a/src/api/ApiError.java +++ b/src/api/ApiError.java @@ -1,4 +1,3 @@ - package api; public enum ApiError { @@ -7,6 +6,8 @@ public enum ApiError { JSON(1, 400), NO_BALANCE(2, 422), NOT_YET_RELEASED(3, 422), + UNAUTHORIZED(4, 401), + REPOSITORY_ISSUE(5, 500), //VALIDATION INVALID_SIGNATURE(101, 400), diff --git a/src/api/ApiErrorFactory.java b/src/api/ApiErrorFactory.java index 32b1e40f..adfe9d31 100644 --- a/src/api/ApiErrorFactory.java +++ b/src/api/ApiErrorFactory.java @@ -34,6 +34,8 @@ public class ApiErrorFactory { this.errorMessages.put(ApiError.JSON, createErrorMessageEntry(ApiError.JSON, "failed to parse json message")); this.errorMessages.put(ApiError.NO_BALANCE, createErrorMessageEntry(ApiError.NO_BALANCE, "not enough balance")); this.errorMessages.put(ApiError.NOT_YET_RELEASED, createErrorMessageEntry(ApiError.NOT_YET_RELEASED, "that feature is not yet released")); + this.errorMessages.put(ApiError.UNAUTHORIZED, createErrorMessageEntry(ApiError.UNAUTHORIZED, "api call unauthorized")); + this.errorMessages.put(ApiError.REPOSITORY_ISSUE, createErrorMessageEntry(ApiError.REPOSITORY_ISSUE, "repository error")); //VALIDATION this.errorMessages.put(ApiError.INVALID_SIGNATURE, createErrorMessageEntry(ApiError.INVALID_SIGNATURE, "invalid signature")); @@ -68,13 +70,13 @@ public class ApiErrorFactory { this.errorMessages.put(ApiError.WALLET_ADDRESS_NO_EXISTS, createErrorMessageEntry(ApiError.WALLET_ADDRESS_NO_EXISTS, "address does not exist in wallet")); this.errorMessages.put(ApiError.WALLET_LOCKED, createErrorMessageEntry(ApiError.WALLET_LOCKED, "wallet is locked")); this.errorMessages.put(ApiError.WALLET_ALREADY_EXISTS, createErrorMessageEntry(ApiError.WALLET_ALREADY_EXISTS, "wallet already exists")); - this.errorMessages.put(ApiError.WALLET_API_CALL_FORBIDDEN_BY_USER, createErrorMessageEntry(ApiError.WALLET_API_CALL_FORBIDDEN_BY_USER, "user denied api call")); + this.errorMessages.put(ApiError.WALLET_API_CALL_FORBIDDEN_BY_USER, createErrorMessageEntry(ApiError.WALLET_API_CALL_FORBIDDEN_BY_USER, "wallet denied api call")); //BLOCK this.errorMessages.put(ApiError.BLOCK_NO_EXISTS, createErrorMessageEntry(ApiError.BLOCK_NO_EXISTS, "block does not exist")); //TRANSACTIONS - this.errorMessages.put(ApiError.TRANSACTION_NO_EXISTS, createErrorMessageEntry(ApiError.TRANSACTION_NO_EXISTS, "transactions does not exist")); + this.errorMessages.put(ApiError.TRANSACTION_NO_EXISTS, createErrorMessageEntry(ApiError.TRANSACTION_NO_EXISTS, "transaction does not exist")); this.errorMessages.put(ApiError.PUBLIC_KEY_NOT_FOUND, createErrorMessageEntry(ApiError.PUBLIC_KEY_NOT_FOUND, "public key not found")); //NAMING @@ -130,7 +132,7 @@ public class ApiErrorFactory { //MESSAGES this.errorMessages.put(ApiError.MESSAGE_FORMAT_NOT_HEX, createErrorMessageEntry(ApiError.MESSAGE_FORMAT_NOT_HEX, "the Message format is not hex - correct the text or use isTextMessage = true")); this.errorMessages.put(ApiError.MESSAGE_BLANK, createErrorMessageEntry(ApiError.MESSAGE_BLANK, "The message attribute is missing or content is blank")); - this.errorMessages.put(ApiError.NO_PUBLIC_KEY, createErrorMessageEntry(ApiError.NO_PUBLIC_KEY, "The recipient has not yet performed any action in the blockchain.\nYou can't send an encrypted message to him.")); + this.errorMessages.put(ApiError.NO_PUBLIC_KEY, createErrorMessageEntry(ApiError.NO_PUBLIC_KEY, "The recipient has not yet performed any action in the blockchain.\nYou can't send an encrypted message to them.")); this.errorMessages.put(ApiError.MESSAGESIZE_EXCEEDED, createErrorMessageEntry(ApiError.MESSAGESIZE_EXCEEDED, "Message size exceeded!")); } diff --git a/src/api/ApiService.java b/src/api/ApiService.java index d5eae3e3..c9e521b6 100644 --- a/src/api/ApiService.java +++ b/src/api/ApiService.java @@ -1,6 +1,7 @@ package api; import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; + import java.io.File; import java.util.HashSet; import java.util.Set; @@ -10,8 +11,10 @@ import org.eclipse.jetty.rewrite.handler.RewriteHandler; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.InetAccessHandler; import org.eclipse.jetty.servlet.DefaultServlet; +import org.eclipse.jetty.servlet.FilterHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.servlets.CrossOriginFilter; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.servlet.ServletContainer; @@ -21,16 +24,19 @@ public class ApiService { private final Server server; private final Set> resources; - + public ApiService() { // resources to register this.resources = new HashSet>(); this.resources.add(AddressesResource.class); + this.resources.add(AdminResource.class); this.resources.add(BlocksResource.class); + this.resources.add(TransactionsResource.class); this.resources.add(OpenApiResource.class); // swagger + this.resources.add(ApiDefinition.class); // for API definition this.resources.add(AnnotationPostProcessor.class); // for API resource annotations ResourceConfig config = new ResourceConfig(this.resources); - + // create RPC server this.server = new Server(Settings.getInstance().getRpcPort()); @@ -49,25 +55,31 @@ public class ApiService { ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); context.setContextPath("/"); rewriteHandler.setHandler(context); - + + FilterHolder filterHolder = new FilterHolder(CrossOriginFilter.class); + filterHolder.setInitParameter("allowedOrigins", "*"); + filterHolder.setInitParameter("allowedMethods", "GET, POST"); + context.addFilter(filterHolder, "/*", null); + // API servlet ServletContainer container = new ServletContainer(config); ServletHolder apiServlet = new ServletHolder(container); apiServlet.setInitOrder(1); context.addServlet(apiServlet, "/*"); - + // Swagger-UI static content ClassLoader loader = this.getClass().getClassLoader(); File swaggerUIResourceLocation = new File(loader.getResource("resources/swagger-ui/").getFile()); - ServletHolder swaggerUIServlet = new ServletHolder("static-swagger-ui", DefaultServlet.class); - swaggerUIServlet.setInitParameter("resourceBase", swaggerUIResourceLocation.getAbsolutePath()); - swaggerUIServlet.setInitParameter("dirAllowed","true"); - swaggerUIServlet.setInitParameter("pathInfoOnly","true"); - context.addServlet(swaggerUIServlet,"/api-documentation/*"); + ServletHolder swaggerUIServlet = new ServletHolder("static-swagger-ui", DefaultServlet.class); + swaggerUIServlet.setInitParameter("resourceBase", swaggerUIResourceLocation.getAbsolutePath()); + swaggerUIServlet.setInitParameter("dirAllowed", "true"); + swaggerUIServlet.setInitParameter("pathInfoOnly", "true"); + context.addServlet(swaggerUIServlet, "/api-documentation/*"); + rewriteHandler.addRule(new RedirectPatternRule("/api-documentation", "/api-documentation/index.html")); // redirect to swagger ui start page } - //XXX: replace singleton pattern by dependency injection? + // XXX: replace singleton pattern by dependency injection? private static ApiService instance; public static ApiService getInstance() { @@ -77,26 +89,26 @@ public class ApiService { return instance; } - + Iterable> getResources() { return resources; } public void start() { try { - //START RPC + // START RPC server.start(); } catch (Exception e) { - //FAILED TO START RPC + // FAILED TO START RPC } } public void stop() { try { - //STOP RPC + // STOP RPC server.stop(); } catch (Exception e) { - //FAILED TO STOP RPC + // FAILED TO STOP RPC } } } diff --git a/src/api/BlocksResource.java b/src/api/BlocksResource.java index 17fa73da..dfbfed37 100644 --- a/src/api/BlocksResource.java +++ b/src/api/BlocksResource.java @@ -2,13 +2,14 @@ package api; import data.block.BlockData; import globalization.Translator; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + import java.math.BigDecimal; import javax.servlet.http.HttpServletRequest; @@ -19,18 +20,18 @@ import javax.ws.rs.Produces; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import qora.block.Block; - +import repository.DataException; import repository.Repository; import repository.RepositoryManager; import utils.Base58; @Path("blocks") @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) -@OpenAPIDefinition( - extensions = @Extension(name = "translation", properties = { +@Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="/Api/BlocksResource") - }) + } ) +@Tag(name = "blocks") public class BlocksResource { @Context @@ -49,6 +50,7 @@ public class BlocksResource { @GET @Path("/{signature}") @Operation( + summary = "Fetch block using base58 signature", description = "returns the block that matches the given signature", extensions = { @Extension(name = "translation", properties = { @@ -104,6 +106,7 @@ public class BlocksResource { @GET @Path("/first") @Operation( + summary = "Fetch genesis block", description = "returns the genesis block", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET first"), @@ -138,6 +141,7 @@ public class BlocksResource { @GET @Path("/last") @Operation( + summary = "Fetch last/newest block in blockchain", description = "returns the last valid block", extensions = @Extension(name = "translation", properties = { @ExtensionProperty(name="path", value="GET last"), @@ -172,6 +176,7 @@ public class BlocksResource { @GET @Path("/child/{signature}") @Operation( + summary = "Fetch child block using base58 signature of parent block", description = "returns the child block of the block that matches the given signature", extensions = { @Extension(name = "translation", properties = { @@ -199,36 +204,31 @@ public class BlocksResource { // decode signature byte[] signatureBytes; - try - { + try { signatureBytes = Base58.decode(signature); - } - catch(Exception e) - { - throw this.apiErrorFactory.createError(ApiError.INVALID_SIGNATURE, e); + } catch (NumberFormatException e) { + throw this.apiErrorFactory.createError(ApiError.INVALID_SIGNATURE, e); } - try (final Repository repository = RepositoryManager.getRepository()) { - BlockData blockData = repository.getBlockRepository().fromSignature(signatureBytes); - + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromSignature(signatureBytes); + // check if block exists if(blockData == null) throw this.apiErrorFactory.createError(ApiError.BLOCK_NO_EXISTS); - int height = blockData.getHeight(); - BlockData childBlockData = repository.getBlockRepository().fromHeight(height + 1); + BlockData childBlockData = repository.getBlockRepository().fromReference(signatureBytes); // check if child exists if(childBlockData == null) throw this.apiErrorFactory.createError(ApiError.BLOCK_NO_EXISTS); return childBlockData; - } catch (ApiException e) { throw e; - } catch (Exception e) { - throw this.apiErrorFactory.createError(ApiError.UNKNOWN, e); - } + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } } @GET diff --git a/src/api/Security.java b/src/api/Security.java index d05a41af..a7599d48 100644 --- a/src/api/Security.java +++ b/src/api/Security.java @@ -5,6 +5,8 @@ import javax.servlet.http.HttpServletRequest; public class Security { public static void checkApiCallAllowed(final String messageToDisplay, HttpServletRequest request) { - // TODO + // TODO + + // throw this.apiErrorFactory.createError(ApiError.UNAUTHORIZED); } } diff --git a/src/api/TransactionsResource.java b/src/api/TransactionsResource.java new file mode 100644 index 00000000..d396d3c3 --- /dev/null +++ b/src/api/TransactionsResource.java @@ -0,0 +1,160 @@ +package api; + +import globalization.Translator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import qora.crypto.Crypto; + +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import data.transaction.TransactionData; +import repository.DataException; +import repository.Repository; +import repository.RepositoryManager; +import repository.TransactionRepository; +import utils.Base58; + +@Path("transactions") +@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) +@Extension(name = "translation", properties = { + @ExtensionProperty(name="path", value="/Api/TransactionsResource") + } +) +@Tag(name = "transactions") +public class TransactionsResource { + + @Context + HttpServletRequest request; + + private ApiErrorFactory apiErrorFactory; + + public TransactionsResource() { + this(new ApiErrorFactory(Translator.getInstance())); + } + + public TransactionsResource(ApiErrorFactory apiErrorFactory) { + this.apiErrorFactory = apiErrorFactory; + } + + @GET + @Path("/address/{address}") + @Operation( + summary = "Fetch transactions involving address", + description = "Returns list of transactions", + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="path", value="GET block:signature"), + @ExtensionProperty(name="description.key", value="operation:description") + }), + @Extension(properties = { + @ExtensionProperty(name="apiErrors", value="[\"INVALID_ADDRESS\"]", parseValue = true), + }) + }, + responses = { + @ApiResponse( + description = "list of transactions", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransactionData.class))), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public List getAddressTransactions(@PathParam("address") String address) { + Security.checkApiCallAllowed("GET transactions/address", request); + + if (!Crypto.isValidAddress(address)) + throw this.apiErrorFactory.createError(ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + TransactionRepository txRepo = repository.getTransactionRepository(); + + List signatures = txRepo.getAllSignaturesInvolvingAddress(address); + + // Pagination would take effect here (or as part of the repository access) + + // Expand signatures to transactions + List transactions = new ArrayList(signatures.size()); + for (byte[] signature : signatures) + transactions.add(txRepo.fromSignature(signature)); + + return transactions; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } + + } + + @GET + @Path("/block/{signature}") + @Operation( + summary = "Fetch transactions via block signature", + description = "Returns list of transactions", + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="path", value="GET block:signature"), + @ExtensionProperty(name="description.key", value="operation:description") + }), + @Extension(properties = { + @ExtensionProperty(name="apiErrors", value="[\"INVALID_SIGNATURE\", \"BLOCK_NO_EXISTS\"]", parseValue = true), + }) + }, + responses = { + @ApiResponse( + description = "list of transactions", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransactionData.class))), + extensions = { + @Extension(name = "translation", properties = { + @ExtensionProperty(name="description.key", value="success_response:description") + }) + } + ) + } + ) + public List getBlockTransactions(@PathParam("signature") String signature) { + Security.checkApiCallAllowed("GET transactions/block", request); + + // decode signature + byte[] signatureBytes; + try { + signatureBytes = Base58.decode(signature); + } catch (NumberFormatException e) { + throw this.apiErrorFactory.createError(ApiError.INVALID_SIGNATURE, e); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + List transactions = repository.getBlockRepository().getTransactionsFromSignature(signatureBytes); + + // check if block exists + if(transactions == null) + throw this.apiErrorFactory.createError(ApiError.BLOCK_NO_EXISTS); + + return transactions; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw this.apiErrorFactory.createError(ApiError.REPOSITORY_ISSUE, e); + } + + } + +} diff --git a/src/blockgenerator.java b/src/blockgenerator.java index eec6a18d..f76c42d6 100644 --- a/src/blockgenerator.java +++ b/src/blockgenerator.java @@ -6,11 +6,15 @@ import org.apache.logging.log4j.Logger; import qora.block.BlockChain; import qora.block.BlockGenerator; import repository.DataException; +import repository.RepositoryFactory; +import repository.RepositoryManager; +import repository.hsqldb.HSQLDBRepositoryFactory; import utils.Base58; public class blockgenerator { private static final Logger LOGGER = LogManager.getLogger(blockgenerator.class); + public static final String connectionUrl = "jdbc:hsqldb:file:db/test;create=true"; public static void main(String[] args) { if (args.length != 1) { @@ -29,7 +33,8 @@ public class blockgenerator { } try { - test.Common.setRepository(); + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(connectionUrl); + RepositoryManager.setRepositoryFactory(repositoryFactory); } catch (DataException e) { LOGGER.error("Couldn't connect to repository", e); System.exit(2); @@ -58,7 +63,7 @@ public class blockgenerator { } try { - test.Common.closeRepository(); + RepositoryManager.closeRepositoryFactory(); } catch (DataException e) { // TODO Auto-generated catch block e.printStackTrace(); diff --git a/src/data/account/AccountBalanceData.java b/src/data/account/AccountBalanceData.java index 830009ec..bcdb721b 100644 --- a/src/data/account/AccountBalanceData.java +++ b/src/data/account/AccountBalanceData.java @@ -2,15 +2,25 @@ package data.account; import java.math.BigDecimal; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) public class AccountBalanceData { // Properties - protected String address; - protected long assetId; - protected BigDecimal balance; + private String address; + private long assetId; + private BigDecimal balance; // Constructors + // necessary for JAX-RS serialization + @SuppressWarnings("unused") + private AccountBalanceData() { + } + public AccountBalanceData(String address, long assetId, BigDecimal balance) { this.address = address; this.assetId = assetId; diff --git a/src/data/block/BlockData.java b/src/data/block/BlockData.java index 656b7853..1af4803e 100644 --- a/src/data/block/BlockData.java +++ b/src/data/block/BlockData.java @@ -1,10 +1,15 @@ package data.block; +import com.google.common.primitives.Bytes; + +import java.io.Serializable; import java.math.BigDecimal; -import com.google.common.primitives.Bytes; -import java.io.Serializable; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) public class BlockData implements Serializable { private static final long serialVersionUID = -7678329659124664620L; diff --git a/src/data/transaction/TransactionData.java b/src/data/transaction/TransactionData.java index 1fc544c8..428223c5 100644 --- a/src/data/transaction/TransactionData.java +++ b/src/data/transaction/TransactionData.java @@ -4,8 +4,13 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.util.Arrays; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + import qora.transaction.Transaction.TransactionType; +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) public abstract class TransactionData { // Properties shared with all transaction types diff --git a/src/database/NoDataFoundException.java b/src/database/NoDataFoundException.java index a9380eeb..68a8bac8 100644 --- a/src/database/NoDataFoundException.java +++ b/src/database/NoDataFoundException.java @@ -7,6 +7,7 @@ import java.sql.SQLException; * */ @SuppressWarnings("serial") +@Deprecated public class NoDataFoundException extends SQLException { public NoDataFoundException() { diff --git a/src/orphan.java b/src/orphan.java index 6090c99f..33a49c94 100644 --- a/src/orphan.java +++ b/src/orphan.java @@ -3,10 +3,14 @@ import qora.block.Block; import qora.block.BlockChain; import repository.DataException; import repository.Repository; +import repository.RepositoryFactory; import repository.RepositoryManager; +import repository.hsqldb.HSQLDBRepositoryFactory; public class orphan { + public static final String connectionUrl = "jdbc:hsqldb:file:db/test;create=true"; + public static void main(String[] args) { if (args.length == 0) { System.err.println("usage: orphan "); @@ -16,7 +20,8 @@ public class orphan { int targetHeight = Integer.parseInt(args[0]); try { - test.Common.setRepository(); + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(connectionUrl); + RepositoryManager.setRepositoryFactory(repositoryFactory); } catch (DataException e) { System.err.println("Couldn't connect to repository: " + e.getMessage()); System.exit(2); @@ -43,7 +48,7 @@ public class orphan { } try { - test.Common.closeRepository(); + RepositoryManager.closeRepositoryFactory(); } catch (DataException e) { e.printStackTrace(); } diff --git a/src/qora/account/Account.java b/src/qora/account/Account.java index c7a14425..23053a31 100644 --- a/src/qora/account/Account.java +++ b/src/qora/account/Account.java @@ -1,6 +1,8 @@ package qora.account; import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -8,6 +10,7 @@ import org.apache.logging.log4j.Logger; import data.account.AccountBalanceData; import data.account.AccountData; import data.block.BlockData; +import data.transaction.TransactionData; import qora.assets.Asset; import qora.block.Block; import qora.block.BlockChain; @@ -144,6 +147,32 @@ public class Account { return accountData.getReference(); } + /** + * Fetch last reference for account, considering unconfirmed transactions. + * + * @return byte[] reference, or null if no reference or account not found. + * @throws DataException + */ + public byte[] getUnconfirmedLastReference() throws DataException { + // Newest unconfirmed transaction takes priority + List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + + byte[] reference = null; + + for (TransactionData transactionData : unconfirmedTransactions) { + String address = PublicKeyAccount.getAddress(transactionData.getCreatorPublicKey()); + + if (address.equals(this.accountData.getAddress())) + reference = transactionData.getSignature(); + } + + if (reference != null) + return reference; + + // No unconfirmed transactions + return getLastReference(); + } + /** * Set last reference for account. * diff --git a/src/repository/AccountRepository.java b/src/repository/AccountRepository.java index d4356a60..bdd231a4 100644 --- a/src/repository/AccountRepository.java +++ b/src/repository/AccountRepository.java @@ -1,5 +1,7 @@ package repository; +import java.util.List; + import data.account.AccountBalanceData; import data.account.AccountData; @@ -19,6 +21,8 @@ public interface AccountRepository { public AccountBalanceData getBalance(String address, long assetId) throws DataException; + public List getAllBalances(String address) throws DataException; + public void save(AccountBalanceData accountBalanceData) throws DataException; public void delete(String address, long assetId) throws DataException; diff --git a/src/repository/TransactionRepository.java b/src/repository/TransactionRepository.java index dda6c698..0413b4ab 100644 --- a/src/repository/TransactionRepository.java +++ b/src/repository/TransactionRepository.java @@ -20,6 +20,8 @@ public interface TransactionRepository { @Deprecated public BlockData getBlockDataFromSignature(byte[] signature) throws DataException; + public List getAllSignaturesInvolvingAddress(String address) throws DataException; + /** * Returns list of unconfirmed transactions in timestamp-else-signature order. * diff --git a/src/repository/hsqldb/HSQLDBAccountRepository.java b/src/repository/hsqldb/HSQLDBAccountRepository.java index 6ecc8147..0daca3d3 100644 --- a/src/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/repository/hsqldb/HSQLDBAccountRepository.java @@ -3,6 +3,8 @@ package repository.hsqldb; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; import data.account.AccountBalanceData; import data.account.AccountData; @@ -84,6 +86,27 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public List getAllBalances(String address) throws DataException { + List balances = new ArrayList(); + + try (ResultSet resultSet = this.repository.checkedExecute("SELECT asset_id, balance FROM AccountBalances WHERE account = ?", address)) { + if (resultSet == null) + return balances; + + do { + long assetId = resultSet.getLong(1); + BigDecimal balance = resultSet.getBigDecimal(2).setScale(8); + + balances.add(new AccountBalanceData(address, assetId, balance)); + } while (resultSet.next()); + + return balances; + } catch (SQLException e) { + throw new DataException("Unable to fetch account balances from repository", e); + } + } + @Override public void save(AccountBalanceData accountBalanceData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("AccountBalances"); diff --git a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 69e3f112..a6db489b 100644 --- a/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -265,6 +265,27 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public List getAllSignaturesInvolvingAddress(String address) throws DataException { + List signatures = new ArrayList(); + + // XXX We need a table for all parties involved in a transaction, not just recipients + try (ResultSet resultSet = this.repository.checkedExecute("SELECT signature FROM TransactionRecipients WHERE recipient = ?", address)) { + if (resultSet == null) + return signatures; + + do { + byte[] signature = resultSet.getBytes(1); + + signatures.add(signature); + } while (resultSet.next()); + + return signatures; + } catch (SQLException e) { + throw new DataException("Unable to fetch involved transaction signatures from repository", e); + } + } + @Override public List getAllUnconfirmedTransactions() throws DataException { List transactions = new ArrayList(); diff --git a/src/txhex.java b/src/txhex.java index e221f19a..6dea671d 100644 --- a/src/txhex.java +++ b/src/txhex.java @@ -4,13 +4,17 @@ import data.transaction.TransactionData; import qora.block.BlockChain; import repository.DataException; import repository.Repository; +import repository.RepositoryFactory; import repository.RepositoryManager; +import repository.hsqldb.HSQLDBRepositoryFactory; import transform.TransformationException; import transform.transaction.TransactionTransformer; import utils.Base58; public class txhex { + public static final String connectionUrl = "jdbc:hsqldb:file:db/test;create=true"; + public static void main(String[] args) { if (args.length == 0) { System.err.println("usage: txhex "); @@ -20,7 +24,8 @@ public class txhex { byte[] signature = Base58.decode(args[0]); try { - test.Common.setRepository(); + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(connectionUrl); + RepositoryManager.setRepositoryFactory(repositoryFactory); } catch (DataException e) { System.err.println("Couldn't connect to repository: " + e.getMessage()); System.exit(2); @@ -42,7 +47,7 @@ public class txhex { } try { - test.Common.closeRepository(); + RepositoryManager.closeRepositoryFactory(); } catch (DataException e) { e.printStackTrace(); } diff --git a/src/v1feeder.java b/src/v1feeder.java index ab032645..1946ebe4 100644 --- a/src/v1feeder.java +++ b/src/v1feeder.java @@ -43,7 +43,9 @@ import qora.block.BlockChain; import qora.crypto.Crypto; import repository.DataException; import repository.Repository; +import repository.RepositoryFactory; import repository.RepositoryManager; +import repository.hsqldb.HSQLDBRepositoryFactory; import transform.TransformationException; import transform.block.BlockTransformer; import transform.transaction.ATTransactionTransformer; @@ -54,6 +56,7 @@ import utils.Triple; public class v1feeder extends Thread { private static final Logger LOGGER = LogManager.getLogger(v1feeder.class); + public static final String connectionUrl = "jdbc:hsqldb:file:db/test;create=true"; private static final int INACTIVITY_TIMEOUT = 60 * 1000; // milliseconds private static final int CONNECTION_TIMEOUT = 2 * 1000; // milliseconds @@ -526,7 +529,8 @@ public class v1feeder extends Thread { readLegacyATs(legacyATPathname); try { - test.Common.setRepository(); + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(connectionUrl); + RepositoryManager.setRepositoryFactory(repositoryFactory); } catch (DataException e) { LOGGER.error("Couldn't connect to repository", e); System.exit(2); @@ -552,7 +556,7 @@ public class v1feeder extends Thread { LOGGER.info("Exiting v1feeder"); try { - test.Common.closeRepository(); + RepositoryManager.closeRepositoryFactory(); } catch (DataException e) { e.printStackTrace(); }