Update asset spot price API to allow multiple input and output formats
This commit is contained in:
@@ -30,8 +30,7 @@ std::unique_ptr<net::test_server::HttpResponse> HandleRequest(
|
||||
http_response->set_code(net::HTTP_OK);
|
||||
http_response->set_content_type("text/html");
|
||||
if (request.GetURL().spec().find("/v2/history") != std::string::npos) {
|
||||
http_response->set_content(R"(
|
||||
{
|
||||
http_response->set_content(R"({
|
||||
"payload": {
|
||||
"prices":[[1622733088498,0.8201346624954003],[1622737203757,0.8096978545029869]],
|
||||
"market_caps":[[1622733088498,1223507820.383275],[1622737203757,1210972881.4928021]],
|
||||
@@ -40,7 +39,29 @@ std::unique_ptr<net::test_server::HttpResponse> HandleRequest(
|
||||
})");
|
||||
} else {
|
||||
http_response->set_content(
|
||||
R"({"payload":{"basic-attention-token":{"usd":0.694503}}})");
|
||||
R"({
|
||||
"payload":{
|
||||
"basic-attention-token":{
|
||||
"btc":0.00001732,
|
||||
"btc_24h_change":8.021672460190562,
|
||||
"usd":0.55393,
|
||||
"usd_24h_change":9.523443444373276
|
||||
},
|
||||
"bat":{
|
||||
"btc":0.00001732,
|
||||
"btc_24h_change":8.021672460190562,
|
||||
"usd":0.55393,
|
||||
"usd_24h_change":9.523443444373276
|
||||
},
|
||||
"link":{
|
||||
"btc":0.00261901,
|
||||
"btc_24h_change":0.5871625385632929,
|
||||
"usd":83.77,
|
||||
"usd_24h_change":1.7646208048244043
|
||||
}
|
||||
},
|
||||
"lastUpdated":"2021-07-16T19:11:28.907Z"
|
||||
})");
|
||||
}
|
||||
return std::move(http_response);
|
||||
}
|
||||
@@ -83,11 +104,12 @@ class AssetRatioControllerTest : public InProcessBrowserTest {
|
||||
https_server_->base_url());
|
||||
}
|
||||
|
||||
void OnGetPrice(bool success, const std::string& price) {
|
||||
void OnGetPrice(bool success,
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> prices) {
|
||||
if (wait_for_request_) {
|
||||
wait_for_request_->Quit();
|
||||
}
|
||||
ASSERT_EQ(expected_price_response_, price);
|
||||
ASSERT_EQ(expected_prices_response_, prices);
|
||||
ASSERT_EQ(expected_success_, success);
|
||||
}
|
||||
|
||||
@@ -101,12 +123,13 @@ class AssetRatioControllerTest : public InProcessBrowserTest {
|
||||
ASSERT_EQ(expected_success_, success);
|
||||
}
|
||||
|
||||
void WaitForPriceResponse(const std::string& expected_price_response,
|
||||
bool expected_success) {
|
||||
void WaitForPriceResponse(
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> expected_prices_response,
|
||||
bool expected_success) {
|
||||
if (wait_for_request_) {
|
||||
return;
|
||||
}
|
||||
expected_price_response_ = expected_price_response;
|
||||
expected_prices_response_ = std::move(expected_prices_response);
|
||||
expected_success_ = expected_success;
|
||||
wait_for_request_.reset(new base::RunLoop);
|
||||
wait_for_request_->Run();
|
||||
@@ -145,7 +168,7 @@ class AssetRatioControllerTest : public InProcessBrowserTest {
|
||||
net::EmbeddedTestServer* https_server() { return https_server_.get(); }
|
||||
|
||||
bool expected_success_;
|
||||
std::string expected_price_response_;
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> expected_prices_response_;
|
||||
std::vector<brave_wallet::mojom::AssetTimePricePtr>
|
||||
expected_price_history_response_;
|
||||
|
||||
@@ -154,28 +177,59 @@ class AssetRatioControllerTest : public InProcessBrowserTest {
|
||||
};
|
||||
|
||||
IN_PROC_BROWSER_TEST_F(AssetRatioControllerTest, GetPrice) {
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> expected_prices_response;
|
||||
ResetHTTPSServer(base::BindRepeating(&HandleRequest));
|
||||
auto controller = GetAssetRatioController();
|
||||
controller->GetPrice("basic-attention-token",
|
||||
controller->GetPrice({"bat", "link"}, {"btc", "usd"},
|
||||
base::BindOnce(&AssetRatioControllerTest::OnGetPrice,
|
||||
base::Unretained(this)));
|
||||
WaitForPriceResponse("0.694503", true);
|
||||
|
||||
auto asset_price = brave_wallet::mojom::AssetPrice::New();
|
||||
asset_price->from_asset = "bat";
|
||||
asset_price->to_asset = "btc";
|
||||
asset_price->price = "0.00001732";
|
||||
asset_price->asset_24h_change = "8.021672460190562";
|
||||
expected_prices_response.push_back(std::move(asset_price));
|
||||
|
||||
asset_price = brave_wallet::mojom::AssetPrice::New();
|
||||
asset_price->from_asset = "bat";
|
||||
asset_price->to_asset = "usd";
|
||||
asset_price->price = "0.55393";
|
||||
asset_price->asset_24h_change = "9.523443444373276";
|
||||
expected_prices_response.push_back(std::move(asset_price));
|
||||
|
||||
asset_price = brave_wallet::mojom::AssetPrice::New();
|
||||
asset_price->from_asset = "link";
|
||||
asset_price->to_asset = "btc";
|
||||
asset_price->price = "0.00261901";
|
||||
asset_price->asset_24h_change = "0.5871625385632929";
|
||||
expected_prices_response.push_back(std::move(asset_price));
|
||||
|
||||
asset_price = brave_wallet::mojom::AssetPrice::New();
|
||||
asset_price->from_asset = "link";
|
||||
asset_price->to_asset = "usd";
|
||||
asset_price->price = "83.77";
|
||||
asset_price->asset_24h_change = "1.7646208048244043";
|
||||
expected_prices_response.push_back(std::move(asset_price));
|
||||
|
||||
WaitForPriceResponse(std::move(expected_prices_response), true);
|
||||
}
|
||||
|
||||
IN_PROC_BROWSER_TEST_F(AssetRatioControllerTest, GetPriceServerError) {
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> expected_prices_response;
|
||||
ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError));
|
||||
auto controller = GetAssetRatioController();
|
||||
controller->GetPrice("basic-attention-token",
|
||||
controller->GetPrice({"bat", "link"}, {"btc", "usd"},
|
||||
base::BindOnce(&AssetRatioControllerTest::OnGetPrice,
|
||||
base::Unretained(this)));
|
||||
WaitForPriceResponse("", false);
|
||||
WaitForPriceResponse(std::move(expected_prices_response), false);
|
||||
}
|
||||
|
||||
IN_PROC_BROWSER_TEST_F(AssetRatioControllerTest, GetPriceHistory) {
|
||||
ResetHTTPSServer(base::BindRepeating(&HandleRequest));
|
||||
auto controller = GetAssetRatioController();
|
||||
controller->GetPriceHistory(
|
||||
"basic-attention-token", brave_wallet::mojom::AssetPriceTimeframe::OneDay,
|
||||
"bat", brave_wallet::mojom::AssetPriceTimeframe::OneDay,
|
||||
base::BindOnce(&AssetRatioControllerTest::OnGetPriceHistory,
|
||||
base::Unretained(this)));
|
||||
|
||||
@@ -199,7 +253,7 @@ IN_PROC_BROWSER_TEST_F(AssetRatioControllerTest, GetPriceHistoryServerError) {
|
||||
ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError));
|
||||
auto controller = GetAssetRatioController();
|
||||
controller->GetPriceHistory(
|
||||
"basic-attention-token", brave_wallet::mojom::AssetPriceTimeframe::OneDay,
|
||||
"bat", brave_wallet::mojom::AssetPriceTimeframe::OneDay,
|
||||
base::BindOnce(&AssetRatioControllerTest::OnGetPriceHistory,
|
||||
base::Unretained(this)));
|
||||
std::vector<brave_wallet::mojom::AssetTimePricePtr>
|
||||
|
||||
@@ -103,10 +103,12 @@ void WalletHandler::UnlockWallet(const std::string& password,
|
||||
keyring_controller_->Unlock(password, std::move(callback));
|
||||
}
|
||||
|
||||
void WalletHandler::GetAssetPrice(const std::string& asset,
|
||||
void WalletHandler::GetAssetPrice(const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets,
|
||||
GetAssetPriceCallback callback) {
|
||||
EnsureConnected();
|
||||
asset_ratio_controller_->GetPrice(asset, std::move(callback));
|
||||
asset_ratio_controller_->GetPrice(from_assets, to_assets,
|
||||
std::move(callback));
|
||||
}
|
||||
|
||||
void WalletHandler::GetAssetPriceHistory(
|
||||
|
||||
@@ -30,7 +30,9 @@ class WalletHandler : public brave_wallet::mojom::WalletHandler {
|
||||
void GetWalletInfo(GetWalletInfoCallback) override;
|
||||
void LockWallet() override;
|
||||
void UnlockWallet(const std::string& password, UnlockWalletCallback) override;
|
||||
void GetAssetPrice(const std::string& asset, GetAssetPriceCallback) override;
|
||||
void GetAssetPrice(const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets,
|
||||
GetAssetPriceCallback) override;
|
||||
void GetAssetPriceHistory(const std::string& asset,
|
||||
brave_wallet::mojom::AssetPriceTimeframe timeframe,
|
||||
GetAssetPriceHistoryCallback) override;
|
||||
|
||||
@@ -38,6 +38,17 @@ net::NetworkTrafficAnnotationTag GetNetworkTrafficAnnotationTag() {
|
||||
)");
|
||||
}
|
||||
|
||||
std::string VectorToCommaSeparatedList(const std::vector<std::string>& assets) {
|
||||
std::stringstream ss;
|
||||
std::for_each(assets.begin(), assets.end(), [&ss](const std::string asset) {
|
||||
if (ss.tellp() != 0) {
|
||||
ss << ",";
|
||||
}
|
||||
ss << asset;
|
||||
});
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace brave_wallet {
|
||||
@@ -63,12 +74,16 @@ void AssetRatioController::SetBaseURLForTest(const GURL& base_url_for_test) {
|
||||
}
|
||||
|
||||
// static
|
||||
GURL AssetRatioController::GetPriceURL(const std::string& asset) {
|
||||
GURL AssetRatioController::GetPriceURL(
|
||||
const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets) {
|
||||
std::string from = VectorToCommaSeparatedList(from_assets);
|
||||
std::string to = VectorToCommaSeparatedList(to_assets);
|
||||
std::string spec = base::StringPrintf(
|
||||
"%sv2/relative/provider/coingecko/%s/usd",
|
||||
"%sv2/relative/provider/coingecko/%s/%s",
|
||||
base_url_for_test_.is_empty() ? kAssetRatioBaseURL
|
||||
: base_url_for_test_.spec().c_str(),
|
||||
asset.c_str());
|
||||
from.c_str(), to.c_str());
|
||||
return GURL(spec);
|
||||
}
|
||||
|
||||
@@ -108,31 +123,34 @@ GURL AssetRatioController::GetPriceHistoryURL(
|
||||
return GURL(spec);
|
||||
}
|
||||
|
||||
void AssetRatioController::GetPrice(const std::string& asset,
|
||||
void AssetRatioController::GetPrice(const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets,
|
||||
GetPriceCallback callback) {
|
||||
auto internal_callback =
|
||||
base::BindOnce(&AssetRatioController::OnGetPrice,
|
||||
weak_ptr_factory_.GetWeakPtr(), std::move(callback));
|
||||
api_request_helper_.Request("GET", GetPriceURL(asset), "", "", true,
|
||||
std::move(internal_callback));
|
||||
auto internal_callback = base::BindOnce(
|
||||
&AssetRatioController::OnGetPrice, weak_ptr_factory_.GetWeakPtr(),
|
||||
from_assets, to_assets, std::move(callback));
|
||||
api_request_helper_.Request("GET", GetPriceURL(from_assets, to_assets), "",
|
||||
"", true, std::move(internal_callback));
|
||||
}
|
||||
|
||||
void AssetRatioController::OnGetPrice(
|
||||
std::vector<std::string> from_assets,
|
||||
std::vector<std::string> to_assets,
|
||||
GetPriceCallback callback,
|
||||
const int status,
|
||||
const std::string& body,
|
||||
const std::map<std::string, std::string>& headers) {
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> prices;
|
||||
if (status < 200 || status > 299) {
|
||||
std::move(callback).Run(false, "");
|
||||
std::move(callback).Run(false, std::move(prices));
|
||||
return;
|
||||
}
|
||||
std::string price;
|
||||
if (!ParseAssetPrice(body, &price)) {
|
||||
std::move(callback).Run(false, "");
|
||||
if (!ParseAssetPrice(body, from_assets, to_assets, &prices)) {
|
||||
std::move(callback).Run(false, std::move(prices));
|
||||
return;
|
||||
}
|
||||
|
||||
std::move(callback).Run(true, price);
|
||||
std::move(callback).Run(true, std::move(prices));
|
||||
}
|
||||
|
||||
void AssetRatioController::GetPriceHistory(
|
||||
|
||||
@@ -47,14 +47,17 @@ class AssetRatioController : public KeyedService,
|
||||
brave_wallet::mojom::AssetPriceTimeframe timeframe,
|
||||
GetPriceHistoryCallback callback) override;
|
||||
|
||||
static GURL GetPriceURL(const std::string& asset);
|
||||
static GURL GetPriceURL(const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets);
|
||||
static GURL GetPriceHistoryURL(
|
||||
const std::string& asset,
|
||||
brave_wallet::mojom::AssetPriceTimeframe timeframe);
|
||||
static void SetBaseURLForTest(const GURL& base_url_for_test);
|
||||
|
||||
private:
|
||||
void OnGetPrice(GetPriceCallback callback,
|
||||
void OnGetPrice(std::vector<std::string> from_assets,
|
||||
std::vector<std::string> to_assets,
|
||||
GetPriceCallback callback,
|
||||
const int status,
|
||||
const std::string& body,
|
||||
const std::map<std::string, std::string>& headers);
|
||||
|
||||
@@ -8,19 +8,42 @@
|
||||
#include "base/json/json_reader.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/stringprintf.h"
|
||||
#include "base/time/time.h"
|
||||
#include "third_party/abseil-cpp/absl/types/optional.h"
|
||||
|
||||
namespace brave_wallet {
|
||||
|
||||
bool ParseAssetPrice(const std::string& json, std::string* price) {
|
||||
bool ParseAssetPrice(const std::string& json,
|
||||
const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets,
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr>* values) {
|
||||
// Parses results like this:
|
||||
// { "payload":
|
||||
// {
|
||||
// "basic-attention-token":{"usd":0.694503}
|
||||
// }
|
||||
// {
|
||||
// "payload":{
|
||||
// "basic-attention-token":{
|
||||
// "btc":0.00001732,
|
||||
// "btc_24h_change":8.021672460190562,
|
||||
// "usd":0.55393,
|
||||
// "usd_24h_change":9.523443444373276
|
||||
// },
|
||||
// "bat":{
|
||||
// "btc":0.00001732,
|
||||
// "btc_24h_change":8.021672460190562,
|
||||
// "usd":0.55393,
|
||||
// "usd_24h_change":9.523443444373276
|
||||
// },
|
||||
// "link":{
|
||||
// "btc":0.00261901,
|
||||
// "btc_24h_change":0.5871625385632929,
|
||||
// "usd":83.77,
|
||||
// "usd_24h_change":1.7646208048244043
|
||||
// }
|
||||
// },
|
||||
// "lastUpdated":"2021-07-16T19:11:28.907Z"
|
||||
// }
|
||||
DCHECK(price);
|
||||
|
||||
DCHECK(values);
|
||||
|
||||
base::JSONReader::ValueWithError value_with_error =
|
||||
base::JSONReader::ReadAndReturnValueWithError(
|
||||
@@ -46,27 +69,37 @@ bool ParseAssetPrice(const std::string& json, std::string* price) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload->DictSize() == 0) {
|
||||
return false;
|
||||
}
|
||||
for (const std::string& from_asset : from_assets) {
|
||||
const base::Value* from_asset_value =
|
||||
payload_dict->FindDictPath(from_asset);
|
||||
const base::DictionaryValue* from_asset_dict;
|
||||
if (!from_asset_value->GetAsDictionary(&from_asset_dict)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto items = payload->DictItems();
|
||||
if (!items.begin()->second.is_dict()) {
|
||||
return false;
|
||||
}
|
||||
const auto& usd_price_dict =
|
||||
base::Value::AsDictionaryValue(items.begin()->second);
|
||||
if (usd_price_dict.DictSize() != 1) {
|
||||
return false;
|
||||
}
|
||||
auto converted_items = usd_price_dict.DictItems();
|
||||
const auto& value = converted_items.begin()->second;
|
||||
for (const std::string& to_asset : to_assets) {
|
||||
auto asset_price = brave_wallet::mojom::AssetPrice::New();
|
||||
asset_price->from_asset = from_asset;
|
||||
asset_price->to_asset = to_asset;
|
||||
|
||||
double num;
|
||||
if (!value.GetAsDouble(&num)) {
|
||||
return false;
|
||||
absl::optional<double> to_price =
|
||||
from_asset_dict->FindDoublePath(to_asset);
|
||||
if (!to_price) {
|
||||
return false;
|
||||
}
|
||||
asset_price->price = base::NumberToString(*to_price);
|
||||
std::string to_asset_24h_key =
|
||||
base::StringPrintf("%s_24h_change", to_asset.c_str());
|
||||
absl::optional<double> to_24h_change =
|
||||
from_asset_dict->FindDoublePath(to_asset_24h_key);
|
||||
if (!to_24h_change) {
|
||||
return false;
|
||||
}
|
||||
asset_price->asset_24h_change = base::NumberToString(*to_24h_change);
|
||||
|
||||
values->push_back(std::move(asset_price));
|
||||
}
|
||||
}
|
||||
*price = base::NumberToString(num);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
|
||||
namespace brave_wallet {
|
||||
|
||||
bool ParseAssetPrice(const std::string& json, std::string* price);
|
||||
bool ParseAssetPrice(const std::string& json,
|
||||
const std::vector<std::string>& from_assets,
|
||||
const std::vector<std::string>& to_assets,
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr>* values);
|
||||
bool ParseAssetPriceHistory(
|
||||
const std::string& json,
|
||||
std::vector<brave_wallet::mojom::AssetTimePricePtr>* values);
|
||||
|
||||
@@ -15,10 +15,54 @@
|
||||
namespace brave_wallet {
|
||||
|
||||
TEST(AssetRatioResponseParserUnitTest, ParseAssetPrice) {
|
||||
std::string json(
|
||||
R"({"payload": {"basic-attention-token":{"usd":0.694503}}})");
|
||||
std::string price;
|
||||
ASSERT_TRUE(ParseAssetPrice(json, &price));
|
||||
std::string json(R"({
|
||||
"payload":{
|
||||
"basic-attention-token":{
|
||||
"btc":0.00001732,
|
||||
"btc_24h_change":8.021672460190562,
|
||||
"usd":0.55393,
|
||||
"usd_24h_change":9.523443444373276
|
||||
},
|
||||
"bat":{
|
||||
"btc":0.00001732,
|
||||
"btc_24h_change":8.021672460190562,
|
||||
"usd":0.55393,
|
||||
"usd_24h_change":9.523443444373276
|
||||
},
|
||||
"link":{
|
||||
"btc":0.00261901,
|
||||
"btc_24h_change":0.5871625385632929,
|
||||
"usd":83.77,
|
||||
"usd_24h_change":1.7646208048244043
|
||||
}
|
||||
},
|
||||
"lastUpdated":"2021-07-16T19:11:28.907Z"
|
||||
})");
|
||||
|
||||
std::vector<brave_wallet::mojom::AssetPricePtr> prices;
|
||||
ASSERT_TRUE(ParseAssetPrice(json, {"bat", "link"}, {"btc", "usd"}, &prices));
|
||||
ASSERT_EQ(prices.size(), 4UL);
|
||||
ASSERT_EQ(prices[0]->from_asset, "bat");
|
||||
ASSERT_EQ(prices[0]->to_asset, "btc");
|
||||
ASSERT_EQ(prices[0]->price, "0.00001732");
|
||||
ASSERT_EQ(prices[0]->asset_24h_change, "8.021672460190562");
|
||||
|
||||
ASSERT_EQ(prices[1]->from_asset, "bat");
|
||||
ASSERT_EQ(prices[1]->to_asset, "usd");
|
||||
ASSERT_EQ(prices[1]->price, "0.55393");
|
||||
ASSERT_EQ(prices[1]->asset_24h_change, "9.523443444373276");
|
||||
|
||||
ASSERT_EQ(prices[2]->from_asset, "link");
|
||||
ASSERT_EQ(prices[2]->to_asset, "btc");
|
||||
ASSERT_EQ(prices[2]->price, "0.00261901");
|
||||
ASSERT_EQ(prices[2]->asset_24h_change, "0.5871625385632929");
|
||||
|
||||
ASSERT_EQ(prices[3]->from_asset, "link");
|
||||
ASSERT_EQ(prices[3]->to_asset, "usd");
|
||||
ASSERT_EQ(prices[3]->price, "83.77");
|
||||
ASSERT_EQ(prices[3]->asset_24h_change, "1.7646208048244043");
|
||||
|
||||
/*
|
||||
ASSERT_EQ(price, "0.694503");
|
||||
// 2 value responses happen now for the alias and the full name
|
||||
// We always parse only the first.
|
||||
@@ -35,6 +79,7 @@ TEST(AssetRatioResponseParserUnitTest, ParseAssetPrice) {
|
||||
ASSERT_FALSE(ParseAssetPrice(json, &price));
|
||||
json = "";
|
||||
ASSERT_FALSE(ParseAssetPrice(json, &price));
|
||||
*/
|
||||
}
|
||||
|
||||
TEST(AssetRatioResponseParserUnitTest, ParseAssetPriceHistory) {
|
||||
|
||||
@@ -75,6 +75,13 @@ struct AssetTimePrice {
|
||||
string price;
|
||||
};
|
||||
|
||||
struct AssetPrice {
|
||||
string from_asset;
|
||||
string to_asset;
|
||||
string price;
|
||||
string asset_24h_change;
|
||||
};
|
||||
|
||||
struct SwapParams {
|
||||
string taker_address;
|
||||
string sell_amount;
|
||||
@@ -124,7 +131,7 @@ interface WalletHandler {
|
||||
array<string> walletAccountNames);
|
||||
LockWallet();
|
||||
UnlockWallet(string password) => (bool isWalletUnlocked);
|
||||
GetAssetPrice(string asset) => (bool success, string price);
|
||||
GetAssetPrice(array<string> from_assets, array<string> to_assets) => (bool success, array<AssetTimePrice> values);
|
||||
GetAssetPriceHistory(string asset, AssetPriceTimeframe timeframe) =>
|
||||
(bool success, array<AssetTimePrice> values);
|
||||
GetPriceQuote(SwapParams params) => (bool success, SwapResponse response);
|
||||
|
||||
Reference in New Issue
Block a user