-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implements a Context that talks to the store mock server (#293)
This PR adds a replacement for the thin MS Store API wrapper by a not-so-thin client of the store mock server we presented in #268. For ease of implementation I opted for writing a Windows specific version of the mock client. You'll see that it's indeed not that hard to talk to REST API's using WinRT. Since we adopted a very consistent calling convention in our REST API, a single `call()` function models very well talking to any of the exposed endpoints, so that the `WinMockContext` methods just wrap around it passing the appropriate arguments. The actual HTTP call could be more elaborated, using streamed data to prevent against large responses, but I don't believe this is necessary, because we control the server and we know the responses are rather small. Since this client code is not trivial, I added a few test cases for it, but I didn't plug it in CI right away because running it is a bit cumbersome: 1. We need to acquire a port to run the store mock server 2. Export the environment variable `UP4W_MS_STORE_MOCK_ENDPOINT` with the address the mock server will run (`localhost:port`) 3. Run the store mock server passing that port and the testcase.yaml configuration file. 4. Export the environment variable `UP4W_TEST_WITH_MS_STORE_MOCK` so the replacement context takes the stage instead of the production one. 5. Configure and build with CMake 6. Run CTest. Not that complicated, but requires Go to build the store mock server and a few lines of powershell scripting to do the port thingy. So I found that this is not the appropriate moment for pluging this on in the CI. Also, the mock client is for supporting testing higher level components, and this is testing it, not the targets. Here's a gist of how I run on my computer: ```powershell cd ./storeapi/test $listener = new-object System.Net.Sockets.TcpListener("127.0.0.1",0) $listener.Start() $env:UP4W_TEST_WITH_MS_STORE_MOCK=1 cmake -S . -B .\out\build\ cmake --build .\out\build $env:UP4W_MS_STORE_MOCK_ENDPOINT="$($listener.LocalEndpoint.Address):$($listener.LocalEndopint.Port)"; $listener.Stop() go run ..\..\mocks\storeserver\storeserver\main.go -a $env:UP4W_MS_STORE_MOCK_ENDPOINT .\storeapi\test\testcase.yaml # On another terminal ctest --test-dir .\out\dir --output-on-failure ``` Fixes Udeng-1258
- Loading branch information
Showing
8 changed files
with
499 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
#pragma once | ||
|
||
#include "impl/StoreContext.hpp" | ||
#include "impl/WinMockContext.hpp" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,217 @@ | ||
#if defined UP4W_TEST_WITH_MS_STORE_MOCK && defined _MSC_VER | ||
|
||
#include "WinMockContext.hpp" | ||
|
||
#include <Windows.h> | ||
#include <processenv.h> | ||
#include <winrt/Windows.Data.Json.h> | ||
#include <winrt/Windows.Foundation.Collections.h> | ||
#include <winrt/Windows.Foundation.h> | ||
#include <winrt/Windows.Web.Http.h> | ||
#include <winrt/base.h> | ||
|
||
#include <algorithm> | ||
#include <cassert> | ||
#include <functional> | ||
#include <iterator> | ||
#include <sstream> | ||
#include <unordered_map> | ||
#include <utility> | ||
|
||
#include "WinRTHelpers.hpp" | ||
|
||
namespace StoreApi::impl { | ||
|
||
using winrt::Windows::Data::Json::IJsonValue; | ||
using winrt::Windows::Data::Json::JsonArray; | ||
using winrt::Windows::Data::Json::JsonObject; | ||
using UrlParams = std::unordered_multimap<winrt::hstring, winrt::hstring>; | ||
using winrt::Windows::Foundation::Uri; | ||
|
||
using winrt::Windows::Foundation::IAsyncOperation; | ||
|
||
namespace { | ||
// Handles the HTTP calls, returning a JsonObject containing the mock server | ||
// response. Notice that the supplied path is relative. | ||
IAsyncOperation<JsonObject> call(winrt::hstring relativePath, | ||
UrlParams const& params = {}); | ||
|
||
/// Translates a textual representation of a purchase transaction result into an | ||
/// instance of the PurchaseStatus enum. | ||
StoreApi::PurchaseStatus translate(winrt::hstring const& purchaseStatus); | ||
} // namespace | ||
|
||
std::vector<WinMockContext::Product> WinMockContext::GetProducts( | ||
std::span<const std::string> kinds, | ||
std::span<const std::string> ids) const { | ||
assert(!kinds.empty() && "kinds vector cannot be empty"); | ||
assert(!ids.empty() && "ids vector cannot be empty"); | ||
|
||
auto hKinds = to_hstrings(kinds); | ||
auto hIds = to_hstrings(ids); | ||
|
||
UrlParams parameters; | ||
|
||
std::ranges::transform( | ||
hKinds, std::inserter(parameters, parameters.end()), | ||
[](winrt::hstring k) { return std::make_pair(L"kinds", k); }); | ||
std::ranges::transform( | ||
hIds, std::inserter(parameters, parameters.end()), | ||
[](winrt::hstring id) { return std::make_pair(L"ids", id); }); | ||
|
||
auto productsJson = call(L"/products", parameters).get(); | ||
|
||
JsonArray products = productsJson.GetNamedArray(L"products"); | ||
|
||
std::vector<WinMockContext::Product> result; | ||
result.reserve(products.Size()); | ||
for (const IJsonValue& product : products) { | ||
JsonObject p = product.GetObject(); | ||
result.emplace_back(WinMockContext::Product{p}); | ||
} | ||
|
||
return result; | ||
} | ||
|
||
std::vector<std::string> WinMockContext::AllLocallyAuthenticatedUserHashes() { | ||
JsonObject usersList = call(L"/allauthenticatedusers").get(); | ||
JsonArray users = usersList.GetNamedArray(L"users"); | ||
|
||
std::vector<std::string> result; | ||
result.reserve(users.Size()); | ||
for (const IJsonValue& user : users) { | ||
result.emplace_back(winrt::to_string(user.GetString())); | ||
} | ||
|
||
return result; | ||
} | ||
|
||
std::string WinMockContext::GenerateUserJwt(std::string token, | ||
std::string userId) const { | ||
assert(!token.empty() && "Azure AD token is required"); | ||
JsonObject res{nullptr}; | ||
|
||
UrlParams parameters{ | ||
{L"serviceticket", winrt::to_hstring(token)}, | ||
}; | ||
if (!userId.empty()) { | ||
parameters.insert({L"publisheruserid", winrt::to_hstring(userId)}); | ||
} | ||
|
||
res = call(L"generateuserjwt", parameters).get(); | ||
|
||
return winrt::to_string(res.GetNamedString(L"jwt")); | ||
} | ||
|
||
// NOOP, for now at least. | ||
void WinMockContext::InitDialogs(Window parentWindow) {} | ||
|
||
void WinMockContext::Product::PromptUserForPurchase( | ||
PurchaseCallback callback) const { | ||
using winrt::Windows::Foundation::AsyncStatus; | ||
|
||
UrlParams params{{L"id", winrt::to_hstring(storeID)}}; | ||
call(L"purchase", params) | ||
.Completed( | ||
[cb = std::move(callback)](IAsyncOperation<JsonObject> const& async, | ||
AsyncStatus const& asyncStatus) { | ||
PurchaseStatus translated; | ||
std::int32_t error = async.ErrorCode().value; | ||
if (error != 0 || asyncStatus == AsyncStatus::Error) { | ||
translated = PurchaseStatus::NetworkError; | ||
} else { | ||
auto json = async.GetResults(); | ||
auto status = json.GetNamedString(L"status"); | ||
translated = translate(status); | ||
} | ||
|
||
cb(translated, error); | ||
}); | ||
} | ||
|
||
WinMockContext::Product::Product(JsonObject const& json) | ||
: storeID{winrt::to_string(json.GetNamedString(L"StoreID"))}, | ||
title{winrt::to_string(json.GetNamedString(L"Title"))}, | ||
description{winrt::to_string(json.GetNamedString(L"Description"))}, | ||
productKind{winrt::to_string(json.GetNamedString(L"ProductKind"))}, | ||
expirationDate{}, | ||
isInUserCollection{json.GetNamedBoolean(L"IsInUserCollection")} | ||
|
||
{ | ||
std::chrono::system_clock::time_point tp{}; | ||
std::stringstream ss{ | ||
winrt::to_string(json.GetNamedString(L"ExpirationDate"))}; | ||
ss >> std::chrono::parse("%FT%T%Tz", tp); | ||
expirationDate = tp; | ||
} | ||
|
||
namespace { | ||
// Returns the mock server endpoint address and port by reading the environment | ||
// variable UP4W_MS_STORE_MOCK_ENDPOINT or localhost:9 if the variable is unset. | ||
winrt::hstring readStoreMockEndpoint() { | ||
constexpr std::size_t endpointSize = 20; | ||
wchar_t endpoint[endpointSize]; | ||
if (0 == GetEnvironmentVariableW(L"UP4W_MS_STORE_MOCK_ENDPOINT", endpoint, | ||
endpointSize)) { | ||
return L"127.0.0.1:9"; // Discard protocol | ||
} | ||
return endpoint; | ||
} | ||
|
||
// Builds a complete URI with a URL encoded query if params are passed. | ||
IAsyncOperation<Uri> buildUri(winrt::hstring& relativePath, | ||
UrlParams const& params) { | ||
// Being tied t an environment variable means that it cannot change after | ||
// program's creation. Thus, there is no reason for recreating this value | ||
// every call. | ||
static winrt::hstring endpoint = L"http://" + readStoreMockEndpoint(); | ||
|
||
if (!params.empty()) { | ||
winrt::Windows::Web::Http::HttpFormUrlEncodedContent p{ | ||
{params.begin(), params.end()}, | ||
}; | ||
auto rawParams = co_await p.ReadAsStringAsync(); | ||
// http://127.0.0.1:56567/relativePath?param=value... | ||
co_return Uri{endpoint, relativePath + L'?' + rawParams}; | ||
} | ||
// http://127.0.0.1:56567/relativePath | ||
co_return Uri{endpoint, relativePath}; | ||
} | ||
|
||
IAsyncOperation<JsonObject> call(winrt::hstring relativePath, | ||
UrlParams const& params) { | ||
// Initialize only once. | ||
static winrt::Windows::Web::Http::HttpClient httpClient{}; | ||
|
||
Uri uri = co_await buildUri(relativePath, params); | ||
|
||
// We can rely on the fact that our mock will return small pieces of data | ||
// certainly under 1 KB. | ||
winrt::hstring contents = co_await httpClient.GetStringAsync(uri); | ||
co_return JsonObject::Parse(contents); | ||
} | ||
|
||
StoreApi::PurchaseStatus translate(winrt::hstring const& purchaseStatus) { | ||
if (purchaseStatus == L"Succeeded") { | ||
return PurchaseStatus::Succeeded; | ||
} | ||
|
||
if (purchaseStatus == L"AlreadyPurchased") { | ||
return StoreApi::PurchaseStatus::AlreadyPurchased; | ||
} | ||
|
||
if (purchaseStatus == L"NotPurchased") { | ||
return StoreApi::PurchaseStatus::UserGaveUp; | ||
} | ||
|
||
if (purchaseStatus == L"ServerError") { | ||
return StoreApi::PurchaseStatus::ServerError; | ||
} | ||
|
||
assert(false && "Missing enum elements to translate StorePurchaseStatus."); | ||
return StoreApi::PurchaseStatus::Unknown; // To be future proof. | ||
} | ||
|
||
} // namespace | ||
} // namespace StoreApi::impl | ||
#endif // UP4W_TEST_WITH_MS_STORE_MOCK |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
#pragma once | ||
|
||
/// Implements a replacement for [StoreContext] which talks to the MS Store Mock | ||
/// Server (still on Windows) instead of the real MS APIs. | ||
/// DO NOT USE IN PRODUCTION. | ||
#if defined UP4W_TEST_WITH_MS_STORE_MOCK && defined _MSC_VER | ||
|
||
#include <chrono> | ||
#include <span> | ||
#include <string> | ||
#include <type_traits> | ||
#include <vector> | ||
|
||
#include "../Purchase.hpp" | ||
|
||
namespace winrt::Windows::Data::Json { | ||
class JsonObject; | ||
} | ||
|
||
namespace StoreApi::impl { | ||
|
||
class WinMockContext { | ||
public: | ||
using Window = std::int32_t; | ||
class Product { | ||
std::string storeID; | ||
std::string title; | ||
std::string description; | ||
std::string productKind; | ||
std::chrono::system_clock::time_point expirationDate; | ||
bool isInUserCollection; | ||
|
||
public: | ||
// Whether the current user owns this product. | ||
bool IsInUserCollection() const { return isInUserCollection; } | ||
|
||
// Assuming this is a Subscription add-on product the current user __owns__, | ||
// returns the expiration date of the current billing period. | ||
std::chrono::system_clock::time_point CurrentExpirationDate() const { | ||
return expirationDate; | ||
} | ||
|
||
protected: | ||
// Assuming this is a Subscription add-on product the current user __does | ||
// not own__, requests the runtime to display a purchase flow so users can | ||
// subscribe to this product. THis function returns early, the result will | ||
// eventually arrive through the supplied callback. This must be called from | ||
// a UI thread with the underlying store context initialized with the parent | ||
// GUI window because we need to render native dialogs. Thus, access to this | ||
// method must be protected so we can ensure it can only happen with GUI | ||
// clients, making API misuse harder to happen. | ||
// See | ||
// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storeproduct.requestpurchaseasync | ||
void PromptUserForPurchase(PurchaseCallback callback) const; | ||
|
||
public: | ||
/// Creates a product from a JsonObject obtained from a call to the mock | ||
/// server containing the relevant information. | ||
explicit Product(winrt::Windows::Data::Json::JsonObject const& json); | ||
Product() = default; | ||
}; | ||
|
||
// Returns a collection of products matching the supplied [kinds] and [ids]. | ||
// Ids must match the Product IDs in Partner Center. Kinds can be: | ||
// Application; Game; Consumable; UnmanagedConsumable; Durable. See | ||
// https://learn.microsoft.com/en-us/uwp/api/windows.services.store.storeproduct.productkind#remarks | ||
std::vector<Product> GetProducts(std::span<const std::string> kinds, | ||
std::span<const std::string> ids) const; | ||
|
||
// Generates the user ID key (a.k.a the JWT) provided the server AAD [token] | ||
// and the [userId] the caller wants to have encoded in the JWT. | ||
std::string GenerateUserJwt(std::string token, std::string userId) const; | ||
|
||
// Initializes the GUI "subsystem" with the [parentWindow] handle so we can | ||
// render native dialogs, such as when purchase or other kinds of | ||
// authorization are required. | ||
void InitDialogs(Window parentWindow); | ||
|
||
// Returns a collection of hashes of all locally authenticated users running | ||
// in this session. Most likely the collection will contain a single element. | ||
static std::vector<std::string> AllLocallyAuthenticatedUserHashes(); | ||
}; | ||
|
||
} // namespace StoreApi::impl | ||
|
||
namespace StoreApi { | ||
using DefaultContext = impl::WinMockContext; | ||
} | ||
|
||
#endif // UP4W_TEST_WITH_MS_STORE_MOCK && _MSC_VER |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
#include <winrt/base.h> | ||
|
||
#include <algorithm> | ||
#include <span> | ||
#include <string> | ||
#include <vector> | ||
|
||
namespace StoreApi::impl { | ||
// Converts a span of strings into a vector of hstrings, needed when passing | ||
// a collection of string as a parameter to an async operation. | ||
inline std::vector<winrt::hstring> to_hstrings( | ||
std::span<const std::string> input) { | ||
std::vector<winrt::hstring> hStrs; | ||
hStrs.reserve(input.size()); | ||
std::ranges::transform(input, std::back_inserter(hStrs), | ||
&winrt::to_hstring<std::string>); | ||
return hStrs; | ||
} | ||
} // namespace StoreApi::impl |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.