diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 197f2bb8..b32abd5c 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -59,7 +59,7 @@ jobs: c_compiler: gcc steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true @@ -79,7 +79,7 @@ jobs: fi - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3f848dc9..e9a1a9da 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index 188f7a50..b5c40b22 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: fetch-depth: 0 # Fetch all history for tags and commits diff --git a/README.md b/README.md index 747447e2..26602df9 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,58 @@ void myLogHandler(const char* message, GALoggerMessageType type) gameAnalytics_configureCustomLogHandler(myLogHandler); ``` +### Custom HTTP client + +By default, the SDK uses cURL for HTTP requests. If you need to use a different HTTP library (e.g. on consoles or custom platforms), you can provide your own implementation by subclassing `GAHttpClient`: + +``` c++ +#include "GameAnalytics/GAHttpClient.h" + +class MyHttpClient : public gameanalytics::GAHttpClient +{ +public: + void initialize() override + { + // Set up your HTTP library + } + + void cleanup() override + { + // Tear down your HTTP library + } + + Response sendRequest( + std::string const& url, + std::string const& auth, + std::vector const& payloadData, + bool useGzip, + void* userData) override + { + Response response; + + // Use your HTTP library to POST payloadData to url. + // Set the following headers: + // - auth (e.g. "Authorization: ...") + // - "Content-Type: application/json" + // - "Content-Encoding: gzip" (if useGzip is true) + // + // Fill in response.code with the HTTP status code. + // Fill in response.packet with the response body bytes. + + return response; + } +}; +``` + +Register it **before** calling `initialize()`: + +``` c++ +gameanalytics::GameAnalytics::configureHttpClient(std::make_unique()); +gameanalytics::GameAnalytics::initialize("", ""); +``` + +If `configureHttpClient` is not called, the built-in cURL implementation is used. + ### Configuration Example: diff --git a/include/GameAnalytics/GAHttpClient.h b/include/GameAnalytics/GAHttpClient.h new file mode 100644 index 00000000..6ddd375c --- /dev/null +++ b/include/GameAnalytics/GAHttpClient.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace gameanalytics +{ + class GAHttpClient + { + public: + + struct Response + { + long code = -1; + std::vector packet; + + inline std::string_view toString() const + { + if(packet.empty()) return {}; + return std::string_view((const char*)packet.data(), packet.size()); + } + }; + + virtual ~GAHttpClient() {}; + + virtual void initialize() = 0; + + virtual void cleanup() = 0; + + virtual Response sendRequest( + std::string const& url, + std::string const& auth, + std::vector const& payloadData, + bool useGzip, + void* userData) = 0; + }; + +} // namespace gameanalytics diff --git a/include/GameAnalytics/GameAnalytics.h b/include/GameAnalytics/GameAnalytics.h index 31bbcb49..03ea6fb1 100644 --- a/include/GameAnalytics/GameAnalytics.h +++ b/include/GameAnalytics/GameAnalytics.h @@ -6,9 +6,11 @@ #pragma once #include "GameAnalytics/GATypes.h" +#include "GameAnalytics/GAHttpClient.h" namespace gameanalytics { + class GameAnalytics { public: @@ -61,6 +63,10 @@ namespace gameanalytics static void configureExternalUserId(std::string const& extId); + // Set a custom HTTP implementation. Must be called before initialize(). + // If not called, the default cURL implementation is used. + static void configureHttpClient(std::unique_ptr httpClient); + // initialize - starting SDK (need configuration before starting) static void initialize(std::string const& gameKey, std::string const& gameSecret); diff --git a/source/gameanalytics/GAEvents.cpp b/source/gameanalytics/GAEvents.cpp index 64d58ecd..e40616ad 100644 --- a/source/gameanalytics/GAEvents.cpp +++ b/source/gameanalytics/GAEvents.cpp @@ -621,7 +621,7 @@ namespace gameanalytics responseEnum = http.sendEventsInArray(dataDict, payloadArray); #endif - if (responseEnum == http::Ok) + if (responseEnum == http::Ok || responseEnum == http::NoContent) { // Delete events store::GAStore::executeQuerySync(deleteSql); diff --git a/source/gameanalytics/GAHTTPApi.cpp b/source/gameanalytics/GAHTTPApi.cpp index 16e5bbe3..bffb2fff 100644 --- a/source/gameanalytics/GAHTTPApi.cpp +++ b/source/gameanalytics/GAHTTPApi.cpp @@ -10,25 +10,29 @@ #include "GAUtilities.h" #include "GAValidator.h" +#include "Http/GAHttpCurl.h" + namespace gameanalytics { namespace http { - size_t writefunc(void *ptr, size_t size, size_t nmemb, ResponseData *s) - { - const size_t new_len = s->packet.size() + size * nmemb + 1; - s->packet.reserve(new_len); + constexpr int HTTP_RESPONSE_OK = 200; + constexpr int HTTP_RESPONSE_CREATED = 201; + constexpr int HTTP_RESPONSE_NO_CONTENT = 204; + constexpr int HTTP_RESPONSE_BAD_REQUEST = 400; + constexpr int HTTP_RESPONSE_UNAUTHORIZED = 401; + constexpr int HTTP_RESPONSE_INTERNAL_ERROR = 500; - s->packet.insert(s->packet.end(), reinterpret_cast(ptr), reinterpret_cast(ptr) + size * nmemb); - s->packet.push_back('\0'); - - return size*nmemb; - } + std::unique_ptr GAHTTPApi::pendingCustomImpl = nullptr; // Constructor - setup the basic information for HTTP - GAHTTPApi::GAHTTPApi() + GAHTTPApi::GAHTTPApi(): + impl(pendingCustomImpl ? std::move(pendingCustomImpl) : std::make_unique()) { - curl_global_init(CURL_GLOBAL_DEFAULT); + if(impl) + { + impl->initialize(); + } baseUrl = protocol + "://" + hostName + "/" + version; remoteConfigsBaseUrl = protocol + "://" + hostName + "/remote_configs/" + remoteConfigsVersion; @@ -43,7 +47,10 @@ namespace gameanalytics GAHTTPApi::~GAHTTPApi() { - curl_global_cleanup(); + if(impl) + { + impl->cleanup(); + } } GAHTTPApi& GAHTTPApi::getInstance() @@ -51,8 +58,19 @@ namespace gameanalytics return state::GAState::getInstance()._gaHttp; } + void GAHTTPApi::setCustomHttpImpl(std::unique_ptr customImpl) + { + pendingCustomImpl = std::move(customImpl); + } + EGAHTTPApiResponse GAHTTPApi::requestInitReturningDict(json& json_out, std::string const& configsHash) { + if(!impl) + { + logging::GALogger::e("Invalid http implmentation"); + return SdkError; + } + std::string gameKey = state::GAState::getGameKey(); // Generate URL @@ -73,49 +91,31 @@ namespace gameanalytics std::vector payloadData = createPayloadData(jsonString, useGzip); - CURL* curl = nullptr; - CURLcode res; - curl = curl_easy_init(); - if (!curl) - { - return NoResponse; - } - - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - - ResponseData s; - - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s); + std::string const auth = createAuth(payloadData); + GAHttpClient::Response response = impl->sendRequest(url, auth, payloadData, useGzip, nullptr); - std::vector authorization = createRequest(curl, url, payloadData, useGzip); - - res = curl_easy_perform(curl); - if (res != CURLE_OK) + if(response.code < 0) { - logging::GALogger::d(curl_easy_strerror(res)); - return NoResponse; + logging::GALogger::e("Request failed: %s", url.c_str()); + return EGAHTTPApiResponse::SdkError; } - long response_code{}; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - curl_easy_cleanup(curl); + std::string_view content = response.toString(); // process the response - logging::GALogger::d("init request content: %s, json: %s", s.toString().c_str(), jsonString.c_str()); - - json requestJsonDict = json::parse(s.toString()); + logging::GALogger::d("init request content: %.*s, json: %s", (int)content.size(), content.data(), jsonString.c_str()); - EGAHTTPApiResponse requestResponseEnum = processRequestResponse(response_code, s.packet.data(), "Init"); + EGAHTTPApiResponse requestResponseEnum = processRequestResponse(response, "Init"); // if not 200 result if (requestResponseEnum != Ok && requestResponseEnum != Created && requestResponseEnum != BadRequest) { - logging::GALogger::d("Failed Init Call. URL: %s, JSONString: %s, Authorization: %s", url.c_str(), jsonString.c_str(), authorization.data()); + logging::GALogger::d("Failed Init Call. URL: %s, JSONString: %s, Authorization: %s", url.c_str(), jsonString.c_str(), auth.c_str()); return requestResponseEnum; } + json requestJsonDict = json::parse(content); if (requestJsonDict.is_null()) { logging::GALogger::d("Failed Init Call. Json decoding failed"); @@ -154,18 +154,35 @@ namespace gameanalytics } } + std::string GAHTTPApi::createAuth(std::vector const& payloadData) + { + const std::string key = state::GAState::getGameSecret(); + + std::vector authorization; + utilities::GAUtilities::hmacWithKey(key.c_str(), payloadData, authorization); + std::string auth = "Authorization: " + std::string(reinterpret_cast(authorization.data()), authorization.size()); + + return auth; + } + EGAHTTPApiResponse GAHTTPApi::sendEventsInArray(json& json_out, const json& eventArray) { + if(!impl) + { + logging::GALogger::e("Invalid http implmentation"); + return SdkError; + } + if (eventArray.empty()) { logging::GALogger::d("sendEventsInArray called with missing eventArray"); return JsonEncodeFailed; } - const std::string gameKey = state::GAState::getGameKey(); - try { + const std::string gameKey = state::GAState::getGameKey(); + // Generate URL const std::string url = baseUrl + '/' + gameKey + '/' + eventsUrlPath; logging::GALogger::d("Sending 'events' URL: %s", url.c_str()); @@ -179,47 +196,37 @@ namespace gameanalytics std::vector payloadData = createPayloadData(jsonString, useGzip); - CURL* curl = nullptr; - CURLcode res{}; - curl = curl_easy_init(); - if (!curl) + std::string const auth = createAuth(payloadData); + GAHttpClient::Response response = impl->sendRequest(url, auth, payloadData, useGzip, nullptr); + + if(response.code < 0) { - return NoResponse; + logging::GALogger::e("Request failed: %s", url.c_str()); + return EGAHTTPApiResponse::SdkError; } - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + std::string_view content = response.toString(); + logging::GALogger::d("body: %.*s", (int)content.size(), content.data()); - ResponseData s = {}; + EGAHTTPApiResponse requestResponseEnum = processRequestResponse(response, "Events"); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s); + const bool isValidResponse = + requestResponseEnum == Ok || requestResponseEnum == Created || requestResponseEnum == NoContent; - std::vector authorization = createRequest(curl, url, payloadData, useGzip); - - res = curl_easy_perform(curl); - if (res != CURLE_OK) + // if not 200 result + if (!isValidResponse && requestResponseEnum != BadRequest) { - logging::GALogger::d(curl_easy_strerror(res)); - return NoResponse; + logging::GALogger::d("Failed Events Call. URL: %s, JSONString: %s, Authorization: %s", url.c_str(), jsonString.c_str(), auth.c_str()); + return requestResponseEnum; } - long response_code{}; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - curl_easy_cleanup(curl); - - logging::GALogger::d("body: %s", s.toString().c_str()); - - EGAHTTPApiResponse requestResponseEnum = processRequestResponse(response_code, s.packet.data(), "Events"); - - // if not 200 result - if (requestResponseEnum != Ok && requestResponseEnum != Created && requestResponseEnum != BadRequest) + if(requestResponseEnum == NoContent) { - logging::GALogger::d("Failed Events Call. URL: %s, JSONString: %s, Authorization: %s", url.c_str(), jsonString.c_str(), authorization.data()); return requestResponseEnum; } // decode JSON - json requestJsonDict = json::parse(s.toString()); + json requestJsonDict = json::parse(response.toString()); if (requestJsonDict.is_null()) { return JsonDecodeFailed; @@ -253,6 +260,12 @@ namespace gameanalytics void GAHTTPApi::sendSdkErrorEvent(EGASdkErrorCategory category, EGASdkErrorArea area, EGASdkErrorAction action, EGASdkErrorParameter parameter, std::string const& reason, std::string const& gameKey, const std::string& secretKey) { + if(!impl) + { + logging::GALogger::e("Invalid http implmentation"); + return; + } + if(!state::GAState::isEventSubmissionEnabled()) { return; @@ -279,7 +292,7 @@ namespace gameanalytics utilities::addIfNotEmpty(jsonObject, "error_parameter", sdkErrorParameterString(parameter)); utilities::addIfNotEmpty(jsonObject, "reason", reason); - json eventArray; + json eventArray = json::array(); eventArray.push_back(jsonObject); std::string payloadJSONString = eventArray.dump(); @@ -291,7 +304,6 @@ namespace gameanalytics logging::GALogger::d("sendSdkErrorEvent json: %s", payloadJSONString.c_str()); -#if !NO_ASYNC ErrorType errorType = std::make_tuple(category, area); bool useGzip = this->useGzip; @@ -324,47 +336,29 @@ namespace gameanalytics std::vector payloadData = getInstance().createPayloadData(payloadJSONString, useGzip); - CURL *curl = nullptr; - CURLcode res; - curl = curl_easy_init(); - if(!curl) - { - return; - } - - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - - ResponseData s = {}; - - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s); - - getInstance().createRequest(curl, url.data(), payloadData, useGzip); + std::string auth = createAuth(payloadData); + GAHttpClient::Response response = impl->sendRequest(url, auth, payloadData, useGzip, nullptr); - res = curl_easy_perform(curl); - if(res != CURLE_OK) + if(response.code < 0) { - logging::GALogger::d(curl_easy_strerror(res)); + logging::GALogger::e("Request failed: %s", url.c_str()); return; } - long statusCode{}; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &statusCode); - curl_easy_cleanup(curl); + std::string_view content = response.toString(); // process the response - logging::GALogger::d("sdk error content : %s", s.toString().c_str());; + logging::GALogger::d("sdk error content : %.*s", (int)content.size(), content.data()); // if not 200 result - if (statusCode != 200) + if (response.code != HTTP_RESPONSE_OK && response.code != HTTP_RESPONSE_NO_CONTENT) { - logging::GALogger::d("sdk error failed. response code not 200. status code: %u", CURLE_OK); + logging::GALogger::d("sdk error failed. response code not 200 or 204. status code: %ld", response.code); return; } countMap[errorType] = countMap[errorType] + 1; }); -#endif } std::vector GAHTTPApi::createPayloadData(std::string const& payload, bool gzip) @@ -389,80 +383,49 @@ namespace gameanalytics return payloadData; } - std::vector GAHTTPApi::createRequest(CURL *curl, std::string const& url, const std::vector& payloadData, bool gzip) - { - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_POST, 1L); - struct curl_slist *header = NULL; - - if (gzip) - { - header = curl_slist_append(header, "Content-Encoding: gzip"); - } - - // create authorization hash - std::string const key = state::GAState::getGameSecret(); - - std::vector authorization; - utilities::GAUtilities::hmacWithKey(key.c_str(), payloadData, authorization); - std::string auth = "Authorization: " + std::string(reinterpret_cast(authorization.data()), authorization.size()); - - header = curl_slist_append(header, auth.c_str()); - - // always JSON - header = curl_slist_append(header, "Content-Type: application/json"); - - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payloadData.data()); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, payloadData.size()); - - return authorization; - } - - EGAHTTPApiResponse GAHTTPApi::processRequestResponse(long statusCode, const char* body, const char* requestId) + EGAHTTPApiResponse GAHTTPApi::processRequestResponse(GAHttpClient::Response const& response, std::string const& requestId) { // if no result - often no connection - if (utilities::GAUtilities::isStringNullOrEmpty(body)) + if (response.packet.empty() && response.code != HTTP_RESPONSE_NO_CONTENT) { - logging::GALogger::d("%s request. failed. Might be no connection. Status code: %ld", requestId, statusCode); + logging::GALogger::d("%s request. failed. Might be no connection. Status code: %ld", requestId.c_str(), response.code); return NoResponse; } // ok - if (statusCode == 200) + if (response.code == HTTP_RESPONSE_OK) { return Ok; } - if (statusCode == 201) + if (response.code == HTTP_RESPONSE_CREATED) { return Created; } + if(response.code == HTTP_RESPONSE_NO_CONTENT) + { + return NoContent; + } // 401 can return 0 status - if (statusCode == 0 || statusCode == 401) + if (response.code == 0 || response.code == HTTP_RESPONSE_UNAUTHORIZED) { - logging::GALogger::d("%s request. 401 - Unauthorized.", requestId); + logging::GALogger::d("%s request. 401 - Unauthorized.", requestId.c_str()); return Unauthorized; } - if (statusCode == 400) + if (response.code == HTTP_RESPONSE_BAD_REQUEST) { - logging::GALogger::d("%s request. 400 - Bad Request.", requestId); + logging::GALogger::d("%s request. 400 - Bad Request.", requestId.c_str()); return BadRequest; } - if (statusCode == 500) + if (response.code == HTTP_RESPONSE_INTERNAL_ERROR) { - logging::GALogger::d("%s request. 500 - Internal Server Error.", requestId); + logging::GALogger::d("%s request. 500 - Internal Server Error.", requestId.c_str()); return InternalServerError; } - return UnknownResponseCode; - } - std::string ResponseData::toString() const - { - return std::string(packet.begin(), packet.end()); + return UnknownResponseCode; } } } diff --git a/source/gameanalytics/GAHTTPApi.h b/source/gameanalytics/GAHTTPApi.h index 3798bf00..065a15dc 100644 --- a/source/gameanalytics/GAHTTPApi.h +++ b/source/gameanalytics/GAHTTPApi.h @@ -6,7 +6,7 @@ #pragma once #include "GACommon.h" -#include +#include "GameAnalytics/GAHttpClient.h" #include #include @@ -18,9 +18,11 @@ namespace gameanalytics { namespace http { - enum EGAHTTPApiResponse { + // sdk is misconfigured + SdkError = -1, + // client NoResponse = 0, BadResponse = 1, @@ -28,12 +30,13 @@ namespace gameanalytics JsonEncodeFailed = 3, JsonDecodeFailed = 4, // server - InternalServerError = 5, + InternalServerError = 5, // 500 BadRequest = 6, // 400 Unauthorized = 7, // 401 UnknownResponseCode = 8, - Ok = 9, - Created = 10, + Ok = 9, // 200 + Created = 10, // 201 + NoContent = 11, // 204 InternalError }; @@ -102,12 +105,6 @@ namespace gameanalytics Message = 14 }; - struct ResponseData - { - std::vector packet; - std::string toString() const; - }; - typedef std::tuple ErrorType; class GAHTTPApi @@ -130,6 +127,8 @@ namespace gameanalytics static GAHTTPApi& getInstance(); + static void setCustomHttpImpl(std::unique_ptr customImpl); + EGAHTTPApiResponse requestInitReturningDict(json& json_out, std::string const& configsHash); EGAHTTPApiResponse sendEventsInArray(json& json_out, const json& eventArray); void sendSdkErrorEvent(EGASdkErrorCategory category, EGASdkErrorArea area, EGASdkErrorAction action, EGASdkErrorParameter parameter, std::string const& reason, std::string const& gameKey, std::string const& secretKey); @@ -141,9 +140,10 @@ namespace gameanalytics GAHTTPApi(const GAHTTPApi&) = delete; GAHTTPApi& operator=(const GAHTTPApi&) = delete; std::vector createPayloadData(std::string const& payload, bool gzip); + std::string createAuth(std::vector const& payload); + EGAHTTPApiResponse processRequestResponse(GAHttpClient::Response const& response, std::string const& requestId); - std::vector createRequest(CURL *curl, std::string const& url, const std::vector& payloadData, bool gzip); - EGAHTTPApiResponse processRequestResponse(long statusCode, const char* body, const char* requestId); + std::unique_ptr impl; std::string protocol = PROTOCOL; std::string hostName = HOST_NAME; @@ -162,20 +162,8 @@ namespace gameanalytics std::map countMap; std::map timestampMap; -#if USE_UWP && defined(USE_UWP_HTTP) - Windows::Web::Http::HttpClient^ httpClient; -#endif - }; - -#if USE_UWP && defined(USE_UWP_HTTP) - ref class GANetworkStatus sealed - { - internal: - static void NetworkInformationOnNetworkStatusChanged(Platform::Object^ sender); - static void CheckInternetAccess(); - static bool hasInternetAccess; + static std::unique_ptr pendingCustomImpl; }; -#endif constexpr const char* GAHTTPApi::sdkErrorCategoryString(EGASdkErrorCategory value) { diff --git a/source/gameanalytics/GameAnalytics.cpp b/source/gameanalytics/GameAnalytics.cpp index 8d524620..f8c68404 100644 --- a/source/gameanalytics/GameAnalytics.cpp +++ b/source/gameanalytics/GameAnalytics.cpp @@ -350,6 +350,22 @@ namespace gameanalytics // ----------------------- INITIALIZE ---------------------- // + void GameAnalytics::configureHttpClient(std::unique_ptr httpClient) + { + if(_endThread) + { + return; + } + + if (isSdkReady(true, false)) + { + logging::GALogger::w("HTTP client must be set before SDK is initialized."); + return; + } + + http::GAHTTPApi::setCustomHttpImpl(std::move(httpClient)); + } + void GameAnalytics::initialize(std::string const& gameKey, std::string const& gameSecret) { if(_endThread) diff --git a/source/gameanalytics/Http/GAHttpCurl.cpp b/source/gameanalytics/Http/GAHttpCurl.cpp new file mode 100644 index 00000000..d70e0d62 --- /dev/null +++ b/source/gameanalytics/Http/GAHttpCurl.cpp @@ -0,0 +1,87 @@ +#include "Http/GAHttpCurl.h" +#include "GAHTTPApi.h" +#include "GALogger.h" + +namespace gameanalytics +{ + size_t writefunc(void *ptr, size_t size, size_t nmemb, GAHttpClient::Response *s) + { + if(!s || !ptr) + { + return 0; + } + + const size_t new_len = s->packet.size() + size * nmemb + 1; + s->packet.reserve(new_len); + s->packet.insert(s->packet.end(), reinterpret_cast(ptr), reinterpret_cast(ptr) + size * nmemb); + + return size*nmemb; + } + + void GAHttpCurl::initialize() + { + curl_global_init(CURL_GLOBAL_DEFAULT); + } + + void GAHttpCurl::cleanup() + { + curl_global_cleanup(); + } + + GAHttpClient::Response GAHttpCurl::sendRequest(std::string const& url, std::string const& auth, std::vector const& payloadData, bool useGzip, void* userData) + { + CURL* curl = curl_easy_init(); + if (!curl) + { + return {}; + } + + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + + GAHttpClient::Response response = {}; + + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + createRequest(curl, url, auth, payloadData, useGzip); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) + { + logging::GALogger::d("%s", curl_easy_strerror(res)); + return {}; + } + + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response.code); + curl_easy_cleanup(curl); + + return response; + } + + void GAHttpCurl::createRequest(CURL *curl, std::string const& url, std::string const& auth, const std::vector& payloadData, bool gzip) + { + if(!curl) + { + return; + } + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + struct curl_slist *header = NULL; + + if (gzip) + { + header = curl_slist_append(header, "Content-Encoding: gzip"); + } + + header = curl_slist_append(header, auth.c_str()); + + // always JSON + header = curl_slist_append(header, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payloadData.data()); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, payloadData.size()); + } +} \ No newline at end of file diff --git a/source/gameanalytics/Http/GAHttpCurl.h b/source/gameanalytics/Http/GAHttpCurl.h new file mode 100644 index 00000000..a894ada6 --- /dev/null +++ b/source/gameanalytics/Http/GAHttpCurl.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef _WIN32 + + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + + #ifndef NOMINMAX + #define NOMINMAX + #endif + + #include + +#endif + +#include "GameAnalytics/GAHttpClient.h" +#include + +namespace gameanalytics +{ + class GAHttpCurl: public GAHttpClient + { + virtual void initialize() override; + + virtual void cleanup() override; + + virtual Response sendRequest( + std::string const& url, + std::string const& auth, + std::vector const& payloadData, + bool useGzip, + void* userData) override; + + private: + + void createRequest(CURL *curl, std::string const& url, std::string const& auth, const std::vector& payloadData, bool gzip); + }; +} \ No newline at end of file diff --git a/test/GAHttpInterfaceTests.cpp b/test/GAHttpInterfaceTests.cpp new file mode 100644 index 00000000..bf83c195 --- /dev/null +++ b/test/GAHttpInterfaceTests.cpp @@ -0,0 +1,170 @@ +// +// GA-SDK-CPP +// Tests for the HTTP interface abstraction and custom implementation registration +// + +#include +#include + +#include "GameAnalytics/GameAnalytics.h" +#include "GameAnalytics/GAHttpClient.h" +#include "GAHTTPApi.h" + +namespace +{ + +// A mock HTTP implementation for testing +class MockHttpClient : public gameanalytics::GAHttpClient +{ +public: + void initialize() override + { + initialized = true; + } + + void cleanup() override + { + cleanedUp = true; + } + + Response sendRequest( + std::string const& url, + std::string const& auth, + std::vector const& payloadData, + bool useGzip, + void* userData) override + { + lastUrl = url; + lastAuth = auth; + lastPayload = payloadData; + lastUseGzip = useGzip; + requestCount++; + + return configuredResponse; + } + + // Test inspection + bool initialized = false; + bool cleanedUp = false; + int requestCount = 0; + std::string lastUrl; + std::string lastAuth; + std::vector lastPayload; + bool lastUseGzip = false; + + // Configurable response + Response configuredResponse = {}; +}; + +// -------- Response struct tests -------- + +TEST(GAHttpClientResponse, DefaultResponseHasNegativeCode) +{ + gameanalytics::GAHttpClient::Response response; + EXPECT_EQ(response.code, -1); + EXPECT_TRUE(response.packet.empty()); +} + +TEST(GAHttpClientResponse, ToStringReturnsEmptyForEmptyPacket) +{ + gameanalytics::GAHttpClient::Response response; + EXPECT_TRUE(response.toString().empty()); +} + +TEST(GAHttpClientResponse, ToStringReturnsPacketContent) +{ + gameanalytics::GAHttpClient::Response response; + std::string body = R"({"status":"ok"})"; + response.packet.assign(body.begin(), body.end()); + response.code = 200; + + std::string_view result = response.toString(); + EXPECT_EQ(result, body); + EXPECT_EQ(result.size(), body.size()); +} + +TEST(GAHttpClientResponse, ToStringHandlesBinaryData) +{ + gameanalytics::GAHttpClient::Response response; + response.packet = {0x00, 0x01, 0x02, 0xFF}; + response.code = 200; + + std::string_view result = response.toString(); + EXPECT_EQ(result.size(), 4u); +} + +// -------- Mock implementation tests -------- + +TEST(GAHttpInterface, MockImplementsInterface) +{ + auto mock = std::make_unique(); + EXPECT_FALSE(mock->initialized); + EXPECT_FALSE(mock->cleanedUp); + + mock->initialize(); + EXPECT_TRUE(mock->initialized); + + mock->cleanup(); + EXPECT_TRUE(mock->cleanedUp); +} + +TEST(GAHttpInterface, MockSendRequestRecordsParameters) +{ + MockHttpClient mock; + mock.configuredResponse.code = 200; + std::string responseBody = R"({"ok":true})"; + mock.configuredResponse.packet.assign(responseBody.begin(), responseBody.end()); + + std::string url = "https://api.gameanalytics.com/v2/test/events"; + std::string auth = "Authorization: abc123"; + std::vector payload = {'[', '{', '}', ']'}; + + auto response = mock.sendRequest(url, auth, payload, true, nullptr); + + EXPECT_EQ(mock.requestCount, 1); + EXPECT_EQ(mock.lastUrl, url); + EXPECT_EQ(mock.lastAuth, auth); + EXPECT_EQ(mock.lastPayload, payload); + EXPECT_TRUE(mock.lastUseGzip); + EXPECT_EQ(response.code, 200); + EXPECT_EQ(response.toString(), responseBody); +} + +TEST(GAHttpInterface, MockCanReturnErrorResponse) +{ + MockHttpClient mock; + mock.configuredResponse.code = 500; + std::string body = "Internal Server Error"; + mock.configuredResponse.packet.assign(body.begin(), body.end()); + + auto response = mock.sendRequest("http://test.com", "auth", {}, false, nullptr); + + EXPECT_EQ(response.code, 500); + EXPECT_EQ(response.toString(), body); +} + +TEST(GAHttpInterface, MockCanReturnNoContentResponse) +{ + MockHttpClient mock; + mock.configuredResponse.code = 204; + // 204 has no body + + auto response = mock.sendRequest("http://test.com", "auth", {}, false, nullptr); + + EXPECT_EQ(response.code, 204); + EXPECT_TRUE(response.packet.empty()); +} + +// -------- Registration tests -------- + +TEST(GAHttpInterface, SetCustomHttpImplAcceptsUniquePtr) +{ + // Verify the static method compiles and runs without crashing + auto mock = std::make_unique(); + gameanalytics::http::GAHTTPApi::setCustomHttpImpl(std::move(mock)); + + // Clean up: reset to nullptr so it doesn't affect other tests + gameanalytics::http::GAHTTPApi::setCustomHttpImpl(nullptr); +} + +} // namespace