diff --git a/pom.xml b/pom.xml index e9cbf1d4..dcbdfc8a 100644 --- a/pom.xml +++ b/pom.xml @@ -71,6 +71,18 @@ https://petstore.swagger.io/v2/swagger.json /openapi.json + + Swagger UI + API Documentation + + + deepLinking: true, + + deepLinking: true, + tagsSorter: "alpha", + operationsSorter: "alpha", + + diff --git a/src/api/AddressesResource.java b/src/api/AddressesResource.java index 81b5ee10..18f28334 100644 --- a/src/api/AddressesResource.java +++ b/src/api/AddressesResource.java @@ -370,7 +370,7 @@ public class AddressesResource { @GET @Path("/publickey/{address}") @Operation( - summary = "Address' public key", + summary = "Get public key of address", description = "Returns the base64-encoded account public key of the given address, or \"false\" if address not known or has no public key.", extensions = { @Extension(name = "translation", properties = { diff --git a/src/api/AdminResource.java b/src/api/AdminResource.java index 35346c19..2b802e0d 100644 --- a/src/api/AdminResource.java +++ b/src/api/AdminResource.java @@ -38,6 +38,7 @@ public class AdminResource { @Parameter(in = ParameterIn.QUERY, name = "limit", description = "Maximum number of entries to return, 0 means unlimited", schema = @Schema(type = "integer", defaultValue = "10")) @Parameter(in = ParameterIn.QUERY, name = "offset", description = "Starting entry in results", schema = @Schema(type = "integer")) @Parameter(in = ParameterIn.QUERY, name = "includeTransactions", description = "Include associated transactions in results", schema = @Schema(type = "boolean")) + @Parameter(in = ParameterIn.QUERY, name = "includeHolders", description = "Include asset holders in results", schema = @Schema(type = "boolean")) public String globalParameters() { return ""; } diff --git a/src/api/ApiDefinition.java b/src/api/ApiDefinition.java index 898f4b12..b91d1b63 100644 --- a/src/api/ApiDefinition.java +++ b/src/api/ApiDefinition.java @@ -7,10 +7,11 @@ 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" ), + info = @Info( title = "Qora API", description = "NOTE: byte-arrays are encoded in Base64" ), tags = { @Tag(name = "Addresses"), @Tag(name = "Admin"), + @Tag(name = "Assets"), @Tag(name = "Blocks"), @Tag(name = "Transactions"), @Tag(name = "Utilities") diff --git a/src/api/ApiService.java b/src/api/ApiService.java index 2389eff6..71fa9850 100644 --- a/src/api/ApiService.java +++ b/src/api/ApiService.java @@ -29,6 +29,7 @@ public class ApiService { this.resources = new HashSet>(); this.resources.add(AddressesResource.class); this.resources.add(AdminResource.class); + this.resources.add(AssetsResource.class); this.resources.add(BlocksResource.class); this.resources.add(TransactionsResource.class); this.resources.add(UtilsResource.class); diff --git a/src/api/AssetsResource.java b/src/api/AssetsResource.java new file mode 100644 index 00000000..a670cb89 --- /dev/null +++ b/src/api/AssetsResource.java @@ -0,0 +1,86 @@ +package api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 repository.DataException; +import repository.Repository; +import repository.RepositoryManager; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import api.models.AssetWithHolders; +import data.assets.AssetData; + +@Path("/assets") +@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) +@Tag(name = "Assets") +public class AssetsResource { + + @Context + HttpServletRequest request; + + @GET + @Path("/all") + @Operation( + summary = "List all known assets", + responses = { + @ApiResponse( + description = "asset info", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = AssetData.class))) + ) + } + ) + public List getAllAssets() { + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getAssetRepository().getAllAssets(); + } catch (DataException e) { + throw ApiErrorFactory.getInstance().createError(ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/info") + @Operation( + summary = "Info on specific asset", + responses = { + @ApiResponse( + description = "asset info", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = AssetData.class))) + ) + } + ) + public AssetWithHolders getAssetInfo(@QueryParam("key") Integer key, @QueryParam("name") String name, @Parameter(ref = "includeHolders") @QueryParam("withHolders") boolean includeHolders) { + if (key == null && (name == null || name.isEmpty())) + throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + AssetData assetData = null; + + if (key != null) + assetData = repository.getAssetRepository().fromAssetId(key); + else + assetData = repository.getAssetRepository().fromAssetName(name); + + if (assetData == null) + throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_ASSET_ID); + + return new AssetWithHolders(repository, assetData, includeHolders); + } catch (DataException e) { + throw ApiErrorFactory.getInstance().createError(ApiError.REPOSITORY_ISSUE, e); + } + } + +} diff --git a/src/api/UtilsResource.java b/src/api/UtilsResource.java index 01e3ad97..86602bc1 100644 --- a/src/api/UtilsResource.java +++ b/src/api/UtilsResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import utils.Base58; +import java.security.SecureRandom; import java.util.Base64; import javax.servlet.http.HttpServletRequest; @@ -63,4 +64,21 @@ public class UtilsResource { } } + @GET + @Path("/seed") + @Operation( + summary = "Generate random 32-byte seed", + responses = { + @ApiResponse( + description = "base64 data", + content = @Content(schema = @Schema(implementation = String.class)) + ) + } + ) + public String seed() { + byte[] seed = new byte[32]; + new SecureRandom().nextBytes(seed); + return Base64.getEncoder().encodeToString(seed); + } + } diff --git a/src/api/models/AssetWithHolders.java b/src/api/models/AssetWithHolders.java new file mode 100644 index 00000000..662b51b2 --- /dev/null +++ b/src/api/models/AssetWithHolders.java @@ -0,0 +1,43 @@ +package api.models; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.xml.bind.annotation.XmlElement; + +import api.ApiError; +import api.ApiErrorFactory; +import data.account.AccountBalanceData; +import data.assets.AssetData; +import data.block.BlockData; +import data.transaction.TransactionData; +import io.swagger.v3.oas.annotations.media.Schema; +import qora.block.Block; +import repository.DataException; +import repository.Repository; + +@Schema(description = "Asset with (optional) asset holders") +public class AssetWithHolders { + + @Schema(implementation = AssetData.class, name = "asset", title = "asset data") + @XmlElement(name = "asset") + public AssetData assetData; + + public List holders; + + // For JAX-RS + @SuppressWarnings("unused") + private AssetWithHolders() { + } + + public AssetWithHolders(Repository repository, AssetData assetData, boolean includeHolders) throws DataException { + if (assetData == null) + throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_ASSET_ID); + + this.assetData = assetData; + + if (includeHolders) + this.holders = repository.getAccountRepository().getAssetBalances(assetData.getAssetId()); + } + +} diff --git a/src/data/assets/AssetData.java b/src/data/assets/AssetData.java index f4446fb2..a6c16df3 100644 --- a/src/data/assets/AssetData.java +++ b/src/data/assets/AssetData.java @@ -1,5 +1,10 @@ package data.assets; +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 AssetData { // Properties @@ -11,6 +16,10 @@ public class AssetData { private boolean isDivisible; private byte[] reference; + // necessary for JAX-RS serialization + protected AssetData() { + } + // NOTE: key is Long, not long, because it can be null if asset ID/key not yet assigned. public AssetData(Long assetId, String owner, String name, String description, long quantity, boolean isDivisible, byte[] reference) { this.assetId = assetId; diff --git a/src/repository/AccountRepository.java b/src/repository/AccountRepository.java index 1559dc3a..99ad57ca 100644 --- a/src/repository/AccountRepository.java +++ b/src/repository/AccountRepository.java @@ -23,6 +23,8 @@ public interface AccountRepository { public List getAllBalances(String address) throws DataException; + public List getAssetBalances(long assetId) throws DataException; + public void save(AccountBalanceData accountBalanceData) throws DataException; public void delete(String address, long assetId) throws DataException; diff --git a/src/repository/AssetRepository.java b/src/repository/AssetRepository.java index 89e27f63..5bd5caed 100644 --- a/src/repository/AssetRepository.java +++ b/src/repository/AssetRepository.java @@ -2,6 +2,7 @@ package repository; import java.util.List; +import data.account.AccountBalanceData; import data.assets.AssetData; import data.assets.OrderData; import data.assets.TradeData; @@ -18,6 +19,10 @@ public interface AssetRepository { public boolean assetExists(String assetName) throws DataException; + public List getAllAssets() throws DataException; + + // For a list of asset holders, see AccountRepository.getAssetBalances + public void save(AssetData assetData) throws DataException; public void delete(long assetId) throws DataException; diff --git a/src/repository/hsqldb/HSQLDBAccountRepository.java b/src/repository/hsqldb/HSQLDBAccountRepository.java index 0cbaabdb..e0c27612 100644 --- a/src/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/repository/hsqldb/HSQLDBAccountRepository.java @@ -114,6 +114,27 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public List getAssetBalances(long assetId) throws DataException { + List balances = new ArrayList(); + + try (ResultSet resultSet = this.repository.checkedExecute("SELECT account, balance FROM AccountBalances WHERE asset_id = ?", assetId)) { + if (resultSet == null) + return balances; + + do { + String address = resultSet.getString(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 asset account balances from repository", e); + } + } + @Override public void save(AccountBalanceData accountBalanceData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("AccountBalances"); diff --git a/src/repository/hsqldb/HSQLDBAssetRepository.java b/src/repository/hsqldb/HSQLDBAssetRepository.java index 1cc32de3..741816fa 100644 --- a/src/repository/hsqldb/HSQLDBAssetRepository.java +++ b/src/repository/hsqldb/HSQLDBAssetRepository.java @@ -82,6 +82,33 @@ public class HSQLDBAssetRepository implements AssetRepository { } } + @Override + public List getAllAssets() throws DataException { + List assets = new ArrayList(); + + try (ResultSet resultSet = this.repository + .checkedExecute("SELECT owner, asset_id, description, quantity, is_divisible, reference, asset_name FROM Assets ORDER BY asset_id ASC")) { + if (resultSet == null) + return assets; + + do { + String owner = resultSet.getString(1); + long assetId = resultSet.getLong(2); + String description = resultSet.getString(3); + long quantity = resultSet.getLong(4); + boolean isDivisible = resultSet.getBoolean(5); + byte[] reference = resultSet.getBytes(6); + String assetName = resultSet.getString(7); + + assets.add(new AssetData(assetId, owner, assetName, description, quantity, isDivisible, reference)); + } while (resultSet.next()); + + return assets; + } catch (SQLException e) { + throw new DataException("Unable to fetch all assets from repository", e); + } + } + @Override public void save(AssetData assetData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("Assets"); @@ -148,8 +175,7 @@ public class HSQLDBAssetRepository implements AssetRepository { try (ResultSet resultSet = this.repository.checkedExecute( "SELECT creator, asset_order_id, amount, fulfilled, price, ordered FROM AssetOrders " - + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE " - + "ORDER BY price ASC, ordered ASC", + + "WHERE have_asset_id = ? AND want_asset_id = ? AND is_closed = FALSE AND is_fulfilled = FALSE ORDER BY price ASC, ordered ASC", haveAssetId, wantAssetId)) { if (resultSet == null) return orders; diff --git a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java index 520723eb..0dd8fc58 100644 --- a/src/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -152,6 +152,8 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX UnconfirmedTransactionsIndex ON UnconfirmedTransactions (creation, signature)"); // Transaction recipients + // XXX This should be transaction "participants" to allow lookup of all activity by an address! + // Could add "is_recipient" boolean flag stmt.execute("CREATE TABLE TransactionRecipients (signature Signature, recipient QoraAddress NOT NULL, " + "FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); // Use a separate table space as this table will be very large.