mirror of
https://github.com/Qortal/qortal.git
synced 2025-04-22 19:07:51 +00:00
Initial APIs and core support for Q-Apps
This commit is contained in:
parent
4232616a5f
commit
32c2f68cb1
401
Q-Apps.md
Normal file
401
Q-Apps.md
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
# Qortal Project - Q-Apps Documentation
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Q-Apps are static web apps written in javascript, HTML, CSS, and other static assets. The key difference between a Q-App and a fully static site is its ability to interact with both the logged-in user and on-chain data. This is achieved using the API described in this document.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Making a request
|
||||||
|
|
||||||
|
Qortal core will automatically inject a `qortalRequest()` javascript function (a Promise) to all websites/apps. This can be used to fetch or publish data to or from the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling.
|
||||||
|
|
||||||
|
```
|
||||||
|
async function myfunction() {
|
||||||
|
try {
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_ACCOUNT_DATA",
|
||||||
|
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res)); // Log the response to the console
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.log("Error: " + JSON.stringify(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
myfunction();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timeouts
|
||||||
|
|
||||||
|
By default, all requests will timeout after 10 seconds, and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retried, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`.
|
||||||
|
|
||||||
|
```
|
||||||
|
async function myfunction() {
|
||||||
|
try {
|
||||||
|
let timeout = 60000; // 60 seconds
|
||||||
|
let res = await qortalRequestWithTimeout({
|
||||||
|
action: "FETCH_QDN_RESOURCE",
|
||||||
|
name: "QortalDemo",
|
||||||
|
service: "THUMBNAIL",
|
||||||
|
identifier: "qortal_avatar"
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
// Do something with the avatar here
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.log("Error: " + JSON.stringify(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
myfunction();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported methods
|
||||||
|
|
||||||
|
Here is a list of currently supported methods:
|
||||||
|
- GET_ACCOUNT_DATA
|
||||||
|
- GET_ACCOUNT_NAMES
|
||||||
|
- GET_NAME_DATA
|
||||||
|
- SEARCH_QDN_RESOURCES
|
||||||
|
- GET_QDN_RESOURCE_STATUS
|
||||||
|
- FETCH_QDN_RESOURCE
|
||||||
|
- PUBLISH_QDN_RESOURCE
|
||||||
|
- GET_WALLET_BALANCE
|
||||||
|
- GET_BALANCE
|
||||||
|
- SEND_COIN
|
||||||
|
- SEARCH_CHAT_MESSAGES
|
||||||
|
- SEND_CHAT_MESSAGE
|
||||||
|
- LIST_GROUPS
|
||||||
|
- JOIN_GROUP
|
||||||
|
- DEPLOY_AT
|
||||||
|
- GET_AT
|
||||||
|
- GET_AT_DATA
|
||||||
|
|
||||||
|
More functionality will be added in the future.
|
||||||
|
|
||||||
|
## Example Requests
|
||||||
|
|
||||||
|
Here is some example requests for each of the above:
|
||||||
|
|
||||||
|
### Get account data
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_ACCOUNT_DATA",
|
||||||
|
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get names owned by account
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_ACCOUNT_NAMES",
|
||||||
|
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get name data
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_NAME_DATA",
|
||||||
|
name: "QortalDemo"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Search QDN resources
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "SEARCH_QDN_RESOURCES",
|
||||||
|
service: "THUMBNAIL",
|
||||||
|
identifier: "qortal_avatar", // Optional
|
||||||
|
default: true, // Optional
|
||||||
|
nameListFilter: "FollowedNames", // Optional
|
||||||
|
includeStatus: false,
|
||||||
|
includeMetadata: false,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
reverse: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch QDN single file resource
|
||||||
|
Data is returned in the base64 format
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "FETCH_QDN_RESOURCE",
|
||||||
|
name: "QortalDemo",
|
||||||
|
service: "THUMBNAIL",
|
||||||
|
identifier: "qortal_avatar", // Optional. If omitted, the default resource is returned, or you can alternatively use the keyword "default"
|
||||||
|
rebuild: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch file from multi file QDN resource
|
||||||
|
Data is returned in the base64 format
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "FETCH_QDN_RESOURCE",
|
||||||
|
name: "QortalDemo",
|
||||||
|
service: "WEBSITE",
|
||||||
|
identifier: "default", // Optional. If omitted, the default resource is returned, or you can alternatively request that using the keyword "default", as shown here
|
||||||
|
filepath: "index.html", // Required only for resources containing more than one file
|
||||||
|
rebuild: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get QDN resource status
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_QDN_RESOURCE_STATUS",
|
||||||
|
name: "QortalDemo",
|
||||||
|
service: "THUMBNAIL",
|
||||||
|
identifier: "qortal_avatar" // Optional
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publish QDN resource
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "PUBLISH_QDN_RESOURCE",
|
||||||
|
name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
|
||||||
|
service: "WEBSITE",
|
||||||
|
data64: "base64_encoded_data",
|
||||||
|
title: "Title",
|
||||||
|
description: "Description",
|
||||||
|
category: "TECHNOLOGY",
|
||||||
|
tags: ["tag1", "tag2", "tag3", "tag4", "tag5"]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get wallet balance (QORT)
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "GET_WALLET_BALANCE",
|
||||||
|
coin: "QORT"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get wallet balance (foreign coin)
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "GET_WALLET_BALANCE",
|
||||||
|
coin: "LTC"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get address or asset balance
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_BALANCE",
|
||||||
|
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_BALANCE",
|
||||||
|
assetId: 1,
|
||||||
|
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send coin to address
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "SEND_COIN",
|
||||||
|
coin: "QORT",
|
||||||
|
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
|
||||||
|
amount: 100000000, // 1 QORT
|
||||||
|
fee: 10000 // 0.0001 QORT
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send coin to address
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "SEND_COIN",
|
||||||
|
coin: "LTC",
|
||||||
|
destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y",
|
||||||
|
amount: 100000000, // 1 LTC
|
||||||
|
fee: 20 // 0.00000020 LTC per byte
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search or list chat messages
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "SEARCH_CHAT_MESSAGES",
|
||||||
|
before: 999999999999999,
|
||||||
|
after: 0,
|
||||||
|
txGroupId: 0, // Optional (must specify either txGroupId or two involving addresses)
|
||||||
|
// involving: ["QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", "QSefrppsDCsZebcwrqiM1gNbWq7YMDXtG2"], // Optional (must specify either txGroupId or two involving addresses)
|
||||||
|
// reference: "reference", // Optional
|
||||||
|
// chatReference: "chatreference", // Optional
|
||||||
|
// hasChatReference: true, // Optional
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
reverse: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send a group chat message
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "SEND_CHAT_MESSAGE",
|
||||||
|
groupId: 0,
|
||||||
|
message: "Test"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send a private chat message
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "SEND_CHAT_MESSAGE",
|
||||||
|
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
|
||||||
|
message: "Test"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### List groups
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "LIST_GROUPS",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
reverse: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Join a group
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
await qortalRequest({
|
||||||
|
action: "JOIN_GROUP",
|
||||||
|
groupId: 100
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Deploy an AT
|
||||||
|
_Requires user approval_
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "DEPLOY_AT",
|
||||||
|
creationBytes: "12345",
|
||||||
|
name: "test name",
|
||||||
|
description: "test description",
|
||||||
|
type: "test type",
|
||||||
|
tags: "test tags",
|
||||||
|
amount: 100000000, // 1 QORT
|
||||||
|
assetId: 0,
|
||||||
|
fee: 20000 // 0.0002 QORT
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get AT info
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_AT",
|
||||||
|
atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get AT data bytes (base58 encoded)
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "GET_AT_DATA",
|
||||||
|
atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### List ATs by functionality
|
||||||
|
```
|
||||||
|
let res = await qortalRequest({
|
||||||
|
action: "LIST_ATS",
|
||||||
|
codeHash58: "4KdJETRAdymE7dodDmJbf5d9L1bp4g5Nxky8m47TBkvA",
|
||||||
|
isExecutable: true,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
reverse: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Sample App
|
||||||
|
|
||||||
|
Here is a sample application to display the logged-in user's avatar:
|
||||||
|
```
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
// Get QORT address of logged in account
|
||||||
|
let address = await qortalRequest({
|
||||||
|
action: "GET_ACCOUNT_ADDRESS"
|
||||||
|
});
|
||||||
|
console.log("address: " + address);
|
||||||
|
|
||||||
|
// Get names owned by this account
|
||||||
|
let names = await qortalRequest({
|
||||||
|
action: "GET_ACCOUNT_NAMES",
|
||||||
|
address: address
|
||||||
|
});
|
||||||
|
console.log("names: " + JSON.stringify(names));
|
||||||
|
|
||||||
|
if (names.size == 0) {
|
||||||
|
console.log("User has no registered names");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the avatar of the first registered name
|
||||||
|
let avatar = await qortalRequest({
|
||||||
|
action: "FETCH_QDN_RESOURCE",
|
||||||
|
name: names[0].name,
|
||||||
|
service: "THUMBNAIL",
|
||||||
|
identifier: "qortal_avatar"
|
||||||
|
});
|
||||||
|
console.log("avatar: " + JSON.stringify(avatar));
|
||||||
|
|
||||||
|
// Display the avatar image on the screen
|
||||||
|
document.getElementsById("avatar").src = "data:image/png;base64," + avatar;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.log("Error: " + JSON.stringify(e));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img width="500" id="avatar" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Testing and Development
|
||||||
|
|
||||||
|
Publishing an in-development app to mainnet isn't recommended. There are several options for developing and testing a Q-app before publishing to mainnet:
|
||||||
|
|
||||||
|
### Preview mode
|
||||||
|
|
||||||
|
All read-only operations can be tested using preview mode. It can be used as follows:
|
||||||
|
|
||||||
|
1. Ensure Qortal core is running locally on the machine you are developing on. Previewing via a remote node is not currently possible.
|
||||||
|
2. Make a local API call to `POST /render/preview`, passing in the API key (found in apikey.txt), and the path to the root of your Q-App, for example:
|
||||||
|
```
|
||||||
|
curl -X POST "http://localhost:12391/render/preview" -H "X-API-KEY: apiKeyGoesHere" -d "/home/username/Websites/MyApp"
|
||||||
|
```
|
||||||
|
3. This returns a URL, which can be copied and pasted into a browser to view the preview
|
||||||
|
4. Modify the Q-App as required, then repeat from step 2 to generate a new preview URL
|
||||||
|
|
||||||
|
This is a short term method until preview functionality has been implemented within the UI.
|
||||||
|
|
||||||
|
|
||||||
|
### Single node testnet
|
||||||
|
|
||||||
|
For full read/write testing of a Q-App, you can set up a single node testnet (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start).
|
@ -110,6 +110,7 @@ Your options are:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<a name="quick-start"></a>
|
||||||
## Quick start
|
## Quick start
|
||||||
Here are some steps to quickly get a single node testnet up and running with a generic minting account:
|
Here are some steps to quickly get a single node testnet up and running with a generic minting account:
|
||||||
1. Start with template `settings-test.json`, and create a `testchain.json` based on mainnet's blockchain.json (or obtain one from Qortal developers). These should be in the same directory as the jar.
|
1. Start with template `settings-test.json`, and create a `testchain.json` based on mainnet's blockchain.json (or obtain one from Qortal developers). These should be in the same directory as the jar.
|
||||||
|
@ -53,7 +53,7 @@ public class ApiService {
|
|||||||
|
|
||||||
private ApiService() {
|
private ApiService() {
|
||||||
this.config = new ResourceConfig();
|
this.config = new ResourceConfig();
|
||||||
this.config.packages("org.qortal.api.resource");
|
this.config.packages("org.qortal.api.resource", "org.qortal.api.apps.resource");
|
||||||
this.config.register(OpenApiResource.class);
|
this.config.register(OpenApiResource.class);
|
||||||
this.config.register(ApiDefinition.class);
|
this.config.register(ApiDefinition.class);
|
||||||
this.config.register(AnnotationPostProcessor.class);
|
this.config.register(AnnotationPostProcessor.class);
|
||||||
|
@ -37,7 +37,7 @@ public class GatewayService {
|
|||||||
|
|
||||||
private GatewayService() {
|
private GatewayService() {
|
||||||
this.config = new ResourceConfig();
|
this.config = new ResourceConfig();
|
||||||
this.config.packages("org.qortal.api.gateway.resource");
|
this.config.packages("org.qortal.api.gateway.resource", "org.qortal.api.apps.resource");
|
||||||
this.config.register(OpenApiResource.class);
|
this.config.register(OpenApiResource.class);
|
||||||
this.config.register(ApiDefinition.class);
|
this.config.register(ApiDefinition.class);
|
||||||
this.config.register(AnnotationPostProcessor.class);
|
this.config.register(AnnotationPostProcessor.class);
|
||||||
|
@ -25,6 +25,10 @@ public class HTMLParser {
|
|||||||
String baseUrl = this.linkPrefix + "/";
|
String baseUrl = this.linkPrefix + "/";
|
||||||
Elements head = document.getElementsByTag("head");
|
Elements head = document.getElementsByTag("head");
|
||||||
if (!head.isEmpty()) {
|
if (!head.isEmpty()) {
|
||||||
|
// Add q-apps script tag
|
||||||
|
String qAppsScriptElement = String.format("<script src=\"/apps/q-apps.js?time=%d\">", System.currentTimeMillis());
|
||||||
|
head.get(0).prepend(qAppsScriptElement);
|
||||||
|
|
||||||
// Add base href tag
|
// Add base href tag
|
||||||
String baseElement = String.format("<base href=\"%s\">", baseUrl);
|
String baseElement = String.format("<base href=\"%s\">", baseUrl);
|
||||||
head.get(0).prepend(baseElement);
|
head.get(0).prepend(baseElement);
|
||||||
|
210
src/main/java/org/qortal/api/apps/resource/AppsResource.java
Normal file
210
src/main/java/org/qortal/api/apps/resource/AppsResource.java
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
package org.qortal.api.apps.resource;
|
||||||
|
|
||||||
|
import com.google.common.io.Resources;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
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.Hidden;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.qortal.api.ApiError;
|
||||||
|
import org.qortal.api.ApiExceptionFactory;
|
||||||
|
import org.qortal.arbitrary.apps.QApp;
|
||||||
|
import org.qortal.arbitrary.misc.Service;
|
||||||
|
import org.qortal.data.account.AccountData;
|
||||||
|
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
|
||||||
|
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
|
||||||
|
import org.qortal.data.at.ATData;
|
||||||
|
import org.qortal.data.chat.ChatMessage;
|
||||||
|
import org.qortal.data.group.GroupData;
|
||||||
|
import org.qortal.data.naming.NameData;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
|
||||||
|
import javax.servlet.ServletContext;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.ws.rs.*;
|
||||||
|
import javax.ws.rs.core.Context;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
|
||||||
|
@Path("/apps")
|
||||||
|
@Tag(name = "Apps")
|
||||||
|
public class AppsResource {
|
||||||
|
|
||||||
|
@Context HttpServletRequest request;
|
||||||
|
@Context HttpServletResponse response;
|
||||||
|
@Context ServletContext context;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/q-apps.js")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
@Operation(
|
||||||
|
summary = "Javascript interface for Q-Apps",
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
description = "javascript",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN,
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public String getQAppsJs() {
|
||||||
|
URL url = Resources.getResource("q-apps/q-apps.js");
|
||||||
|
try {
|
||||||
|
return Resources.toString(url, StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/q-apps-helper.js")
|
||||||
|
@Hidden // For testing only
|
||||||
|
public String getQAppsHelperJs() {
|
||||||
|
URL url = Resources.getResource("q-apps/q-apps-helper.js");
|
||||||
|
try {
|
||||||
|
return Resources.toString(url, StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/account")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public AccountData getAccount(@QueryParam("address") String address) {
|
||||||
|
try {
|
||||||
|
return QApp.getAccountData(address);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/account/names")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public List<NameData> getAccountNames(@QueryParam("address") String address) {
|
||||||
|
try {
|
||||||
|
return QApp.getAccountNames(address);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/name")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public NameData getName(@QueryParam("name") String name) {
|
||||||
|
try {
|
||||||
|
return QApp.getNameData(name);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/chatmessages")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public List<ChatMessage> searchChatMessages(@QueryParam("before") Long before, @QueryParam("after") Long after, @QueryParam("txGroupId") Integer txGroupId, @QueryParam("involving") List<String> involvingAddresses, @QueryParam("reference") String reference, @QueryParam("chatReference") String chatReference, @QueryParam("hasChatReference") Boolean hasChatReference, @QueryParam("limit") Integer limit, @QueryParam("offset") Integer offset, @QueryParam("reverse") Boolean reverse) {
|
||||||
|
try {
|
||||||
|
return QApp.searchChatMessages(before, after, txGroupId, involvingAddresses, reference, chatReference, hasChatReference, limit, offset, reverse);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/resources")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public List<ArbitraryResourceInfo> getResources(@QueryParam("service") Service service, @QueryParam("identifier") String identifier, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(description = "Filter names by list") @QueryParam("nameListFilter") String nameListFilter, @Parameter(description = "Include status") @QueryParam("includeStatus") Boolean includeStatus, @Parameter(description = "Include metadata") @QueryParam("includeMetadata") Boolean includeMetadata, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||||
|
try {
|
||||||
|
return QApp.searchQdnResources(service, identifier, defaultResource, nameListFilter, includeStatus, includeMetadata, limit, offset, reverse);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/resourcestatus")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public ArbitraryResourceStatus getResourceStatus(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier) {
|
||||||
|
return QApp.getQdnResourceStatus(service, name, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/resource")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public String getResource(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier, @QueryParam("filepath") String filepath, @QueryParam("rebuild") boolean rebuild) {
|
||||||
|
try {
|
||||||
|
return QApp.fetchQdnResource64(service, name, identifier, filepath, rebuild);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/groups")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public List<GroupData> listGroups(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||||
|
try {
|
||||||
|
return QApp.listGroups(limit, offset, reverse);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/balance")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public Long getBalance(@QueryParam("assetId") long assetId, @QueryParam("address") String address) {
|
||||||
|
try {
|
||||||
|
return QApp.getBalance(assetId, address);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/at")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public ATData getAT(@QueryParam("atAddress") String atAddress) {
|
||||||
|
try {
|
||||||
|
return QApp.getAtInfo(atAddress);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/atdata")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public String getATData(@QueryParam("atAddress") String atAddress) {
|
||||||
|
try {
|
||||||
|
return QApp.getAtData58(atAddress);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/ats")
|
||||||
|
@Hidden // For internal Q-App API use only
|
||||||
|
public List<ATData> listATs(@QueryParam("codeHash58") String codeHash58, @QueryParam("isExecutable") Boolean isExecutable, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||||
|
try {
|
||||||
|
return QApp.listATs(codeHash58, isExecutable, limit, offset, reverse);
|
||||||
|
} catch (DataException | IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -128,10 +128,10 @@ public class ArbitraryResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeStatus != null && includeStatus) {
|
if (includeStatus != null && includeStatus) {
|
||||||
resources = this.addStatusToResources(resources);
|
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
|
||||||
}
|
}
|
||||||
if (includeMetadata != null && includeMetadata) {
|
if (includeMetadata != null && includeMetadata) {
|
||||||
resources = this.addMetadataToResources(resources);
|
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resources;
|
return resources;
|
||||||
@ -175,10 +175,10 @@ public class ArbitraryResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeStatus != null && includeStatus) {
|
if (includeStatus != null && includeStatus) {
|
||||||
resources = this.addStatusToResources(resources);
|
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
|
||||||
}
|
}
|
||||||
if (includeMetadata != null && includeMetadata) {
|
if (includeMetadata != null && includeMetadata) {
|
||||||
resources = this.addMetadataToResources(resources);
|
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resources;
|
return resources;
|
||||||
@ -232,10 +232,10 @@ public class ArbitraryResource {
|
|||||||
.getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, reverse);
|
.getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, reverse);
|
||||||
|
|
||||||
if (includeStatus != null && includeStatus) {
|
if (includeStatus != null && includeStatus) {
|
||||||
resources = this.addStatusToResources(resources);
|
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
|
||||||
}
|
}
|
||||||
if (includeMetadata != null && includeMetadata) {
|
if (includeMetadata != null && includeMetadata) {
|
||||||
resources = this.addMetadataToResources(resources);
|
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
creatorName.resources = resources;
|
creatorName.resources = resources;
|
||||||
@ -511,10 +511,10 @@ public class ArbitraryResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeStatus != null && includeStatus) {
|
if (includeStatus != null && includeStatus) {
|
||||||
resources = this.addStatusToResources(resources);
|
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
|
||||||
}
|
}
|
||||||
if (includeMetadata != null && includeMetadata) {
|
if (includeMetadata != null && includeMetadata) {
|
||||||
resources = this.addMetadataToResources(resources);
|
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resources;
|
return resources;
|
||||||
@ -1258,42 +1258,4 @@ public class ArbitraryResource {
|
|||||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private List<ArbitraryResourceInfo> addStatusToResources(List<ArbitraryResourceInfo> resources) {
|
|
||||||
// Determine and add the status of each resource
|
|
||||||
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
|
|
||||||
for (ArbitraryResourceInfo resourceInfo : resources) {
|
|
||||||
try {
|
|
||||||
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME,
|
|
||||||
resourceInfo.service, resourceInfo.identifier);
|
|
||||||
ArbitraryResourceStatus status = resource.getStatus(true);
|
|
||||||
if (status != null) {
|
|
||||||
resourceInfo.status = status;
|
|
||||||
}
|
|
||||||
updatedResources.add(resourceInfo);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses
|
|
||||||
LOGGER.info("Caught exception when adding status to resource %s: %s", resourceInfo, e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updatedResources;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<ArbitraryResourceInfo> addMetadataToResources(List<ArbitraryResourceInfo> resources) {
|
|
||||||
// Add metadata fields to each resource if they exist
|
|
||||||
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
|
|
||||||
for (ArbitraryResourceInfo resourceInfo : resources) {
|
|
||||||
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME,
|
|
||||||
resourceInfo.service, resourceInfo.identifier);
|
|
||||||
ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata();
|
|
||||||
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
|
|
||||||
if (resourceMetadata != null) {
|
|
||||||
resourceInfo.metadata = resourceMetadata;
|
|
||||||
}
|
|
||||||
updatedResources.add(resourceInfo);
|
|
||||||
}
|
|
||||||
return updatedResources;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
276
src/main/java/org/qortal/arbitrary/apps/QApp.java
Normal file
276
src/main/java/org/qortal/arbitrary/apps/QApp.java
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
package org.qortal.arbitrary.apps;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.ArrayUtils;
|
||||||
|
import org.bouncycastle.util.encoders.Base64;
|
||||||
|
import org.ciyam.at.MachineState;
|
||||||
|
import org.qortal.account.Account;
|
||||||
|
import org.qortal.arbitrary.ArbitraryDataFile;
|
||||||
|
import org.qortal.arbitrary.ArbitraryDataReader;
|
||||||
|
import org.qortal.arbitrary.exception.MissingDataException;
|
||||||
|
import org.qortal.arbitrary.misc.Service;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.controller.LiteNode;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.data.account.AccountData;
|
||||||
|
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
|
||||||
|
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
|
||||||
|
import org.qortal.data.at.ATData;
|
||||||
|
import org.qortal.data.at.ATStateData;
|
||||||
|
import org.qortal.data.chat.ChatMessage;
|
||||||
|
import org.qortal.data.group.GroupData;
|
||||||
|
import org.qortal.data.naming.NameData;
|
||||||
|
import org.qortal.list.ResourceListManager;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class QApp {
|
||||||
|
|
||||||
|
public static AccountData getAccountData(String address) throws DataException {
|
||||||
|
if (!Crypto.isValidAddress(address))
|
||||||
|
throw new IllegalArgumentException("Invalid address");
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
return repository.getAccountRepository().getAccount(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<NameData> getAccountNames(String address) throws DataException {
|
||||||
|
if (!Crypto.isValidAddress(address))
|
||||||
|
throw new IllegalArgumentException("Invalid address");
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
return repository.getNameRepository().getNamesByOwner(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NameData getNameData(String name) throws DataException {
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
if (Settings.getInstance().isLite()) {
|
||||||
|
return LiteNode.getInstance().fetchNameData(name);
|
||||||
|
} else {
|
||||||
|
return repository.getNameRepository().fromName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ChatMessage> searchChatMessages(Long before, Long after, Integer txGroupId, List<String> involvingAddresses,
|
||||||
|
String reference, String chatReference, Boolean hasChatReference,
|
||||||
|
Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||||
|
// Check args meet expectations
|
||||||
|
if ((txGroupId == null && involvingAddresses.size() != 2)
|
||||||
|
|| (txGroupId != null && !involvingAddresses.isEmpty()))
|
||||||
|
throw new IllegalArgumentException("Invalid txGroupId or involvingAddresses");
|
||||||
|
|
||||||
|
// Check any provided addresses are valid
|
||||||
|
if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address)))
|
||||||
|
throw new IllegalArgumentException("Invalid address");
|
||||||
|
|
||||||
|
if (before != null && before < 1500000000000L)
|
||||||
|
throw new IllegalArgumentException("Invalid timestamp");
|
||||||
|
|
||||||
|
byte[] referenceBytes = null;
|
||||||
|
if (reference != null)
|
||||||
|
referenceBytes = Base58.decode(reference);
|
||||||
|
|
||||||
|
byte[] chatReferenceBytes = null;
|
||||||
|
if (chatReference != null)
|
||||||
|
chatReferenceBytes = Base58.decode(chatReference);
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
return repository.getChatRepository().getMessagesMatchingCriteria(
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
txGroupId,
|
||||||
|
referenceBytes,
|
||||||
|
chatReferenceBytes,
|
||||||
|
hasChatReference,
|
||||||
|
involvingAddresses,
|
||||||
|
limit, offset, reverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ArbitraryResourceInfo> searchQdnResources(Service service, String identifier, Boolean defaultResource,
|
||||||
|
String nameListFilter, Boolean includeStatus, Boolean includeMetadata,
|
||||||
|
Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
|
||||||
|
// Treat empty identifier as null
|
||||||
|
if (identifier != null && identifier.isEmpty()) {
|
||||||
|
identifier = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that "default" and "identifier" parameters cannot coexist
|
||||||
|
boolean defaultRes = Boolean.TRUE.equals(defaultResource);
|
||||||
|
if (defaultRes == true && identifier != null) {
|
||||||
|
throw new IllegalArgumentException("identifier cannot be specified when requesting a default resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load filter from list if needed
|
||||||
|
List<String> names = null;
|
||||||
|
if (nameListFilter != null) {
|
||||||
|
names = ResourceListManager.getInstance().getStringsInList(nameListFilter);
|
||||||
|
if (names.isEmpty()) {
|
||||||
|
// List doesn't exist or is empty - so there will be no matches
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
|
||||||
|
.getArbitraryResources(service, identifier, names, defaultRes, limit, offset, reverse);
|
||||||
|
|
||||||
|
if (resources == null) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeStatus != null && includeStatus) {
|
||||||
|
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
|
||||||
|
}
|
||||||
|
if (includeMetadata != null && includeMetadata) {
|
||||||
|
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ArbitraryResourceStatus getQdnResourceStatus(Service service, String name, String identifier) {
|
||||||
|
return ArbitraryTransactionUtils.getStatus(service, name, identifier, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String fetchQdnResource64(Service service, String name, String identifier, String filepath, boolean rebuild) throws DataException {
|
||||||
|
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
|
||||||
|
try {
|
||||||
|
|
||||||
|
int attempts = 0;
|
||||||
|
int maxAttempts = 5;
|
||||||
|
|
||||||
|
// Loop until we have data
|
||||||
|
while (!Controller.isStopping()) {
|
||||||
|
attempts++;
|
||||||
|
if (!arbitraryDataReader.isBuilding()) {
|
||||||
|
try {
|
||||||
|
arbitraryDataReader.loadSynchronously(rebuild);
|
||||||
|
break;
|
||||||
|
} catch (MissingDataException e) {
|
||||||
|
if (attempts > maxAttempts) {
|
||||||
|
// Give up after 5 attempts
|
||||||
|
throw new DataException("Data unavailable. Please try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(3000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
java.nio.file.Path outputPath = arbitraryDataReader.getFilePath();
|
||||||
|
if (outputPath == null) {
|
||||||
|
// Assume the resource doesn't exist
|
||||||
|
throw new DataException("File not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filepath == null || filepath.isEmpty()) {
|
||||||
|
// No file path supplied - so check if this is a single file resource
|
||||||
|
String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
|
||||||
|
if (files.length == 1) {
|
||||||
|
// This is a single file resource
|
||||||
|
filepath = files[0];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new IllegalArgumentException("filepath is required for resources containing more than one file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: limit file size that can be read into memory
|
||||||
|
java.nio.file.Path path = Paths.get(outputPath.toString(), filepath);
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] bytes = Files.readAllBytes(path);
|
||||||
|
if (bytes != null) {
|
||||||
|
return Base64.toBase64String(bytes);
|
||||||
|
}
|
||||||
|
throw new DataException("File contents could not be read");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new DataException(String.format("Unable to fetch resource: %s", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<GroupData> listGroups(Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
List<GroupData> allGroupData = repository.getGroupRepository().getAllGroups(limit, offset, reverse);
|
||||||
|
allGroupData.forEach(groupData -> {
|
||||||
|
try {
|
||||||
|
groupData.memberCount = repository.getGroupRepository().countGroupMembers(groupData.getGroupId());
|
||||||
|
} catch (DataException e) {
|
||||||
|
// Exclude memberCount for this group
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return allGroupData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long getBalance(Long assetId, String address) throws DataException {
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
if (assetId == null)
|
||||||
|
assetId = Asset.QORT;
|
||||||
|
|
||||||
|
Account account = new Account(repository, address);
|
||||||
|
return account.getConfirmedBalance(assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ATData getAtInfo(String atAddress) throws DataException {
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
if (atData == null) {
|
||||||
|
throw new IllegalArgumentException("AT not found");
|
||||||
|
}
|
||||||
|
return atData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getAtData58(String atAddress) throws DataException {
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
|
||||||
|
if (atStateData == null) {
|
||||||
|
throw new IllegalArgumentException("AT not found");
|
||||||
|
}
|
||||||
|
byte[] stateData = atStateData.getStateData();
|
||||||
|
byte[] dataBytes = MachineState.extractDataBytes(stateData);
|
||||||
|
return Base58.encode(dataBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ATData> listATs(String codeHash58, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException {
|
||||||
|
// Decode codeHash
|
||||||
|
byte[] codeHash;
|
||||||
|
try {
|
||||||
|
codeHash = Base58.decode(codeHash58);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new IllegalArgumentException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeHash must be present and have correct length
|
||||||
|
if (codeHash == null || codeHash.length != 32)
|
||||||
|
throw new IllegalArgumentException("Invalid code hash");
|
||||||
|
|
||||||
|
// Impose a limit on 'limit'
|
||||||
|
if (limit != null && limit > 100)
|
||||||
|
throw new IllegalArgumentException("Limit is too high");
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
return repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,11 +3,11 @@ package org.qortal.utils;
|
|||||||
import org.apache.commons.lang3.ArrayUtils;
|
import org.apache.commons.lang3.ArrayUtils;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
import org.qortal.arbitrary.ArbitraryDataFile;
|
import org.qortal.arbitrary.*;
|
||||||
import org.qortal.arbitrary.ArbitraryDataFileChunk;
|
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
|
||||||
import org.qortal.arbitrary.ArbitraryDataReader;
|
|
||||||
import org.qortal.arbitrary.ArbitraryDataResource;
|
|
||||||
import org.qortal.arbitrary.misc.Service;
|
import org.qortal.arbitrary.misc.Service;
|
||||||
|
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
|
||||||
|
import org.qortal.data.arbitrary.ArbitraryResourceMetadata;
|
||||||
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
|
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
|
||||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||||
import org.qortal.data.transaction.TransactionData;
|
import org.qortal.data.transaction.TransactionData;
|
||||||
@ -440,4 +440,41 @@ public class ArbitraryTransactionUtils {
|
|||||||
return resource.getStatus(false);
|
return resource.getStatus(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<ArbitraryResourceInfo> addStatusToResources(List<ArbitraryResourceInfo> resources) {
|
||||||
|
// Determine and add the status of each resource
|
||||||
|
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
|
||||||
|
for (ArbitraryResourceInfo resourceInfo : resources) {
|
||||||
|
try {
|
||||||
|
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ArbitraryDataFile.ResourceIdType.NAME,
|
||||||
|
resourceInfo.service, resourceInfo.identifier);
|
||||||
|
ArbitraryResourceStatus status = resource.getStatus(true);
|
||||||
|
if (status != null) {
|
||||||
|
resourceInfo.status = status;
|
||||||
|
}
|
||||||
|
updatedResources.add(resourceInfo);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses
|
||||||
|
LOGGER.info("Caught exception when adding status to resource %s: %s", resourceInfo, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedResources;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ArbitraryResourceInfo> addMetadataToResources(List<ArbitraryResourceInfo> resources) {
|
||||||
|
// Add metadata fields to each resource if they exist
|
||||||
|
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
|
||||||
|
for (ArbitraryResourceInfo resourceInfo : resources) {
|
||||||
|
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ArbitraryDataFile.ResourceIdType.NAME,
|
||||||
|
resourceInfo.service, resourceInfo.identifier);
|
||||||
|
ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata();
|
||||||
|
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
|
||||||
|
if (resourceMetadata != null) {
|
||||||
|
resourceInfo.metadata = resourceMetadata;
|
||||||
|
}
|
||||||
|
updatedResources.add(resourceInfo);
|
||||||
|
}
|
||||||
|
return updatedResources;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
206
src/main/resources/q-apps/q-apps.js
Normal file
206
src/main/resources/q-apps/q-apps.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
function httpGet(url) {
|
||||||
|
var request = new XMLHttpRequest();
|
||||||
|
request.open("GET", url, false);
|
||||||
|
request.send(null);
|
||||||
|
return request.responseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResponse(event, response) {
|
||||||
|
if (event == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle emmpty or missing responses
|
||||||
|
if (response == null || response.length == 0) {
|
||||||
|
response = "{\"error\": \"Empty response\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
let responseObj;
|
||||||
|
try {
|
||||||
|
responseObj = JSON.parse(response);
|
||||||
|
} catch (e) {
|
||||||
|
// Not all responses will be JSON
|
||||||
|
responseObj = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to app
|
||||||
|
if (responseObj.error != null) {
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
result: null,
|
||||||
|
error: responseObj
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
result: responseObj,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event == null || event.data == null || event.data.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data.action == null) {
|
||||||
|
// This could be a response from the UI
|
||||||
|
handleResponse(event, event.data);
|
||||||
|
}
|
||||||
|
if (event.data.requestedHandler != null && event.data.requestedHandler === "UI") {
|
||||||
|
// This request was destined for the UI, so ignore it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Core received event: " + JSON.stringify(event.data));
|
||||||
|
|
||||||
|
let url;
|
||||||
|
let response;
|
||||||
|
let data = event.data;
|
||||||
|
|
||||||
|
switch (data.action) {
|
||||||
|
case "GET_ACCOUNT_DATA":
|
||||||
|
response = httpGet("/apps/account?address=" + data.address);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_ACCOUNT_NAMES":
|
||||||
|
response = httpGet("/apps/account/names?address=" + data.address);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_NAME_DATA":
|
||||||
|
response = httpGet("/apps/name?name=" + data.name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "SEARCH_QDN_RESOURCES":
|
||||||
|
url = "/apps/resources?";
|
||||||
|
if (data.service != null) url = url.concat("&service=" + data.service);
|
||||||
|
if (data.identifier != null) url = url.concat("&identifier=" + data.identifier);
|
||||||
|
if (data.default != null) url = url.concat("&default=" + data.default);
|
||||||
|
if (data.nameListFilter != null) url = url.concat("&nameListFilter=" + data.nameListFilter);
|
||||||
|
if (data.includeStatus != null) url = url.concat("&includeStatus=" + new Boolean(data.includeStatus).toString());
|
||||||
|
if (data.includeMetadata != null) url = url.concat("&includeMetadata=" + new Boolean(data.includeMetadata).toString());
|
||||||
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
||||||
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
||||||
|
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "FETCH_QDN_RESOURCE":
|
||||||
|
url = "/apps/resource?";
|
||||||
|
if (data.service != null) url = url.concat("&service=" + data.service);
|
||||||
|
if (data.name != null) url = url.concat("&name=" + data.name);
|
||||||
|
if (data.identifier != null) url = url.concat("&identifier=" + data.identifier);
|
||||||
|
if (data.filepath != null) url = url.concat("&filepath=" + data.filepath);
|
||||||
|
if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString())
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_QDN_RESOURCE_STATUS":
|
||||||
|
url = "/apps/resourcestatus?";
|
||||||
|
if (data.service != null) url = url.concat("&service=" + data.service);
|
||||||
|
if (data.name != null) url = url.concat("&name=" + data.name);
|
||||||
|
if (data.identifier != null) url = url.concat("&identifier=" + data.identifier);
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "SEARCH_CHAT_MESSAGES":
|
||||||
|
url = "/apps/chatmessages?";
|
||||||
|
if (data.before != null) url = url.concat("&before=" + data.before);
|
||||||
|
if (data.after != null) url = url.concat("&after=" + data.after);
|
||||||
|
if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId);
|
||||||
|
if (data.involving != null) data.involving.forEach((x, i) => url = url.concat("&involving=" + x));
|
||||||
|
if (data.reference != null) url = url.concat("&reference=" + data.reference);
|
||||||
|
if (data.chatReference != null) url = url.concat("&chatReference=" + data.chatReference);
|
||||||
|
if (data.hasChatReference != null) url = url.concat("&hasChatReference=" + new Boolean(data.hasChatReference).toString());
|
||||||
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
||||||
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
||||||
|
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "LIST_GROUPS":
|
||||||
|
url = "/apps/groups?";
|
||||||
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
||||||
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
||||||
|
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_BALANCE":
|
||||||
|
url = "/apps/balance?";
|
||||||
|
if (data.assetId != null) url = url.concat("&assetId=" + data.assetId);
|
||||||
|
if (data.address != null) url = url.concat("&address=" + data.address);
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_AT":
|
||||||
|
url = "/apps/at?";
|
||||||
|
if (data.atAddress != null) url = url.concat("&atAddress=" + data.atAddress);
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GET_AT_DATA":
|
||||||
|
url = "/apps/atdata?";
|
||||||
|
if (data.atAddress != null) url = url.concat("&atAddress=" + data.atAddress);
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "LIST_ATS":
|
||||||
|
url = "/apps/ats?";
|
||||||
|
if (data.codeHash58 != null) url = url.concat("&codeHash58=" + data.codeHash58);
|
||||||
|
if (data.isExecutable != null) url = url.concat("&isExecutable=" + data.isExecutable);
|
||||||
|
if (data.limit != null) url = url.concat("&limit=" + data.limit);
|
||||||
|
if (data.offset != null) url = url.concat("&offset=" + data.offset);
|
||||||
|
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
|
||||||
|
response = httpGet(url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Pass to parent (UI), in case they can fulfil this request
|
||||||
|
event.data.requestedHandler = "UI";
|
||||||
|
parent.postMessage(event.data, '*', [event.ports[0]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResponse(event, response);
|
||||||
|
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
const awaitTimeout = (timeout, reason) =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => (reason === undefined ? resolve() : reject(reason)),
|
||||||
|
timeout
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Qortal (Q-Apps) request with no timeout
|
||||||
|
*/
|
||||||
|
const qortalRequestWithNoTimeout = (request) => new Promise((res, rej) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port1.onmessage = ({data}) => {
|
||||||
|
channel.port1.close();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
rej(data.error);
|
||||||
|
} else {
|
||||||
|
res(data.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.postMessage(request, '*', [channel.port2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Qortal (Q-Apps) request with the default timeout (10 seconds)
|
||||||
|
*/
|
||||||
|
const qortalRequest = (request) =>
|
||||||
|
Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(10000, "The request timed out")]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds
|
||||||
|
*/
|
||||||
|
const qortalRequestWithTimeout = (request, timeout) =>
|
||||||
|
Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(timeout, "The request timed out")]);
|
Loading…
x
Reference in New Issue
Block a user