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(); }