diff --git a/.github/workflows/run-tests-and-package.yml b/.github/workflows/run-tests-and-package.yml index 4001817b3..ede0d0432 100644 --- a/.github/workflows/run-tests-and-package.yml +++ b/.github/workflows/run-tests-and-package.yml @@ -352,6 +352,8 @@ jobs: run: | if [[ ${{ github.event_name == 'workflow_dispatch' }} == true ]]; then echo "LOOTLOCKER_URL=${{ INPUTS.LL_URL }}" | sed -e 's/https:\/\///g' >> $GITHUB_ENV; elif [ ${{ vars.LL_USE_STAGE }} == 'true' ]; then echo "LOOTLOCKER_URL=${{ SECRETS.LOOTLOCKER_API_STAGE_URL }}" | sed -e 's/https:\/\///g' >> $GITHUB_ENV; else echo "LOOTLOCKER_URL=${{ SECRETS.LOOTLOCKER_API_PRODUCTION_URL }}" | sed -e 's/https:\/\///g' >> $GITHUB_ENV; fi if [[ ${{ github.event_name == 'workflow_dispatch' }} == true ]]; then echo "TARGET_ENVIRONMENT=CUSTOM" >> $GITHUB_ENV; echo "USE_TAILSCALE=true" >> $GITHUB_ENV; elif [ ${{ vars.LL_USE_STAGE }} == 'true' ]; then echo "TARGET_ENVIRONMENT=STAGE" >> $GITHUB_ENV; echo "USE_TAILSCALE=true" >> $GITHUB_ENV; else echo "TARGET_ENVIRONMENT=PRODUCTION" >> $GITHUB_ENV; echo "USE_TAILSCALE=false" >> $GITHUB_ENV; fi + COINFLIP=$(($RANDOM%${{ vars.LL_USE_LEGACY_HTTP_ONE_IN }})) + if [[ $COINFLIP -lt 1 ]]; then echo "USE_HTTP_EXECUTION_QUEUE=false" >> $GITHUB_ENV; else echo "USE_HTTP_EXECUTION_QUEUE=true"; fi - name: Checkout this repository uses: actions/checkout@v4 with: @@ -361,7 +363,7 @@ jobs: mkdir TestProject mkdir TestProject/Packages DEPENDENCY_STRING=$'{\n "dependencies": {\n "com.lootlocker.lootlockersdk": "file:../../sdk"' - if [[ ${{ matrix.jsonLibrary == 'newtonsoft' }} ]]; then DEPENDENCY_STRING=$DEPENDENCY_STRING$',\n "com.unity.nuget.newtonsoft-json": "3.0.2"'; fi + if [[ ${{ matrix.jsonLibrary == 'newtonsoft' }} ]]; then DEPENDENCY_STRING=$DEPENDENCY_STRING$',\n "com.unity.nuget.newtonsoft-json": "3.2.1"'; fi DEPENDENCY_STRING=$DEPENDENCY_STRING$'\n },\n "testables": ["com.lootlocker.lootlockersdk"]\n}' echo $DEPENDENCY_STRING >> TestProject/Packages/manifest.json mkdir TestProject/Assets/ @@ -385,7 +387,12 @@ jobs: if: ${{ matrix.jsonLibrary == 'newtonsoft' }} run: | mkdir TestProject/ProjectSettings - echo $'%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n--- !u!129 &1\nPlayerSettings:\n scriptingDefineSymbols:\n 1: LOOTLOCKER_USE_NEWTONSOFTJSON;LOOTLOCKER_COMMANDLINE_SETTINGS' >> TestProject/ProjectSettings/ProjectSettings.asset + echo $'%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n--- !u!129 &1\nPlayerSettings:\n scriptingDefineSymbols:\n 1: LOOTLOCKER_COMMANDLINE_SETTINGS;LOOTLOCKER_USE_NEWTONSOFTJSON' >> TestProject/ProjectSettings/ProjectSettings.asset + sed -i -e 's/"nunit.framework.dll"/"nunit.framework.dll",\n\t\t"Newtonsoft.Json.dll"/g' sdk/Tests/LootLockerTests/PlayMode/PlayModeTests.asmdef + - name: Use HTTP Execution Queue + if: ${{ ENV.USE_HTTP_EXECUTION_QUEUE != 'true' }} + run: | + sed -i -e 's/1: LOOTLOCKER_COMMANDLINE_SETTINGS/1: LOOTLOCKER_BETA_HTTP_QUEUE;LOOTLOCKER_TARGET_STAGE_ENV/g' TestProject/ProjectSettings/ProjectSettings.asset - name: Set user information command line arguments if: ${{ ENV.USE_TAILSCALE != 'true' }} run: | diff --git a/Runtime/Client/EndPointClass.cs b/Runtime/Client/EndPointClass.cs index 027a6137c..fcc6299c4 100644 --- a/Runtime/Client/EndPointClass.cs +++ b/Runtime/Client/EndPointClass.cs @@ -1,8 +1,10 @@ using System; -using LootLocker; -using System.Collections; -using System.Collections.Generic; -using UnityEngine; +using LootLocker.LootLockerEnums; + +namespace LootLocker.LootLockerEnums +{ + public enum LootLockerCallerRole { User, Admin, Player, Base }; +} namespace LootLocker { @@ -11,13 +13,15 @@ public class EndPointClass { public string endPoint { get; set; } public LootLockerHTTPMethod httpMethod { get; set; } + public LootLockerCallerRole callerRole { get; set; } public EndPointClass() { } - public EndPointClass(string endPoint, LootLockerHTTPMethod httpMethod) + public EndPointClass(string endPoint, LootLockerHTTPMethod httpMethod, LootLockerCallerRole callerRole = LootLockerCallerRole.User) { this.endPoint = endPoint; this.httpMethod = httpMethod; + this.callerRole = callerRole; } } } diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index d8366d865..c7854f071 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -1,4 +1,5 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; using UnityEngine; @@ -37,12 +38,12 @@ public class LootLockerEndPoints // White Label Login [Header("White Label Login")] - public static EndPointClass whiteLabelSignUp = new EndPointClass("white-label-login/sign-up", LootLockerHTTPMethod.POST); - public static EndPointClass whiteLabelLogin = new EndPointClass("white-label-login/login", LootLockerHTTPMethod.POST); - public static EndPointClass whiteLabelVerifySession = new EndPointClass("white-label-login/verify-session", LootLockerHTTPMethod.POST); - public static EndPointClass whiteLabelRequestPasswordReset = new EndPointClass("white-label-login/request-reset-password", LootLockerHTTPMethod.POST); + public static EndPointClass whiteLabelSignUp = new EndPointClass("white-label-login/sign-up", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); + public static EndPointClass whiteLabelLogin = new EndPointClass("white-label-login/login", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); + public static EndPointClass whiteLabelVerifySession = new EndPointClass("white-label-login/verify-session", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); + public static EndPointClass whiteLabelRequestPasswordReset = new EndPointClass("white-label-login/request-reset-password", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); public static EndPointClass whiteLabelRequestAccountVerification = new EndPointClass("white-label-login/request-verification", LootLockerHTTPMethod.POST); - public static EndPointClass whiteLabelLoginSessionRequest = new EndPointClass("v2/session/white-label", LootLockerHTTPMethod.POST); + public static EndPointClass whiteLabelLoginSessionRequest = new EndPointClass("v2/session/white-label", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); // Player [Header("Player")] @@ -322,6 +323,20 @@ public class LootLockerEndPoints public static EndPointClass ListNotifications = new EndPointClass("notifications/v1", LootLockerHTTPMethod.GET); public static EndPointClass ReadNotifications = new EndPointClass("notifications/v1/read", LootLockerHTTPMethod.PUT); public static EndPointClass ReadAllNotifications = new EndPointClass("notifications/v1/read/all", LootLockerHTTPMethod.PUT); + } + [Serializable] + public enum LootLockerHTTPMethod + { + GET = 0, + POST = 1, + DELETE = 2, + PUT = 3, + HEAD = 4, + CREATE = 5, + OPTIONS = 6, + PATCH = 7, + UPLOAD_FILE = 8, + UPDATE_FILE = 9 } -} \ No newline at end of file +} diff --git a/Runtime/Client/LootLockerErrorData.cs b/Runtime/Client/LootLockerErrorData.cs new file mode 100644 index 000000000..2985cfca4 --- /dev/null +++ b/Runtime/Client/LootLockerErrorData.cs @@ -0,0 +1,95 @@ +namespace LootLocker +{ + public class LootLockerErrorData + { + public LootLockerErrorData(int httpStatusCode, string errorMessage) + { + code = $"HTTP{httpStatusCode}"; + doc_url = $"https://developer.mozilla.org/docs/Web/HTTP/Status/{httpStatusCode}"; + message = errorMessage; + } + + public LootLockerErrorData() { } + + /// + /// A descriptive code identifying the error. + /// + public string code { get; set; } + + /// + /// A link to further documentation on the error. + /// + public string doc_url { get; set; } + + /// + /// A unique identifier of the request to use in contact with support. + /// + public string request_id { get; set; } + + /// + /// A unique identifier for tracing the request through LootLocker systems, use this in contact with support. + /// + public string trace_id { get; set; } + + /// + /// If the request was not a success this property will hold any error messages + /// + public string message { get; set; } + + /// + /// If the request was rate limited (status code 429) or the servers were temporarily unavailable (status code 503) you can use this value to determine how many seconds to wait before retrying + /// + public int? retry_after_seconds { get; set; } = null; + + /// + /// An easy way of debugging LootLockerErrorData class, example: Debug.Log(onComplete.errorData); + /// + /// string used to debug errors + public override string ToString() + { + // Empty error, make sure we print something + if (string.IsNullOrEmpty(message) && string.IsNullOrEmpty(trace_id) && string.IsNullOrEmpty(request_id)) + { + return $"An unexpected LootLocker error without error data occurred. Please try again later.\n If the issue persists, please contact LootLocker support."; + } + + //Print the most important info first + string prettyError = $"LootLocker Error: \"{message ?? ""}\""; + + // Look for intermittent, non user errors + if (!string.IsNullOrEmpty(code) && code.StartsWith("HTTP5")) + { + prettyError += + $"\nTry again later. If the issue persists, please contact LootLocker support and provide the following error details:\n trace ID - \"{trace_id ?? ""}\",\n request ID - \"{request_id ?? ""}\",\n message - \"{message ?? ""}\"."; + if (!string.IsNullOrEmpty(doc_url)) + { + prettyError += $"\nFor more information, see {doc_url} (error code was \"{code}\")."; + } + } + // Print user errors + else + { + prettyError += + $"\nThere was a problem with your request. The error message provides information on the problem and will help you fix it."; + if (!string.IsNullOrEmpty(doc_url ?? "")) + { + prettyError += $"\nFor more information, see {doc_url ?? ""} (error code was \"{code ?? ""}\")."; + } + + prettyError += + $"\nIf you are unable to fix the issue, contact LootLocker support and provide the following error details:"; + if (!string.IsNullOrEmpty(trace_id ?? "")) + { + prettyError += $"\n trace ID - \"{trace_id}\""; + } + if (!string.IsNullOrEmpty(request_id)) + { + prettyError += $"\n request ID - \"{request_id}\""; + } + + prettyError += $"\n message - \"{message ?? ""}\"."; + } + return prettyError; + } + } +} diff --git a/Runtime/Client/LootLockerErrorData.cs.meta b/Runtime/Client/LootLockerErrorData.cs.meta new file mode 100644 index 000000000..6270b4e34 --- /dev/null +++ b/Runtime/Client/LootLockerErrorData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 520b2a111c56b4215aa9f3b62f93449c \ No newline at end of file diff --git a/Runtime/Client/LootLockerHTTPClient.cs b/Runtime/Client/LootLockerHTTPClient.cs new file mode 100644 index 000000000..60c5dad3e --- /dev/null +++ b/Runtime/Client/LootLockerHTTPClient.cs @@ -0,0 +1,867 @@ +#if LOOTLOCKER_BETA_HTTP_QUEUE +using System.Collections.Generic; +using UnityEngine; +using System; +using LootLocker.LootLockerEnums; +using System.Collections; +using System.Text; +using UnityEngine.Networking; +using LootLocker.Requests; +using LootLocker.HTTP; +#if UNITY_EDITOR +using UnityEditor; +using UnityEditorInternal; +#endif + +namespace LootLocker +{ + /// + /// Construct a request to send to the server. + /// + [Serializable] + public struct LootLockerServerRequest + { + #region Make ServerRequest and call send (3 functions) + + public static void CallAPI(string endPoint, LootLockerHTTPMethod httpMethod, string body = null, Action onComplete = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User, Dictionary additionalHeaders = null) + { + if (httpMethod == LootLockerHTTPMethod.GET || httpMethod == LootLockerHTTPMethod.HEAD || httpMethod == LootLockerHTTPMethod.OPTIONS) + { + if (!string.IsNullOrEmpty(body)) + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Warning)("Payloads can not be sent in GET, HEAD, or OPTIONS requests. Attempted to send a body to: " + httpMethod.ToString() + " " + endPoint); + } + LootLockerHTTPClient.Get().ScheduleRequest(LootLockerHTTPRequestData.MakeNoContentRequest(endPoint, httpMethod, onComplete, useAuthToken, callerRole, additionalHeaders, null)); + } + else + { + LootLockerHTTPClient.Get().ScheduleRequest(LootLockerHTTPRequestData.MakeJsonRequest(endPoint, httpMethod, body, onComplete, useAuthToken, callerRole, additionalHeaders, null)); + } + } + + public static void UploadFile(string endPoint, LootLockerHTTPMethod httpMethod, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary body = null, Action onComplete = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User, Dictionary additionalHeaders = null) + { + if (file == null || file.Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("File content is empty, not allowed."); +#endif + onComplete(LootLockerResponseFactory.ClientError("File content is empty, not allowed.")); + return; + } + + LootLockerHTTPClient.Get().ScheduleRequest(LootLockerHTTPRequestData.MakeFileRequest(endPoint, httpMethod, file, fileName, fileContentType, body, onComplete, useAuthToken, callerRole, additionalHeaders, null)); + } + + public static void UploadFile(EndPointClass endPoint, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary body = null, Action onComplete = null, bool useAuthToken = true, LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User, Dictionary additionalHeaders = null) + { + UploadFile(endPoint.endPoint, endPoint.httpMethod, file, fileName, fileContentType, body, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken, callerRole, additionalHeaders); + } + + #endregion + } + + #if UNITY_EDITOR + [ExecuteInEditMode] + #endif + public class LootLockerHTTPClient : MonoBehaviour + { + #region Configuration + private const int MaxRetries = 5; + private const int IncrementalBackoffFactor = 2; + private const int InitialRetryWaitTimeInMs = 50; + private const int MaxOngoingRequests = 50; + private const int ChokeWarningThreshold = 500; + private const bool DenyIncomingRequestsWhenBackedUp = true; + private Dictionary CurrentlyOngoingRequests = new Dictionary(); + + private static readonly Dictionary BaseHeaders = new Dictionary + { + { "Accept", "application/json; charset=UTF-8" }, + { "Content-Type", "application/json; charset=UTF-8" }, + { "Access-Control-Allow-Credentials", "true" }, + { "Access-Control-Allow-Headers", "Accept, X-Access-Token, X-Application-Name, X-Request-Sent-Time" }, + { "Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS, HEAD" }, + { "Access-Control-Allow-Origin", "*" }, + { "LL-Instance-Identifier", System.Guid.NewGuid().ToString() } + }; + #endregion + + #region Instance Handling + private static LootLockerHTTPClient _instance; + private static int _instanceId = 0; + public GameObject HostingGameObject = null; + + public static void Instantiate() + { + if (_instance == null) + { + var gameObject = new GameObject("LootLockerHTTPClient"); + + _instance = gameObject.AddComponent(); + _instanceId = _instance.GetInstanceID(); + _instance.HostingGameObject = gameObject; + _instance.StartCoroutine(CleanUpOldInstances()); + if (Application.isPlaying) + DontDestroyOnLoad(_instance.gameObject); + } + } + + public static IEnumerator CleanUpOldInstances() + { +#if UNITY_2020_1_OR_NEWER + LootLockerHTTPClient[] serverApis = GameObject.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); +#else + LootLockerHTTPClient[] serverApis = GameObject.FindObjectsOfType(); +#endif + foreach (LootLockerHTTPClient serverApi in serverApis) + { + if (serverApi != null && _instanceId != serverApi.GetInstanceID() && serverApi.HostingGameObject != null) + { +#if UNITY_EDITOR + DestroyImmediate(serverApi.HostingGameObject); +#else + Destroy(serverApi.HostingGameObject); +#endif + } + } + yield return null; + } + + public static void ResetInstance() + { + if (_instance == null) return; +#if UNITY_EDITOR + DestroyImmediate(_instance.gameObject); +#else + Destroy(_instance.gameObject); +#endif + _instance = null; + _instanceId = 0; + } + +#if UNITY_EDITOR + [InitializeOnEnterPlayMode] + static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) + { + ResetInstance(); + } +#endif + + public static LootLockerHTTPClient Get() + { + if (_instance == null) + { + Instantiate(); + } + return _instance; + } + #endregion + + private Dictionary HTTPExecutionQueue = new Dictionary(); + private List CompletedRequestIDs = new List(); + + private void OnDestroy() + { + foreach(var executionItem in HTTPExecutionQueue.Values) + { + if(executionItem != null && executionItem.WebRequest != null) + { + executionItem.Dispose(); + } + } + } + + void Update() + { + List ExecutionItemsNeedingRefresh = new List(); + foreach(var executionItem in HTTPExecutionQueue.Values) + { + // Skip completed requests + if(executionItem.Done) + { + if(!CompletedRequestIDs.Contains(executionItem.RequestData.RequestId)) + { + CompletedRequestIDs.Add(executionItem.RequestData.RequestId); + } + continue; + } + + // Skip requests that are waiting for session refresh + if (executionItem.IsWaitingForSessionRefresh) + { + continue; + } + + // Send unsent + if (executionItem.AsyncOperation == null && executionItem.WebRequest == null) + { + if(executionItem.RetryAfter != null && executionItem.RetryAfter > DateTime.Now) + { + // Wait for retry + continue; + } + + if (CurrentlyOngoingRequests.Count >= MaxOngoingRequests) + { + // Wait for some requests to finish before scheduling more requests + continue; + } + + CreateAndSendRequest(executionItem); + continue; + } + + // Process ongoing + var Result = ProcessOngoingRequest(executionItem); + + if(Result == HTTPExecutionQueueProcessingResult.NeedsSessionRefresh) + { + //Bulk handle session refreshes at the end + ExecutionItemsNeedingRefresh.Add(executionItem.RequestData.RequestId); + continue; + } + else if(Result == HTTPExecutionQueueProcessingResult.WaitForNextTick || Result == HTTPExecutionQueueProcessingResult.None) + { + // Nothing to handle, simply continue + continue; + } + + HandleRequestResult(executionItem, Result); + } + + // Bulk session refresh requests + if (ExecutionItemsNeedingRefresh.Count > 0) + { + foreach (string executionItemId in ExecutionItemsNeedingRefresh) + { + if (HTTPExecutionQueue.TryGetValue(executionItemId, out var executionItem)) + { + CurrentlyOngoingRequests.Remove(executionItem.RequestData.RequestId); + executionItem.IsWaitingForSessionRefresh = true; + executionItem.RequestData.TimesRetried++; + + // Unsetting web request fields will make the execution queue retry it + executionItem.AbortRequest(); + } + } + + string tokenBeforeRefresh = LootLockerConfig.current.token; + StartCoroutine(RefreshSession(newSessionResponse => + { + foreach (string executionItemId in ExecutionItemsNeedingRefresh) + { + if (HTTPExecutionQueue.TryGetValue(executionItemId, out var executionItem)) + { + if (tokenBeforeRefresh.Equals(LootLockerConfig.current.token)) + { + // Session refresh failed so abort call chain + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.TokenExpiredError()); + } + + // Session refresh worked so update the session token header + if (executionItem.RequestData.CallerRole == LootLockerCallerRole.Admin) + { +#if UNITY_EDITOR + executionItem.RequestData.ExtraHeaders["x-auth-token"] = LootLockerConfig.current.adminToken; +#endif + } + else + { + executionItem.RequestData.ExtraHeaders["x-session-token"] = LootLockerConfig.current.token; + } + + // Mark request as ready for continuation + executionItem.IsWaitingForSessionRefresh = false; + } + } + })); + } + + if((HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count) > ChokeWarningThreshold) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Warning)($"LootLocker HTTP Execution Queue is overloaded. Requests currently waiting for execution: '{(HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count)}'"); +#endif + } + } + + private void LateUpdate() + { + // Do Cleanup + foreach (var CompletedRequestID in CompletedRequestIDs) + { + if(HTTPExecutionQueue.TryGetValue(CompletedRequestID, out var completedRequest)) + { + if(!completedRequest.Done) + { + continue; + } + + if(!completedRequest.RequestData.HaveListenersBeenInvoked) + { + if(completedRequest.Response != null) + { + completedRequest.RequestData.CallListenersWithResult(completedRequest.Response); + } + else if (completedRequest.WebRequest != null) + { + if (WebRequestSucceeded(completedRequest.WebRequest)) + { + completedRequest.RequestData.CallListenersWithResult(LootLockerResponseFactory.Success((int)completedRequest.WebRequest.responseCode, completedRequest.WebRequest.downloadHandler.text)); + } + else + { + completedRequest.RequestData.CallListenersWithResult(ExtractFailureResponseFromExecutionItem(completedRequest)); + } + } + else + { + completedRequest.RequestData.CallListenersWithResult(LootLockerResponseFactory.ClientError("Request completed but no response was present")); + } + } + + HTTPExecutionQueue.Remove(CompletedRequestID); + completedRequest.Dispose(); + } + } + CompletedRequestIDs.Clear(); + + foreach (var ExecutionItem in HTTPExecutionQueue.Values) + { + // Find stragglers + if (ExecutionItem.Done) + { + CompletedRequestIDs.Add(ExecutionItem.RequestData.RequestId); + } + } + + List OngoingIdsToCleanUp = new List(); + foreach(string OngoingId in CurrentlyOngoingRequests.Keys) + { + if(!HTTPExecutionQueue.TryGetValue(OngoingId, out var executionQueueItem) || executionQueueItem.Done) + { + OngoingIdsToCleanUp.Add(OngoingId); + } + } + foreach(string CompletedId in OngoingIdsToCleanUp) + { + CurrentlyOngoingRequests.Remove(CompletedId); + } + } + + public void ScheduleRequest(LootLockerHTTPRequestData request) + { + StartCoroutine(_ScheduleRequest(request)); + } + + private IEnumerator _ScheduleRequest(LootLockerHTTPRequestData request) + { + //Always wait 1 frame before starting any request to the server to make sure the requester code has exited the main thread. + yield return null; + + if (DenyIncomingRequestsWhenBackedUp && (HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count) > ChokeWarningThreshold) + { + // Execution queue is backed up, deny request + request.CallListenersWithResult(LootLockerResponseFactory.ClientError("Request was denied because there are currently too many requests in queue")); + yield break; + } + + if (HTTPExecutionQueue.TryGetValue(request.RequestId, out var executionQueueItem)) + { + executionQueueItem.RequestData.Listeners.AddRange(request.Listeners); + yield break; + } + HTTPExecutionQueue.Add(request.RequestId, new LootLockerHTTPExecutionQueueItem { RequestData = request }); + } + + private bool CreateAndSendRequest(LootLockerHTTPExecutionQueueItem executionItem) + { + if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit()) + { + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RateLimitExceeded(executionItem.RequestData.Endpoint, RateLimiter.Get().GetSecondsLeftOfRateLimit())); + return false; + } + +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Verbose)("ServerRequest " + executionItem.RequestData.HTTPMethod + " URL: " + executionItem.RequestData.FormattedURL); +#endif + + UnityWebRequest webRequest = CreateWebRequest(executionItem.RequestData); + if (webRequest == null) + { + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.ClientError($"Call to {executionItem.RequestData.Endpoint} failed because Unity Web Request could not be created")); + return false; + } + + executionItem.RequestStartTime = Time.time; + + executionItem.WebRequest = webRequest; + executionItem.AsyncOperation = executionItem.WebRequest.SendWebRequest(); + CurrentlyOngoingRequests.Add(executionItem.RequestData.RequestId, true); + return true; + } + + private HTTPExecutionQueueProcessingResult ProcessOngoingRequest(LootLockerHTTPExecutionQueueItem executionItem) + { + if (executionItem.AsyncOperation == null) + { + return HTTPExecutionQueueProcessingResult.WaitForNextTick; + } + + bool timedOut = !executionItem.AsyncOperation.isDone && (Time.time - executionItem.RequestStartTime) >= LootLockerConfig.current.clientSideRequestTimeOut; + if(timedOut) + { + return HTTPExecutionQueueProcessingResult.Completed_TimedOut; + } + + // Not timed out and not done, nothing to do + if(!executionItem.AsyncOperation.isDone) + { + return HTTPExecutionQueueProcessingResult.WaitForNextTick; + } + + if (WebRequestSucceeded(executionItem.WebRequest)) + { + return HTTPExecutionQueueProcessingResult.Completed_Success; + } + + if (ShouldRetryRequest(executionItem.WebRequest.responseCode, executionItem.RequestData.TimesRetried) && !(executionItem.WebRequest.responseCode == 401 && !IsAuthorizedRequest(executionItem))) + { + if (ShouldRefreshSession(executionItem.WebRequest.responseCode) && (CanRefreshUsingRefreshToken(executionItem.RequestData) || CanStartNewSessionUsingCachedData())) + { + executionItem.IsWaitingForSessionRefresh = true; + return HTTPExecutionQueueProcessingResult.NeedsSessionRefresh; + } + return HTTPExecutionQueueProcessingResult.ShouldBeRetried; + } + + + return HTTPExecutionQueueProcessingResult.Completed_Failed; + } + + private void HandleRequestResult(LootLockerHTTPExecutionQueueItem executionItem, HTTPExecutionQueueProcessingResult result) + { + switch(result) + { + case HTTPExecutionQueueProcessingResult.None: + case HTTPExecutionQueueProcessingResult.WaitForNextTick: + case HTTPExecutionQueueProcessingResult.NeedsSessionRefresh: + default: + { + // Should be handled outside this method, nothing to do + return; + } + case HTTPExecutionQueueProcessingResult.Completed_Success: + { + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.Success((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text)); + } + break; + case HTTPExecutionQueueProcessingResult.ShouldBeRetried: + { + int RetryAfterHeader = ExtractRetryAfterFromHeader(executionItem); + if (RetryAfterHeader > 0) + { + // If the retry after header suggests to retry after we'd have timed out the request then handle it as a failure + if (executionItem.RequestStartTime + RetryAfterHeader > LootLockerConfig.current.clientSideRequestTimeOut) + { + LootLockerResponse response = LootLockerResponseFactory.Failure((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text); + response.errorData = ExtractErrorData(response); + if (response.errorData != null) + { + response.errorData.retry_after_seconds = RetryAfterHeader; + } + + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)(response.errorData.ToString()); + CallListenersAndMarkDone(executionItem, response); + return; + } + executionItem.RetryAfter = DateTime.Now.AddSeconds(RetryAfterHeader); + } + else + { + // Incremental backoff + executionItem.RetryAfter = DateTime.Now.AddMilliseconds(InitialRetryWaitTimeInMs + (InitialRetryWaitTimeInMs * executionItem.RequestData.TimesRetried*IncrementalBackoffFactor)); + } + executionItem.RequestData.TimesRetried++; + + // Unsetting web request fields will make the execution queue retry it + executionItem.AbortRequest(); + + CurrentlyOngoingRequests.Remove(executionItem.RequestData.RequestId); + return; + } + case HTTPExecutionQueueProcessingResult.Completed_TimedOut: + { + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RequestTimeOut()); + } + break; + case HTTPExecutionQueueProcessingResult.Completed_Failed: + { + LootLockerResponse response = ExtractFailureResponseFromExecutionItem(executionItem); + + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)(response.errorData.ToString()); + CallListenersAndMarkDone(executionItem, response); + } + break; + } + + LogResponse(executionItem); + } + + private void CallListenersAndMarkDone(LootLockerHTTPExecutionQueueItem executionItem, LootLockerResponse response) + { + CurrentlyOngoingRequests.Remove(executionItem.RequestData.RequestId); + executionItem.IsWaitingForSessionRefresh = false; + executionItem.Done = true; + executionItem.Response = response; + executionItem.RequestData.CallListenersWithResult(response); + CompletedRequestIDs.Add(executionItem.RequestData.RequestId); + } + + private IEnumerator RefreshSession(Action onSessionRefreshedCallback) + { + LootLockerSessionResponse newSessionResponse = null; + bool callCompleted = false; + switch (CurrentPlatform.Get()) + { + case Platforms.Guest: + { + LootLockerSDKManager.StartGuestSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.WhiteLabel: + { + LootLockerSDKManager.StartWhiteLabelSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.AppleGameCenter: + { + LootLockerSDKManager.RefreshAppleGameCenterSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.AppleSignIn: + { + LootLockerSDKManager.RefreshAppleSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.Epic: + { + LootLockerSDKManager.RefreshEpicSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.Google: + { + LootLockerSDKManager.RefreshGoogleSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.Remote: + { + LootLockerSDKManager.RefreshRemoteSession(response => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.PlayStationNetwork: + case Platforms.XboxOne: + case Platforms.AmazonLuna: + { + LootLockerAPIManager.Session(new LootLockerSessionRequest(LootLockerConfig.current.deviceID), (response) => + { + newSessionResponse = response; + callCompleted = true; + }); + } + break; + case Platforms.NintendoSwitch: + case Platforms.Steam: + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Warning)($"Token has expired and token refresh is not supported for {CurrentPlatform.GetFriendlyString()}"); + yield break; + } + case Platforms.None: + default: + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)($"Token refresh for platform {CurrentPlatform.GetFriendlyString()} not supported"); + yield break; + } + } + yield return new WaitUntil(() => callCompleted); + onSessionRefreshedCallback?.Invoke(newSessionResponse); + } + + #region Session Refresh Helper Methods + + private static bool ShouldRetryRequest(long statusCode, int timesRetried) + { + return (statusCode == 401 || statusCode == 403 || statusCode == 502 || statusCode == 500 || statusCode == 503) && LootLockerConfig.current.allowTokenRefresh && CurrentPlatform.Get() != Platforms.Steam && timesRetried < MaxRetries; + } + + private static bool ShouldRefreshSession(long statusCode) + { + return (statusCode == 401 || statusCode == 403) && LootLockerConfig.current.allowTokenRefresh && CurrentPlatform.Get() != Platforms.Steam; + } + + private static bool IsAuthorizedRequest(LootLockerHTTPExecutionQueueItem request) + { + return !string.IsNullOrEmpty(request.WebRequest?.GetRequestHeader("x-session-token")) || !string.IsNullOrEmpty(request.WebRequest?.GetRequestHeader("x-auth-token")); + } + + private static bool CanRefreshUsingRefreshToken(LootLockerHTTPRequestData cachedRequest) + { + if (!LootLockerPlatformSettings.PlatformsWithRefreshTokens.Contains(CurrentPlatform.Get())) + { + return false; + } + // The failed request isn't a refresh session request but we have a refresh token stored, so try to refresh the session automatically before failing + string json = cachedRequest.Content.dataType == LootLockerHTTPRequestDataType.JSON ? ((LootLockerJsonBodyRequestContent)cachedRequest.Content).jsonBody : null; + return (string.IsNullOrEmpty(json) || !json.Contains("refresh_token")) && !string.IsNullOrEmpty(LootLockerConfig.current.refreshToken); + } + + private static bool CanStartNewSessionUsingCachedData() + { + if (!LootLockerPlatformSettings.PlatformsWithStoredAuthData.Contains(CurrentPlatform.Get())) + { + return false; + } + if (CurrentPlatform.Get() == Platforms.Guest) + { + return true; + } + else if (CurrentPlatform.Get() == Platforms.WhiteLabel && !string.IsNullOrEmpty(PlayerPrefs.GetString("LootLockerWhiteLabelSessionToken", "")) && !string.IsNullOrEmpty(PlayerPrefs.GetString("LootLockerWhiteLabelSessionEmail", ""))) + { + return true; + } + else + { + return !string.IsNullOrEmpty(LootLockerConfig.current.deviceID); + } + } + #endregion + + #region Web Request Helper Methods + private bool WebRequestSucceeded(UnityWebRequest webRequest) + { + return ! +#if UNITY_2020_1_OR_NEWER + (webRequest.result == UnityWebRequest.Result.ProtocolError || webRequest.result == UnityWebRequest.Result.ConnectionError || !string.IsNullOrEmpty(webRequest.error)); +#else + (webRequest.isHttpError || webRequest.isNetworkError || !string.IsNullOrEmpty(webRequest.error)); +#endif + } + + private LootLockerResponse ExtractFailureResponseFromExecutionItem(LootLockerHTTPExecutionQueueItem executionItem) + { + LootLockerResponse response = LootLockerResponseFactory.Failure((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text); + response.errorData = ExtractErrorData(response); + if (response.errorData != null) + { + response.errorData.retry_after_seconds = ExtractRetryAfterFromHeader(executionItem); + } + return response; + } + + private UnityWebRequest CreateWebRequest(LootLockerHTTPRequestData request) + { + UnityWebRequest webRequest = null; + switch (request.HTTPMethod) + { + case LootLockerHTTPMethod.OPTIONS: + case LootLockerHTTPMethod.HEAD: + case LootLockerHTTPMethod.GET: + webRequest = UnityWebRequest.Get(request.FormattedURL); + webRequest.method = request.HTTPMethod.ToString(); + break; + + case LootLockerHTTPMethod.DELETE: + webRequest = UnityWebRequest.Delete(request.FormattedURL); + break; + case LootLockerHTTPMethod.UPLOAD_FILE: + case LootLockerHTTPMethod.UPDATE_FILE: + if (request.Content.dataType != LootLockerHTTPRequestDataType.FILE) + { + request.CallListenersWithResult(LootLockerResponseFactory.ClientError("File request without file content")); + return null; + } + webRequest = UnityWebRequest.Post(request.FormattedURL, ((LootLockerFileRequestContent)request.Content).fileForm); + if(request.HTTPMethod == LootLockerHTTPMethod.UPDATE_FILE) + { + // Workaround for UnityWebRequest with PUT HTTP verb not having form fields + webRequest.method = UnityWebRequest.kHttpVerbPUT; + } + break; + case LootLockerHTTPMethod.POST: + case LootLockerHTTPMethod.PATCH: + case LootLockerHTTPMethod.PUT: + if (request.Content.dataType == LootLockerHTTPRequestDataType.WWW_FORM) + { + webRequest = MakeWWWFormWebRequest(request); + } + else + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Verbose)("REQUEST BODY = " + LootLockerObfuscator.ObfuscateJsonStringForLogging(((LootLockerJsonBodyRequestContent)request.Content).jsonBody)); +#endif + byte[] bytes = Encoding.UTF8.GetBytes(string.IsNullOrEmpty(((LootLockerJsonBodyRequestContent)request.Content).jsonBody) ? "{}" : ((LootLockerJsonBodyRequestContent)request.Content).jsonBody); + webRequest = UnityWebRequest.Put(request.FormattedURL, bytes); + webRequest.method = request.HTTPMethod.ToString(); + } + break; + default: + request.CallListenersWithResult(LootLockerResponseFactory.ClientError("Unsupported HTTP Method")); + return webRequest; + } + + if (BaseHeaders != null) + { + foreach (KeyValuePair pair in BaseHeaders) + { + if (pair.Key == "Content-Type" && request.Content.dataType != LootLockerHTTPRequestDataType.JSON) continue; + + webRequest.SetRequestHeader(pair.Key, pair.Value); + } + } + + if (!string.IsNullOrEmpty(LootLockerConfig.current?.sdk_version)) + { + webRequest.SetRequestHeader("LL-SDK-Version", LootLockerConfig.current.sdk_version); + } + + if (request.ExtraHeaders != null) + { + foreach (KeyValuePair pair in request.ExtraHeaders) + { + webRequest.SetRequestHeader(pair.Key, pair.Value); + } + } + + webRequest.downloadHandler = new DownloadHandlerBuffer(); + return webRequest; + } + + private static UnityWebRequest MakeWWWFormWebRequest(LootLockerHTTPRequestData request) + { + UnityWebRequest webRequest = new UnityWebRequest(); + var content = (LootLockerWWWFormRequestContent)request.Content; + List form = new List + { + new MultipartFormFileSection(content.name, content.content, System.DateTime.Now.ToString(), content.type) + }; + + // generate a boundary then convert the form to byte[] + byte[] boundary = UnityWebRequest.GenerateBoundary(); + byte[] formSections = UnityWebRequest.SerializeFormSections(form, boundary); + // Set the content type - NO QUOTES around the boundary + string contentType = String.Concat("multipart/form-data; boundary=--", Encoding.UTF8.GetString(boundary)); + + // Make my request object and add the raw text. Set anything else you need here + webRequest.SetRequestHeader("Content-Type", "multipart/form-data; boundary=--"); + webRequest.uri = new Uri(request.FormattedURL); + webRequest.uploadHandler = new UploadHandlerRaw(formSections); + webRequest.uploadHandler.contentType = contentType; + webRequest.useHttpContinue = false; + + // webRequest.method = "POST"; + webRequest.method = UnityWebRequest.kHttpVerbPOST; + return webRequest; + } + #endregion + + #region Misc Helper Methods + + private static int ExtractRetryAfterFromHeader(LootLockerHTTPExecutionQueueItem executionItem) + { + int retryAfterSeconds = -1; + string RetryAfterHeader = executionItem.WebRequest.GetResponseHeader("Retry-After"); + if (!string.IsNullOrEmpty(RetryAfterHeader)) + { + retryAfterSeconds = int.Parse(RetryAfterHeader); + } + return retryAfterSeconds; + } + + private static LootLockerErrorData ExtractErrorData(LootLockerResponse response) + { + LootLockerErrorData errorData = null; + try + { + errorData = LootLockerJson.DeserializeObject(response.text); + } + catch (Exception) + { + if (response.text.StartsWith("<")) + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Warning)("Non Json Response body (starts with <), info: \n statusCode: " + response.statusCode + "\n body: " + response.text); + } + errorData = null; + } + // Error data was not parseable, populate with what we know + if (errorData == null) + { + errorData = new LootLockerErrorData(response.statusCode, response.text); + } + return errorData; + } + + private static void LogResponse(LootLockerHTTPExecutionQueueItem executedItem) + { + if(!executedItem.Done) + { + return; + } + if (executedItem.WebRequest.responseCode == 0 && string.IsNullOrEmpty(executedItem.WebRequest.downloadHandler.text) && !string.IsNullOrEmpty(executedItem.WebRequest.error)) + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Verbose)("Unity Web request failed, request to " + + executedItem.RequestData.FormattedURL + " completed in " + + (Time.time - executedItem.RequestStartTime).ToString("n4") + + " secs.\nWeb Request Error: " + executedItem.WebRequest.error); + return; + } + + try + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Verbose)("Server Response: " + + executedItem.WebRequest.responseCode + " " + + executedItem.RequestData.FormattedURL + " completed in " + + (Time.time - executedItem.RequestStartTime).ToString("n4") + + " secs.\nResponse: " + + LootLockerObfuscator + .ObfuscateJsonStringForLogging(executedItem.WebRequest.downloadHandler.text)); + } + catch + { + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)(executedItem.RequestData.HTTPMethod.ToString()); + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)(executedItem.RequestData.FormattedURL); + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)(LootLockerObfuscator.ObfuscateJsonStringForLogging(executedItem.WebRequest.downloadHandler.text)); + } + } + #endregion + } +} +#endif diff --git a/Runtime/Client/LootLockerHTTPClient.cs.meta b/Runtime/Client/LootLockerHTTPClient.cs.meta new file mode 100644 index 000000000..fd6103f1f --- /dev/null +++ b/Runtime/Client/LootLockerHTTPClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e5fc10ff3e874bfcaa15e18367075e5 \ No newline at end of file diff --git a/Runtime/Client/LootLockerHTTPExecutionQueueItem.cs b/Runtime/Client/LootLockerHTTPExecutionQueueItem.cs new file mode 100644 index 000000000..7d2633d16 --- /dev/null +++ b/Runtime/Client/LootLockerHTTPExecutionQueueItem.cs @@ -0,0 +1,65 @@ +using System; +using LootLocker; +using LootLocker.HTTP; +using UnityEngine; +using UnityEngine.Networking; + +namespace LootLocker.LootLockerEnums +{ + public enum HTTPExecutionQueueProcessingResult + { + None = 0, + WaitForNextTick = 1, + Completed_Success = 2, + Completed_Failed = 3, + Completed_TimedOut = 4, + ShouldBeRetried = 5, + NeedsSessionRefresh = 6 + } +} + +namespace LootLocker +{ +public class LootLockerHTTPExecutionQueueItem +{ + + public LootLockerHTTPRequestData RequestData { get; set; } = null; + + public UnityWebRequest WebRequest { get; set; } = null; + + public UnityWebRequestAsyncOperation AsyncOperation { get; set; } = null; + + public float RequestStartTime { get; set; } = float.MinValue; + + public DateTime? RetryAfter { get; set; } = null; + + public bool IsWaitingForSessionRefresh { get; set; } = false; + + public bool Done { get; set; } = false; + + public LootLockerResponse Response { get; set; } = null; + + public void OnDestroy() + { + Dispose(); + } + + public void AbortRequest() + { + if (WebRequest != null) + { + WebRequest?.Abort(); + WebRequest.downloadHandler?.Dispose(); + WebRequest.uploadHandler?.Dispose(); + WebRequest.Dispose(); + } + WebRequest = null; + AsyncOperation = null; + } + + public void Dispose() + { + AbortRequest(); + } +} +} \ No newline at end of file diff --git a/Runtime/Client/LootLockerHTTPExecutionQueueItem.cs.meta b/Runtime/Client/LootLockerHTTPExecutionQueueItem.cs.meta new file mode 100644 index 000000000..3e049449f --- /dev/null +++ b/Runtime/Client/LootLockerHTTPExecutionQueueItem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52e6a6ffaaef5404c85289fb57a6e9d2 \ No newline at end of file diff --git a/Runtime/Client/LootLockerHttpRequestData.cs b/Runtime/Client/LootLockerHttpRequestData.cs new file mode 100644 index 000000000..78682d652 --- /dev/null +++ b/Runtime/Client/LootLockerHttpRequestData.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using LootLocker.LootLockerEnums; +using UnityEngine; + +namespace LootLocker.LootLockerEnums +{ + public enum LootLockerHTTPRequestDataType + { + EMPTY = 0, + JSON = 1, + WWW_FORM = 2, + FILE = 3, + } +} + +namespace LootLocker.HTTP +{ + [Serializable] + public class LootLockerHTTPRequestData + { + /// + /// The endpoint to send the request to + /// + public string Endpoint { get; set; } + /// + /// The HTTP method to use for the request + /// + public LootLockerHTTPMethod HTTPMethod { get; set; } + /// + /// Which target to use for the request + /// + public LootLockerCallerRole CallerRole { get; set; } + /// + /// The full url with endpoint, target, and query parameters included + /// + public string FormattedURL { get; set; } + /// + /// The content of the request, check content.dataType to see what type of content it is + /// + public LootLockerHTTPRequestContent Content { get; set; } + + /// + /// Leave this null if you don't need custom headers + /// + public Dictionary ExtraHeaders; + + /// + /// Query parameters to append to the end of the request URI + /// Example: If you include a dictionary with a key of "page" and a value of "42" (as a string) then the url would become "https://mydomain.com/endpoint?page=42" + /// + public Dictionary QueryParams; + + /// + /// How many times this request has been retried + /// + public int TimesRetried { get; set; } + + /// + /// The listeners for handling responses + /// + public List> Listeners { get; set; } + + /// + /// Whether the listeners have been invoked or not + /// + public bool HaveListenersBeenInvoked { get; set; } + + /// + /// A generated id for this request, it is a combination of hashes for the endpoint, headers and content + /// + public string RequestId { get; set; } + + + /// + /// Call all listeners with response + /// + public void CallListenersWithResult(LootLockerResponse response) + { + foreach(var listener in Listeners) + { + listener?.Invoke(response); + } + HaveListenersBeenInvoked = true; + } + + public override bool Equals(object obj) + { + if(obj != null && obj.GetType() != this.GetType()) + { + return false; + } + return Equals((LootLockerHTTPRequestData)obj); + } + + public bool Equals(LootLockerHTTPRequestData other) + { + return other != null && other.RequestId.Equals(RequestId); + } + + public override int GetHashCode() + { + return RequestId.GetHashCode(); + } + + #region Factory Methods + public static LootLockerHTTPRequestData MakeFileRequest(string endPoint, LootLockerHTTPMethod httpMethod, byte[] file, string fileName, string fileContentType, Dictionary body, Action onComplete, bool useAuthToken, LootLockerCallerRole callerRole, Dictionary additionalHeaders, Dictionary queryParams) + { + LootLockerHTTPRequestContent content = null; + if (LootLockerHTTPMethod.PUT == httpMethod) + { + content = new LootLockerWWWFormRequestContent(file, fileName, fileContentType); + } + else + { + content = new LootLockerFileRequestContent(file, fileName, body); + } + return _MakeRequestDataWithContent( + content, + endPoint, + httpMethod, + onComplete, + useAuthToken, + callerRole, + additionalHeaders, + queryParams); + } + + public static LootLockerHTTPRequestData MakeJsonRequest(string endPoint, LootLockerHTTPMethod httpMethod, string body, Action onComplete, bool useAuthToken, LootLockerCallerRole callerRole, Dictionary additionalHeaders, Dictionary queryParams) + { + return _MakeRequestDataWithContent(new LootLockerJsonBodyRequestContent(string.IsNullOrEmpty(body) ? "{}" : body), endPoint, httpMethod, onComplete, useAuthToken, callerRole, additionalHeaders, queryParams); + } + + public static LootLockerHTTPRequestData MakeNoContentRequest(string endPoint, LootLockerHTTPMethod httpMethod, Action onComplete, bool useAuthToken, LootLockerCallerRole callerRole, Dictionary additionalHeaders, Dictionary queryParams) + { + return _MakeRequestDataWithContent(new LootLockerHTTPRequestContent(), endPoint, httpMethod, onComplete, useAuthToken, callerRole, additionalHeaders, queryParams); + } + + private static LootLockerHTTPRequestData _MakeRequestDataWithContent(LootLockerHTTPRequestContent content, string endPoint, LootLockerHTTPMethod httpMethod, Action onComplete, bool useAuthToken, LootLockerCallerRole callerRole, Dictionary additionalHeaders, Dictionary queryParams) + { + Dictionary headers = InitializeHeadersWithSessionToken(callerRole, useAuthToken); + + if (LootLockerConfig.current != null) + headers.Add(LootLockerConfig.current.dateVersion.key, LootLockerConfig.current.dateVersion.value); + + if (additionalHeaders != null) + { + foreach (var additionalHeader in additionalHeaders) + { + headers.Add(additionalHeader.Key, additionalHeader.Value); + } + } + if (headers != null && headers.Count == 0) + { + headers = null; // Force extra headers to null if empty dictionary was supplied + } + string headersString = ""; + foreach(var header in headers) + { + headersString += $"|{header.Key}:{header.Value}"; + } + + string formattedUrl = BuildUrl(endPoint, queryParams, callerRole); + string requestId = $"{formattedUrl}-h{headersString.GetHashCode()}-c{content.GetHashCode()}-{Guid.NewGuid()}"; + + return new LootLockerHTTPRequestData + { + TimesRetried = 0, + Endpoint = endPoint, + HTTPMethod = httpMethod, + ExtraHeaders = headers, + QueryParams = queryParams, + CallerRole = callerRole, + Content = content, + Listeners = new List> { onComplete }, + HaveListenersBeenInvoked = false, + FormattedURL = formattedUrl, + RequestId = requestId + }; + } + #endregion + + #region Helper Methods + private static Dictionary InitializeHeadersWithSessionToken(LootLockerCallerRole callerRole, bool useAuthToken) + { + var headers = new Dictionary(); + if (useAuthToken) + { + if (callerRole == LootLockerCallerRole.Admin) + { +#if UNITY_EDITOR + if (!string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + headers.Add("x-auth-token", LootLockerConfig.current.adminToken); + } +#endif + } + else if (!string.IsNullOrEmpty(LootLockerConfig.current.token)) + { + headers.Add("x-session-token", LootLockerConfig.current.token); + } + } + return headers; + } + + private static string BuildUrl(string endpoint, Dictionary queryParams, LootLockerCallerRole callerRole) + { + string trimmedEndpoint = endpoint.StartsWith("/") ? endpoint.Trim() : "/" + endpoint.Trim(); + string urlBase; + switch (callerRole) + { + case LootLockerCallerRole.Admin: + urlBase = LootLockerConfig.current.adminUrl; + break; + case LootLockerCallerRole.User: + urlBase = LootLockerConfig.current.userUrl; + break; + case LootLockerCallerRole.Player: + urlBase = LootLockerConfig.current.playerUrl; + break; + case LootLockerCallerRole.Base: + urlBase = LootLockerConfig.current.baseUrl; + break; + default: + urlBase = LootLockerConfig.current.url; + break; + } + + return (urlBase + trimmedEndpoint + GetQueryParameterStringFromDictionary(queryParams)).Trim(); + } + + public static string GetQueryParameterStringFromDictionary(Dictionary queryDict) + { + if (queryDict == null || queryDict.Count == 0) return string.Empty; + + string query = "?"; + + foreach (KeyValuePair pair in queryDict) + { + if (query.Length > 1) + query += "&"; + + query += pair.Key + "=" + pair.Value; + } + + return query; + } + #endregion + } + + public class LootLockerHTTPRequestContent + { + public LootLockerHTTPRequestContent(LootLockerHTTPRequestDataType type = LootLockerHTTPRequestDataType.EMPTY) + { + this.dataType = type; + } + public LootLockerHTTPRequestDataType dataType { get; set; } + + public override int GetHashCode() + { +#if UNITY_2021_3_OR_NEWER + return HashCode.Combine(dataType, string.Empty.GetHashCode()); +#else + unchecked + { + int hash = 17; + hash = hash * 31 + ((int)dataType); + hash = hash * 31 + string.Empty.GetHashCode(); + return hash; + } +#endif + } + } + + public class LootLockerJsonBodyRequestContent : LootLockerHTTPRequestContent + { + public LootLockerJsonBodyRequestContent(string jsonBody) : base(LootLockerHTTPRequestDataType.JSON) + { + this.jsonBody = jsonBody; + } + public string jsonBody { get; set; } + + public override int GetHashCode() + { +#if UNITY_2021_3_OR_NEWER + return HashCode.Combine(dataType, jsonBody.GetHashCode()); +#else + unchecked + { + int hash = 17; + hash = hash * 31 + ((int)dataType); + hash = hash * 31 + jsonBody.GetHashCode(); + return hash; + } +#endif + } + } + + public class LootLockerWWWFormRequestContent : LootLockerHTTPRequestContent + { + public LootLockerWWWFormRequestContent(byte[] content, string name, string type) : base(LootLockerHTTPRequestDataType.WWW_FORM) + { + this.content = content; + this.name = name; + this.type = type; + } + public byte[] content { get; set; } + public string name { get; set; } + public string type { get; set; } + + public override int GetHashCode() + { +#if UNITY_2021_3_OR_NEWER + return HashCode.Combine(dataType, content.GetHashCode(), name.GetHashCode(), type.GetHashCode()); +#else + unchecked + { + int hash = 17; + hash = hash * 31 + ((int)dataType); + hash = hash * 31 + content.GetHashCode(); + hash = hash * 31 + name.GetHashCode(); + hash = hash * 31 + type.GetHashCode(); + return hash; + } +#endif + } + } + + public class LootLockerFileRequestContent : LootLockerHTTPRequestContent + { + public LootLockerFileRequestContent(byte[] content, string name, Dictionary formFields) : base(LootLockerHTTPRequestDataType.FILE) + { + this.fileForm = new WWWForm(); + + foreach (var kvp in formFields) + { + this.fileForm.AddField(kvp.Key, kvp.Value); + } + + this.fileForm.AddBinaryData("file", content, name); + } + public WWWForm fileForm { get; set; } + + public override int GetHashCode() + { +#if UNITY_2021_3_OR_NEWER + return HashCode.Combine(dataType, fileForm.GetHashCode()); +#else + unchecked + { + int hash = 17; + hash = hash * 31 + ((int)dataType); + hash = hash * 31 + fileForm.GetHashCode(); + return hash; + } +#endif + } + } +} diff --git a/Runtime/Client/LootLockerHttpRequestData.cs.meta b/Runtime/Client/LootLockerHttpRequestData.cs.meta new file mode 100644 index 000000000..4164c0e16 --- /dev/null +++ b/Runtime/Client/LootLockerHttpRequestData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a2c818ad2b7d04166baf238c3dc9d7d5 \ No newline at end of file diff --git a/Runtime/Client/LootLockerJson.cs b/Runtime/Client/LootLockerJson.cs new file mode 100644 index 000000000..6aafecc4b --- /dev/null +++ b/Runtime/Client/LootLockerJson.cs @@ -0,0 +1,111 @@ +using System; +#if LOOTLOCKER_USE_NEWTONSOFTJSON +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Newtonsoft.Json.Converters; +#else +using LLlibs.ZeroDepJson; +#endif +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace LootLocker +{ + public static class LootLockerJsonSettings + { +#if LOOTLOCKER_USE_NEWTONSOFTJSON + public static readonly JsonSerializerSettings Default = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() }, + Converters = {new StringEnumConverter()}, + Formatting = Formatting.None + }; +#else + public static readonly JsonOptions Default = new JsonOptions((JsonSerializationOptions.Default | JsonSerializationOptions.EnumAsText) & ~JsonSerializationOptions.SkipGetOnly); +#endif + } + + public static class LootLockerJson + { +#if LOOTLOCKER_USE_NEWTONSOFTJSON + public static string SerializeObject(object obj) + { + return SerializeObject(obj, LootLockerJsonSettings.Default); + } + + public static string SerializeObject(object obj, JsonSerializerSettings settings) + { + return JsonConvert.SerializeObject(obj, settings ?? LootLockerJsonSettings.Default); + } + + public static T DeserializeObject(string json) + { + return DeserializeObject(json, LootLockerJsonSettings.Default); + } + + public static T DeserializeObject(string json, JsonSerializerSettings settings) + { + return JsonConvert.DeserializeObject(json, settings ?? LootLockerJsonSettings.Default); + } + + public static bool TryDeserializeObject(string json, out T output) + { + return TryDeserializeObject(json, LootLockerJsonSettings.Default, out output); + } + + public static bool TryDeserializeObject(string json, JsonSerializerSettings options, out T output) + { + try + { + output = JsonConvert.DeserializeObject(json, options ?? LootLockerJsonSettings.Default); + return true; + } + catch (Exception) + { + output = default(T); + return false; + } + } +#else //LOOTLOCKER_USE_NEWTONSOFTJSON + public static string SerializeObject(object obj) + { + return SerializeObject(obj, LootLockerJsonSettings.Default); + } + + public static string SerializeObject(object obj, JsonOptions options) + { + return Json.Serialize(obj, options ?? LootLockerJsonSettings.Default); + } + + public static T DeserializeObject(string json) + { + return DeserializeObject(json, LootLockerJsonSettings.Default); + } + + public static T DeserializeObject(string json, JsonOptions options) + { + return Json.Deserialize(json, options ?? LootLockerJsonSettings.Default); + } + + public static bool TryDeserializeObject(string json, out T output) + { + return TryDeserializeObject(json, LootLockerJsonSettings.Default, out output); + } + + public static bool TryDeserializeObject(string json, JsonOptions options, out T output) + { + try + { + output = Json.Deserialize(json, options ?? LootLockerJsonSettings.Default); + return true; + } + catch (Exception) + { + output = default(T); + return false; + } + } +#endif //LOOTLOCKER_USE_NEWTONSOFTJSON + } +} diff --git a/Runtime/Client/LootLockerJson.cs.meta b/Runtime/Client/LootLockerJson.cs.meta new file mode 100644 index 000000000..a33b6349a --- /dev/null +++ b/Runtime/Client/LootLockerJson.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e6baa2296f3a42bd97769f1cb18bbfd \ No newline at end of file diff --git a/Runtime/Client/LootLockerPagination.cs b/Runtime/Client/LootLockerPagination.cs new file mode 100644 index 000000000..b25a7c399 --- /dev/null +++ b/Runtime/Client/LootLockerPagination.cs @@ -0,0 +1,66 @@ +namespace LootLocker +{ + public class LootLockerPaginationResponse + { + /// + /// The total available items in this list + /// + public int total { get; set; } + /// + /// The cursor that points to the next item in the list. Use this in subsequent requests to get additional items from the list. + /// + public TKey next_cursor { get; set; } + /// + /// The cursor that points to the first item in this batch of items. + /// + public TKey previous_cursor { get; set; } + } + + public class LootLockerExtendedPaginationError + { + /// + /// Which field in the pagination that this error relates to + /// + public string field { get; set; } + /// + /// The error message in question + /// + public string message { get; set; } + } + + public class LootLockerExtendedPagination + { + /// + /// How many entries in total exists in the paginated list + /// + public int total { get; set; } + /// + /// How many entries (counting from the beginning of the paginated list) from the first entry that the current page starts at + /// + public int offset { get; set; } + /// + /// Number of entries on each page + /// + public int per_page { get; set; } + /// + /// The page index to use for fetching the last page of entries + /// + public int last_page { get; set; } + /// + /// The page index used for fetching this page of entries + /// + public int current_page { get; set; } + /// + /// The page index to use for fetching the page of entries immediately succeeding this page of entries + /// + public int? next_page { get; set; } + /// + /// The page index to use for fetching the page of entries immediately preceding this page of entries + /// + public int? prev_page { get; set; } + /// + /// List of pagination errors (if any). These are errors specifically related to the pagination of the entry set. + /// + public LootLockerExtendedPaginationError[] errors { get; set; } + } + } diff --git a/Runtime/Client/LootLockerPagination.cs.meta b/Runtime/Client/LootLockerPagination.cs.meta new file mode 100644 index 000000000..c245f6339 --- /dev/null +++ b/Runtime/Client/LootLockerPagination.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f9a561fddc7a147ddb12aa055b46429e \ No newline at end of file diff --git a/Runtime/Client/LootLockerRateLimiter.cs b/Runtime/Client/LootLockerRateLimiter.cs new file mode 100644 index 000000000..71c395bbd --- /dev/null +++ b/Runtime/Client/LootLockerRateLimiter.cs @@ -0,0 +1,158 @@ + +using System; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace LootLocker +{ + #region Rate Limiting Support + + public class RateLimiter + { + protected bool EnableRateLimiter = LootLockerConfig.IsTargetingProductionEnvironment(); + /* -- Configurable constants -- */ + // Tripwire settings, allow for a max total of n requests per x seconds + protected const int TripWireTimeFrameSeconds = 60; + protected const int MaxRequestsPerTripWireTimeFrame = 280; + protected const int SecondsPerBucket = 5; // Needs to evenly divide the time frame + + // Moving average settings, allow for a max average of n requests per x seconds + protected const float AllowXPercentOfTripWireMaxForMovingAverage = 0.8f; // Moving average threshold (the average number of requests per bucket) is set slightly lower to stop constant abusive call behaviour just under the tripwire limit + protected const int CountMovingAverageAcrossNTripWireTimeFrames = 3; // Count Moving average across a longer time period + + /* -- Calculated constants -- */ + protected const int BucketsPerTimeFrame = TripWireTimeFrameSeconds / SecondsPerBucket; + protected const int RateLimitMovingAverageBucketCount = CountMovingAverageAcrossNTripWireTimeFrames * BucketsPerTimeFrame; + private const int MaxRequestsPerBucketOnMovingAverage = (int)((MaxRequestsPerTripWireTimeFrame * AllowXPercentOfTripWireMaxForMovingAverage) / (BucketsPerTimeFrame)); + + + /* -- Functionality -- */ + protected readonly int[] buckets = new int[RateLimitMovingAverageBucketCount]; + + protected int lastBucket = -1; + private DateTime _lastBucketChangeTime = DateTime.MinValue; + private int _totalRequestsInBuckets; + private int _totalRequestsInBucketsInTripWireTimeFrame; + + protected bool isRateLimited = false; + private DateTime _rateLimitResolvesAt = DateTime.MinValue; + + protected virtual DateTime GetTimeNow() + { + return DateTime.Now; + } + + public int GetSecondsLeftOfRateLimit() + { + if (!isRateLimited) + { + return 0; + } + return (int)Math.Ceiling((_rateLimitResolvesAt - GetTimeNow()).TotalSeconds); + } + private int MoveCurrentBucket(DateTime now) + { + int moveOverXBuckets = _lastBucketChangeTime == DateTime.MinValue ? 1 : (int)Math.Floor((now - _lastBucketChangeTime).TotalSeconds / SecondsPerBucket); + if (moveOverXBuckets == 0) + { + return lastBucket; + } + + for (int stepIndex = 1; stepIndex <= moveOverXBuckets; stepIndex++) + { + int bucketIndex = (lastBucket + stepIndex) % buckets.Length; + if (bucketIndex == lastBucket) + { + continue; + } + int bucketMovingOutOfTripWireTimeFrame = (bucketIndex - BucketsPerTimeFrame) < 0 ? buckets.Length + (bucketIndex - BucketsPerTimeFrame) : bucketIndex - BucketsPerTimeFrame; + _totalRequestsInBucketsInTripWireTimeFrame -= buckets[bucketMovingOutOfTripWireTimeFrame]; // Remove the request count from the bucket that is moving out of the time frame from trip wire count + _totalRequestsInBuckets -= buckets[bucketIndex]; // Remove the count from the bucket we're moving into from the total before emptying it + buckets[bucketIndex] = 0; + } + + return (lastBucket + moveOverXBuckets) % buckets.Length; // Step to next bucket and wrap around if necessary; + } + + public virtual bool AddRequestAndCheckIfRateLimitHit() + { + //Disable local ratelimiter when not targeting production + if (!EnableRateLimiter) + { + return false; + } + + DateTime now = GetTimeNow(); + var currentBucket = MoveCurrentBucket(now); + + if (isRateLimited) + { + if (_totalRequestsInBuckets <= 0) + { + isRateLimited = false; + _rateLimitResolvesAt = DateTime.MinValue; + } + } + else + { + buckets[currentBucket]++; // Increment the current bucket + _totalRequestsInBuckets++; // Increment the total request count + _totalRequestsInBucketsInTripWireTimeFrame++; // Increment the request count for the current time frame + + isRateLimited |= _totalRequestsInBucketsInTripWireTimeFrame >= MaxRequestsPerTripWireTimeFrame; // If the request count for the time frame is greater than the max requests per time frame, set isRateLimited to true + isRateLimited |= _totalRequestsInBuckets / RateLimitMovingAverageBucketCount > MaxRequestsPerBucketOnMovingAverage; // If the average number of requests per bucket is greater than the max requests on moving average, set isRateLimited to true +#if UNITY_EDITOR + if (_totalRequestsInBucketsInTripWireTimeFrame >= MaxRequestsPerTripWireTimeFrame) LootLockerLogger.GetForLogLevel()("Rate Limit Hit due to Trip Wire, count = " + _totalRequestsInBucketsInTripWireTimeFrame + " out of allowed " + MaxRequestsPerTripWireTimeFrame); + if (_totalRequestsInBuckets / RateLimitMovingAverageBucketCount > MaxRequestsPerBucketOnMovingAverage) LootLockerLogger.GetForLogLevel()("Rate Limit Hit due to Moving Average, count = " + _totalRequestsInBuckets / RateLimitMovingAverageBucketCount + " out of allowed " + MaxRequestsPerBucketOnMovingAverage); +#endif + if (isRateLimited) + { + _rateLimitResolvesAt = (now - TimeSpan.FromSeconds(now.Second % SecondsPerBucket)) + TimeSpan.FromSeconds(buckets.Length*SecondsPerBucket); + } + } + if (currentBucket != lastBucket) + { + _lastBucketChangeTime = now; + lastBucket = currentBucket; + } + return isRateLimited; + } + + protected int GetMaxRequestsInSingleBucket() + { + int maxRequests = 0; + foreach (var t in buckets) + { + maxRequests = Math.Max(maxRequests, t); + } + + return maxRequests; + } + + private static RateLimiter _rateLimiter = null; + + public static RateLimiter Get() + { + if (_rateLimiter == null) + { + Reset(); + } + return _rateLimiter; + } + + public static void Reset() + { + _rateLimiter = new RateLimiter(); + } + +#if UNITY_EDITOR + [InitializeOnEnterPlayMode] + static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) + { + Reset(); + } +#endif + } + #endregion +} diff --git a/Runtime/Client/LootLockerRateLimiter.cs.meta b/Runtime/Client/LootLockerRateLimiter.cs.meta new file mode 100644 index 000000000..d8659de14 --- /dev/null +++ b/Runtime/Client/LootLockerRateLimiter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9097699b2a8684c5f8275046ab9bfc46 \ No newline at end of file diff --git a/Runtime/Client/LootLockerResponse.cs b/Runtime/Client/LootLockerResponse.cs new file mode 100644 index 000000000..ed4381c1f --- /dev/null +++ b/Runtime/Client/LootLockerResponse.cs @@ -0,0 +1,202 @@ +using System; +#if LOOTLOCKER_USE_NEWTONSOFTJSON +using Newtonsoft.Json; +#else +using LLlibs.ZeroDepJson; +#endif +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace LootLocker +{ + /// + /// All ServerAPI.SendRequest responses will invoke the callback using an instance of this class for easier handling in client code. + /// + public class LootLockerResponse + { + /// + /// HTTP Status Code + /// + public int statusCode { get; set; } + + /// + /// Whether this request was a success + /// + public bool success { get; set; } + + /// + /// Raw text/http body from the server response + /// + public string text { get; set; } + + /// + /// If this request was not a success, this structure holds all the information needed to identify the problem + /// + public LootLockerErrorData errorData { get; set; } + + /// + /// inheritdoc added this because unity main thread executing style cut the calling stack and make the event orphan see also calling multiple events + /// of the same type makes use unable to identify each one + /// + public string EventId { get; set; } = Guid.NewGuid().ToString(); + + public static void Deserialize(Action onComplete, LootLockerResponse serverResponse, +#if LOOTLOCKER_USE_NEWTONSOFTJSON + JsonSerializerSettings options = null +#else //LOOTLOCKER_USE_NEWTONSOFTJSON + JsonOptions options = null +#endif + ) + where T : LootLockerResponse, new() + { + onComplete?.Invoke(Deserialize(serverResponse, options)); + } + + public static T Deserialize(LootLockerResponse serverResponse, +#if LOOTLOCKER_USE_NEWTONSOFTJSON + JsonSerializerSettings options = null +#else //LOOTLOCKER_USE_NEWTONSOFTJSON + JsonOptions options = null +#endif + ) + where T : LootLockerResponse, new() + { + if (serverResponse == null) + { + return LootLockerResponseFactory.ClientError("Unknown error, please check your internet connection."); + } + else if (serverResponse.errorData != null) + { + return new T() { success = false, errorData = serverResponse.errorData, statusCode = serverResponse.statusCode, text = serverResponse.text }; + } + + var response = LootLockerJson.DeserializeObject(serverResponse.text, options ?? LootLockerJsonSettings.Default) ?? new T(); + + response.text = serverResponse.text; + response.success = serverResponse.success; + response.errorData = serverResponse.errorData; + response.statusCode = serverResponse.statusCode; + response.EventId = serverResponse.EventId; + + return response; + } + } + + /// + /// Convenience factory class for creating some responses that we use often. + /// + public class LootLockerResponseFactory + { + /// + /// Construct a success response + /// + public static T Success(int statusCode, string responseBody) where T : LootLockerResponse, new() + { + return new T() + { + success = true, + text = responseBody, + statusCode = statusCode, + errorData = null + }; + } + + /// + /// Construct a failure response + /// + public static T Failure(int statusCode, string responseBody) where T : LootLockerResponse, new() + { + return new T() + { + success = false, + text = responseBody, + statusCode = statusCode, + errorData = null + }; + } + + /// + /// Construct an error response from a network request to send to the client. + /// + public static T NetworkError(string errorMessage, int httpStatusCode) where T : LootLockerResponse, new() + { + return new T() + { + success = false, + text = "{ \"message\": \"" + errorMessage + "\"}", + statusCode = httpStatusCode, + errorData = new LootLockerErrorData(httpStatusCode, errorMessage) + }; + } + + /// + /// Construct an error response from a client side error to send to the client. + /// + public static T ClientError(string errorMessage) where T : LootLockerResponse, new() + { + return new T() + { + success = false, + text = "{ \"message\": \"" + errorMessage + "\"}", + statusCode = 0, + errorData = new LootLockerErrorData + { + message = errorMessage, + } + }; + } + + /// + /// Construct an error response for token expiration. + /// + public static T TokenExpiredError() where T : LootLockerResponse, new() + { + return NetworkError("Token Expired", 401); + } + + /// + /// Construct an error response for the request being timed out client side + /// + public static T RequestTimeOut() where T : LootLockerResponse, new() + { + return NetworkError("The request has timed out", 408); + } + + /// + /// Construct an error response specifically when the SDK has not been initialized. + /// + public static T SDKNotInitializedError() where T : LootLockerResponse, new() + { + return ClientError("The LootLocker SDK has not been initialized, please start a session to call this method"); + } + + /// + /// Construct an error response because an unserializable input has been given + /// + public static T InputUnserializableError() where T : LootLockerResponse, new() + { + return ClientError("Method parameter could not be serialized"); + } + + /// + /// Construct an error response because the rate limit has been hit + /// + public static T RateLimitExceeded(string method, int secondsLeftOfRateLimit) where T : LootLockerResponse, new() + { + var error = ClientError($"Your request to {method} was not sent. You are sending too many requests and are being rate limited for {secondsLeftOfRateLimit} seconds"); + error.errorData.retry_after_seconds = secondsLeftOfRateLimit; + return error; + } + + /// + /// Construct a default constructed successful response of the specified type + /// + public static T EmptySuccess() where T : LootLockerResponse, new() + { + T response = new T(); + response.text = LootLockerJson.SerializeObject(response); + return response; + } + } +} \ No newline at end of file diff --git a/Runtime/Client/LootLockerResponse.cs.meta b/Runtime/Client/LootLockerResponse.cs.meta new file mode 100644 index 000000000..996a4a7eb --- /dev/null +++ b/Runtime/Client/LootLockerResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b62934f950294b3da56ebc0ec400691 \ No newline at end of file diff --git a/Runtime/Client/LootLockerServerApi.cs b/Runtime/Client/LootLockerServerApi.cs index e17cafd23..0b6b2ad76 100644 --- a/Runtime/Client/LootLockerServerApi.cs +++ b/Runtime/Client/LootLockerServerApi.cs @@ -1,3 +1,4 @@ +#if !LOOTLOCKER_BETA_HTTP_QUEUE using System.Collections; using System.Collections.Generic; using UnityEngine; @@ -13,17 +14,12 @@ using UnityEditorInternal; #endif -namespace LootLocker.LootLockerEnums -{ - public enum LootLockerCallerRole { User, Admin, Player, Base }; -} - namespace LootLocker { - public class LootLockerServerApi : MonoBehaviour + public class LootLockerHTTPClient : MonoBehaviour { private static bool _bTaggedGameObjects = false; - private static LootLockerServerApi _instance; + private static LootLockerHTTPClient _instance; private static int _instanceId = 0; private const int MaxRetries = 3; private int _tries; @@ -33,13 +29,13 @@ public static void Instantiate() { if (_instance == null) { - var gameObject = new GameObject("LootLockerServerApi"); + var gameObject = new GameObject("LootLockerHTTPClient"); if (_bTaggedGameObjects) { - gameObject.tag = "LootLockerServerApiGameObject"; + gameObject.tag = "LootLockerHTTPClientGameObject"; } - _instance = gameObject.AddComponent(); + _instance = gameObject.AddComponent(); _instanceId = _instance.GetInstanceID(); _instance.HostingGameObject = gameObject; _instance.StartCoroutine(CleanUpOldInstances()); @@ -51,11 +47,11 @@ public static void Instantiate() public static IEnumerator CleanUpOldInstances() { #if UNITY_2020_1_OR_NEWER - LootLockerServerApi[] serverApis = GameObject.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + LootLockerHTTPClient[] serverApis = GameObject.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); #else - LootLockerServerApi[] serverApis = GameObject.FindObjectsOfType(); + LootLockerHTTPClient[] serverApis = GameObject.FindObjectsOfType(); #endif - foreach (LootLockerServerApi serverApi in serverApis) + foreach (LootLockerHTTPClient serverApi in serverApis) { if (serverApi != null && _instanceId != serverApi.GetInstanceID() && serverApi.HostingGameObject != null) { @@ -89,6 +85,10 @@ private static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) } #endif + void Update() + { + } + public static void SendRequest(LootLockerServerRequest request, Action OnServerResponse = null) { if (_instance == null) @@ -204,7 +204,7 @@ IEnumerator coroutine() private static bool ShouldRetryRequest(long statusCode, int timesRetried) { - return (statusCode == 401 || statusCode == 403) && LootLockerConfig.current.allowTokenRefresh && CurrentPlatform.Get() != Platforms.Steam && timesRetried < MaxRetries; + return (statusCode == 401 || statusCode == 403 || statusCode == 502 || statusCode == 500 || statusCode == 503) && LootLockerConfig.current.allowTokenRefresh && CurrentPlatform.Get() != Platforms.Steam && timesRetried < MaxRetries; } private static void LogResponse(LootLockerServerRequest request, long statusCode, string responseBody, float startTime, string unityWebRequestError) @@ -543,3 +543,4 @@ private string GetQueryStringFromDictionary(Dictionary queryDict #endregion } } +#endif diff --git a/Runtime/Client/LootLockerServerRequest.cs b/Runtime/Client/LootLockerServerRequest.cs index 8d73d0f4a..d2ebe0f7b 100644 --- a/Runtime/Client/LootLockerServerRequest.cs +++ b/Runtime/Client/LootLockerServerRequest.cs @@ -1,594 +1,11 @@ +#if !LOOTLOCKER_BETA_HTTP_QUEUE using System.Collections.Generic; using UnityEngine; using System; using LootLocker.LootLockerEnums; -#if LOOTLOCKER_USE_NEWTONSOFTJSON -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Newtonsoft.Json.Converters; -#else -using LLlibs.ZeroDepJson; -#endif -#if UNITY_EDITOR -using UnityEditor; -#endif -//this is common between user and admin namespace LootLocker { - - public static class LootLockerJsonSettings - { -#if LOOTLOCKER_USE_NEWTONSOFTJSON - public static readonly JsonSerializerSettings Default = new JsonSerializerSettings - { - ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() }, - Converters = {new StringEnumConverter()}, - Formatting = Formatting.None - }; -#else - public static readonly JsonOptions Default = new JsonOptions((JsonSerializationOptions.Default | JsonSerializationOptions.EnumAsText) & ~JsonSerializationOptions.SkipGetOnly); -#endif - } - - public static class LootLockerJson - { -#if LOOTLOCKER_USE_NEWTONSOFTJSON - public static string SerializeObject(object obj) - { - return SerializeObject(obj, LootLockerJsonSettings.Default); - } - - public static string SerializeObject(object obj, JsonSerializerSettings settings) - { - return JsonConvert.SerializeObject(obj, settings ?? LootLockerJsonSettings.Default); - } - - public static T DeserializeObject(string json) - { - return DeserializeObject(json, LootLockerJsonSettings.Default); - } - - public static T DeserializeObject(string json, JsonSerializerSettings settings) - { - return JsonConvert.DeserializeObject(json, settings ?? LootLockerJsonSettings.Default); - } - - public static bool TryDeserializeObject(string json, out T output) - { - return TryDeserializeObject(json, LootLockerJsonSettings.Default, out output); - } - - public static bool TryDeserializeObject(string json, JsonSerializerSettings options, out T output) - { - try - { - output = JsonConvert.DeserializeObject(json, options ?? LootLockerJsonSettings.Default); - return true; - } - catch (Exception) - { - output = default(T); - return false; - } - } -#else //LOOTLOCKER_USE_NEWTONSOFTJSON - public static string SerializeObject(object obj) - { - return SerializeObject(obj, LootLockerJsonSettings.Default); - } - - public static string SerializeObject(object obj, JsonOptions options) - { - return Json.Serialize(obj, options ?? LootLockerJsonSettings.Default); - } - - public static T DeserializeObject(string json) - { - return DeserializeObject(json, LootLockerJsonSettings.Default); - } - - public static T DeserializeObject(string json, JsonOptions options) - { - return Json.Deserialize(json, options ?? LootLockerJsonSettings.Default); - } - - public static bool TryDeserializeObject(string json, out T output) - { - return TryDeserializeObject(json, LootLockerJsonSettings.Default, out output); - } - - public static bool TryDeserializeObject(string json, JsonOptions options, out T output) - { - try - { - output = Json.Deserialize(json, options ?? LootLockerJsonSettings.Default); - return true; - } catch (Exception) - { - output = default(T); - return false; - } - } -#endif //LOOTLOCKER_USE_NEWTONSOFTJSON - } - - [Serializable] - public enum LootLockerHTTPMethod - { - GET = 0, - POST = 1, - DELETE = 2, - PUT = 3, - HEAD = 4, - CREATE = 5, - OPTIONS = 6, - PATCH = 7, - UPLOAD_FILE = 8, - UPDATE_FILE = 9 - } - - public class LootLockerErrorData - { - public LootLockerErrorData(int httpStatusCode, string errorMessage) - { - code = $"HTTP{httpStatusCode}"; - doc_url = $"https://developer.mozilla.org/docs/Web/HTTP/Status/{httpStatusCode}"; - message = errorMessage; - } - - public LootLockerErrorData() { } - - /// - /// A descriptive code identifying the error. - /// - public string code { get; set; } - - /// - /// A link to further documentation on the error. - /// - public string doc_url { get; set; } - - /// - /// A unique identifier of the request to use in contact with support. - /// - public string request_id { get; set; } - - /// - /// A unique identifier for tracing the request through LootLocker systems, use this in contact with support. - /// - public string trace_id { get; set; } - - /// - /// If the request was not a success this property will hold any error messages - /// - public string message { get; set; } - - /// - /// If the request was rate limited (status code 429) or the servers were temporarily unavailable (status code 503) you can use this value to determine how many seconds to wait before retrying - /// - public int? retry_after_seconds { get; set; } = null; - - /// - /// An easy way of debugging LootLockerErrorData class, example: Debug.Log(onComplete.errorData); - /// - /// string used to debug errors - public override string ToString() - { - // Empty error, make sure we print something - if (string.IsNullOrEmpty(message) && string.IsNullOrEmpty(trace_id) && string.IsNullOrEmpty(request_id)) - { - return $"An unexpected LootLocker error without error data occurred. Please try again later.\n If the issue persists, please contact LootLocker support."; - } - - //Print the most important info first - string prettyError = $"LootLocker Error: \"{message ?? ""}\""; - - // Look for intermittent, non user errors - if (!string.IsNullOrEmpty(code) && code.StartsWith("HTTP5")) - { - prettyError += - $"\nTry again later. If the issue persists, please contact LootLocker support and provide the following error details:\n trace ID - \"{trace_id ?? ""}\",\n request ID - \"{request_id ?? ""}\",\n message - \"{message ?? ""}\"."; - if (!string.IsNullOrEmpty(doc_url)) - { - prettyError += $"\nFor more information, see {doc_url} (error code was \"{code}\")."; - } - } - // Print user errors - else - { - prettyError += - $"\nThere was a problem with your request. The error message provides information on the problem and will help you fix it."; - if (!string.IsNullOrEmpty(doc_url ?? "")) - { - prettyError += $"\nFor more information, see {doc_url ?? ""} (error code was \"{code ?? ""}\")."; - } - - prettyError += - $"\nIf you are unable to fix the issue, contact LootLocker support and provide the following error details:"; - if (!string.IsNullOrEmpty(trace_id ?? "")) - { - prettyError += $"\n trace ID - \"{trace_id}\""; - } - if (!string.IsNullOrEmpty(request_id)) - { - prettyError += $"\n request ID - \"{request_id}\""; - } - - prettyError += $"\n message - \"{message ?? ""}\"."; - } - return prettyError; - } - } - - /// - /// All ServerAPI.SendRequest responses will invoke the callback using an instance of this class for easier handling in client code. - /// - public class LootLockerResponse - { - /// - /// HTTP Status Code - /// - public int statusCode { get; set; } - - /// - /// Whether this request was a success - /// - public bool success { get; set; } - - /// - /// Raw text/http body from the server response - /// - public string text { get; set; } - - /// - /// If this request was not a success, this structure holds all the information needed to identify the problem - /// - public LootLockerErrorData errorData { get; set; } - - /// - /// inheritdoc added this because unity main thread executing style cut the calling stack and make the event orphan see also calling multiple events - /// of the same type makes use unable to identify each one - /// - public string EventId { get; set; } - - public static void Deserialize(Action onComplete, LootLockerResponse serverResponse, -#if LOOTLOCKER_USE_NEWTONSOFTJSON - JsonSerializerSettings options = null -#else //LOOTLOCKER_USE_NEWTONSOFTJSON - JsonOptions options = null -#endif - ) - where T : LootLockerResponse, new() - { - onComplete?.Invoke(Deserialize(serverResponse, options)); - } - - public static T Deserialize(LootLockerResponse serverResponse, -#if LOOTLOCKER_USE_NEWTONSOFTJSON - JsonSerializerSettings options = null -#else //LOOTLOCKER_USE_NEWTONSOFTJSON - JsonOptions options = null -#endif - ) - where T : LootLockerResponse, new() - { - if (serverResponse == null) - { - return LootLockerResponseFactory.ClientError("Unknown error, please check your internet connection."); - } - else if (serverResponse.errorData != null) - { - return new T() { success = false, errorData = serverResponse.errorData, statusCode = serverResponse.statusCode, text = serverResponse.text }; - } - - var response = LootLockerJson.DeserializeObject(serverResponse.text, options ?? LootLockerJsonSettings.Default) ?? new T(); - - response.text = serverResponse.text; - response.success = serverResponse.success; - response.errorData = serverResponse.errorData; - response.statusCode = serverResponse.statusCode; - - return response; - } - } - - public class LootLockerPaginationResponse - { - /// - /// The total available items in this list - /// - public int total { get; set; } - /// - /// The cursor that points to the next item in the list. Use this in subsequent requests to get additional items from the list. - /// - public TKey next_cursor { get; set; } - /// - /// The cursor that points to the first item in this batch of items. - /// - public TKey previous_cursor { get; set; } - } - - public class LootLockerExtendedPaginationError - { - /// - /// Which field in the pagination that this error relates to - /// - public string field { get; set; } - /// - /// The error message in question - /// - public string message { get; set; } - } - - public class LootLockerExtendedPagination - { - /// - /// How many entries in total exists in the paginated list - /// - public int total { get; set; } - /// - /// How many entries (counting from the beginning of the paginated list) from the first entry that the current page starts at - /// - public int offset { get; set; } - /// - /// Number of entries on each page - /// - public int per_page { get; set; } - /// - /// The page index to use for fetching the last page of entries - /// - public int last_page { get; set; } - /// - /// The page index used for fetching this page of entries - /// - public int current_page { get; set; } - /// - /// The page index to use for fetching the page of entries immediately succeeding this page of entries - /// - public int? next_page { get; set; } - /// - /// The page index to use for fetching the page of entries immediately preceding this page of entries - /// - public int? prev_page { get; set; } - /// - /// List of pagination errors (if any). These are errors specifically related to the pagination of the entry set. - /// - public LootLockerExtendedPaginationError[] errors { get; set; } - } - - /// - /// Convenience factory class for creating some responses that we use often. - /// - public class LootLockerResponseFactory - { - /// - /// Construct an error response from a network request to send to the client. - /// - public static T NetworkError(string errorMessage, int httpStatusCode) where T : LootLockerResponse, new() - { - return new T() - { - success = false, - text = "{ \"message\": \"" + errorMessage + "\"}", - statusCode = httpStatusCode, - errorData = new LootLockerErrorData(httpStatusCode, errorMessage) - }; - } - - /// - /// Construct an error response from a client side error to send to the client. - /// - public static T ClientError(string errorMessage) where T : LootLockerResponse, new() - { - return new T() - { - success = false, - text = "{ \"message\": \"" + errorMessage + "\"}", - statusCode = 0, - errorData = new LootLockerErrorData - { - message = errorMessage, - } - }; - } - - /// - /// Construct an error response for token expiration. - /// - public static T TokenExpiredError() where T : LootLockerResponse, new() - { - return NetworkError("Token Expired", 401); - } - - /// - /// Construct an error response specifically when the SDK has not been initialized. - /// - public static T SDKNotInitializedError() where T : LootLockerResponse, new() - { - return ClientError("The LootLocker SDK has not been initialized, please start a session to call this method"); - } - - /// - /// Construct an error response because an unserializable input has been given - /// - public static T InputUnserializableError() where T : LootLockerResponse, new() - { - return ClientError("Method parameter could not be serialized"); - } - - /// - /// Construct an error response because the rate limit has been hit - /// - public static T RateLimitExceeded(string method, int secondsLeftOfRateLimit) where T : LootLockerResponse, new() - { - var error = ClientError($"Your request to {method} was not sent. You are sending too many requests and are being rate limited for {secondsLeftOfRateLimit} seconds"); - error.errorData.retry_after_seconds = secondsLeftOfRateLimit; - return error; - } - - /// - /// Construct a default constructed successful response of the specified type - /// - public static T EmptySuccess() where T : LootLockerResponse, new() - { - T response = new T(); - response.text = LootLockerJson.SerializeObject(response); - return response; - } - } - - - - #region Rate Limiting Support - - public class RateLimiter - { - /* -- Configurable constants -- */ - // Tripwire settings, allow for a max total of n requests per x seconds - protected const int TripWireTimeFrameSeconds = 60; - protected const int MaxRequestsPerTripWireTimeFrame = 280; - protected const int SecondsPerBucket = 5; // Needs to evenly divide the time frame - - // Moving average settings, allow for a max average of n requests per x seconds - protected const float AllowXPercentOfTripWireMaxForMovingAverage = 0.8f; // Moving average threshold (the average number of requests per bucket) is set slightly lower to stop constant abusive call behaviour just under the tripwire limit - protected const int CountMovingAverageAcrossNTripWireTimeFrames = 3; // Count Moving average across a longer time period - - /* -- Calculated constants -- */ - protected const int BucketsPerTimeFrame = TripWireTimeFrameSeconds / SecondsPerBucket; - protected const int RateLimitMovingAverageBucketCount = CountMovingAverageAcrossNTripWireTimeFrames * BucketsPerTimeFrame; - private const int MaxRequestsPerBucketOnMovingAverage = (int)((MaxRequestsPerTripWireTimeFrame * AllowXPercentOfTripWireMaxForMovingAverage) / (BucketsPerTimeFrame)); - - - /* -- Functionality -- */ - protected readonly int[] buckets = new int[RateLimitMovingAverageBucketCount]; - - protected int lastBucket = -1; - private DateTime _lastBucketChangeTime = DateTime.MinValue; - private int _totalRequestsInBuckets; - private int _totalRequestsInBucketsInTripWireTimeFrame; - - protected bool isRateLimited = false; - private DateTime _rateLimitResolvesAt = DateTime.MinValue; - - protected virtual DateTime GetTimeNow() - { - return DateTime.Now; - } - - public int GetSecondsLeftOfRateLimit() - { - if (!isRateLimited) - { - return 0; - } - return (int)Math.Ceiling((_rateLimitResolvesAt - GetTimeNow()).TotalSeconds); - } - private int MoveCurrentBucket(DateTime now) - { - int moveOverXBuckets = _lastBucketChangeTime == DateTime.MinValue ? 1 : (int)Math.Floor((now - _lastBucketChangeTime).TotalSeconds / SecondsPerBucket); - if (moveOverXBuckets == 0) - { - return lastBucket; - } - - for (int stepIndex = 1; stepIndex <= moveOverXBuckets; stepIndex++) - { - int bucketIndex = (lastBucket + stepIndex) % buckets.Length; - if (bucketIndex == lastBucket) - { - continue; - } - int bucketMovingOutOfTripWireTimeFrame = (bucketIndex - BucketsPerTimeFrame) < 0 ? buckets.Length + (bucketIndex - BucketsPerTimeFrame) : bucketIndex - BucketsPerTimeFrame; - _totalRequestsInBucketsInTripWireTimeFrame -= buckets[bucketMovingOutOfTripWireTimeFrame]; // Remove the request count from the bucket that is moving out of the time frame from trip wire count - _totalRequestsInBuckets -= buckets[bucketIndex]; // Remove the count from the bucket we're moving into from the total before emptying it - buckets[bucketIndex] = 0; - } - - return (lastBucket + moveOverXBuckets) % buckets.Length; // Step to next bucket and wrap around if necessary; - } - - public virtual bool AddRequestAndCheckIfRateLimitHit() - { - //Disable local ratelimiter when not targeting production - if (!LootLockerConfig.IsTargetingProductionEnvironment()) - { - return false; - } - - DateTime now = GetTimeNow(); - var currentBucket = MoveCurrentBucket(now); - - if (isRateLimited) - { - if (_totalRequestsInBuckets <= 0) - { - isRateLimited = false; - _rateLimitResolvesAt = DateTime.MinValue; - } - } - else - { - buckets[currentBucket]++; // Increment the current bucket - _totalRequestsInBuckets++; // Increment the total request count - _totalRequestsInBucketsInTripWireTimeFrame++; // Increment the request count for the current time frame - - isRateLimited |= _totalRequestsInBucketsInTripWireTimeFrame >= MaxRequestsPerTripWireTimeFrame; // If the request count for the time frame is greater than the max requests per time frame, set isRateLimited to true - isRateLimited |= _totalRequestsInBuckets / RateLimitMovingAverageBucketCount > MaxRequestsPerBucketOnMovingAverage; // If the average number of requests per bucket is greater than the max requests on moving average, set isRateLimited to true -#if UNITY_EDITOR - if (_totalRequestsInBucketsInTripWireTimeFrame >= MaxRequestsPerTripWireTimeFrame) LootLockerLogger.GetForLogLevel()("Rate Limit Hit due to Trip Wire, count = " + _totalRequestsInBucketsInTripWireTimeFrame + " out of allowed " + MaxRequestsPerTripWireTimeFrame); - if (_totalRequestsInBuckets / RateLimitMovingAverageBucketCount > MaxRequestsPerBucketOnMovingAverage) LootLockerLogger.GetForLogLevel()("Rate Limit Hit due to Moving Average, count = " + _totalRequestsInBuckets / RateLimitMovingAverageBucketCount + " out of allowed " + MaxRequestsPerBucketOnMovingAverage); -#endif - if (isRateLimited) - { - _rateLimitResolvesAt = (now - TimeSpan.FromSeconds(now.Second % SecondsPerBucket)) + TimeSpan.FromSeconds(buckets.Length*SecondsPerBucket); - } - } - if (currentBucket != lastBucket) - { - _lastBucketChangeTime = now; - lastBucket = currentBucket; - } - return isRateLimited; - } - - protected int GetMaxRequestsInSingleBucket() - { - int maxRequests = 0; - foreach (var t in buckets) - { - maxRequests = Math.Max(maxRequests, t); - } - - return maxRequests; - } - - private static RateLimiter _rateLimiter = null; - - public static RateLimiter Get() - { - if (_rateLimiter == null) - { - Reset(); - } - return _rateLimiter; - } - - public static void Reset() - { - _rateLimiter = new RateLimiter(); - } - -#if UNITY_EDITOR - [InitializeOnEnterPlayMode] - static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) - { - Reset(); - } -#endif - } - #endregion - /// /// Construct a request to send to the server. /// @@ -666,35 +83,6 @@ public static void CallAPI(string endPoint, LootLockerHTTPMethod httpMethod, str new LootLockerServerRequest(endPoint, httpMethod, body, headers, callerRole: callerRole).Send((response) => { onComplete?.Invoke(response); }); } - public static void CallDomainAuthAPI(string endPoint, LootLockerHTTPMethod httpMethod, string body = null, Action onComplete = null) - { - if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit()) - { - onComplete?.Invoke(LootLockerResponseFactory.RateLimitExceeded(endPoint, RateLimiter.Get().GetSecondsLeftOfRateLimit())); - return; - } - - if (LootLockerConfig.current.domainKey.ToString().Length == 0) - { -#if UNITY_EDITOR - LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); -#endif - onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); - - return; - } - - Dictionary headers = new Dictionary(); - headers.Add("domain-key", LootLockerConfig.current.domainKey); - - if(LootLockerConfig.current.apiKey.StartsWith("dev_")) - { - headers.Add("is-development", "true"); - } - - new LootLockerServerRequest(endPoint, httpMethod, body, headers, callerRole: LootLockerCallerRole.Base).Send((response) => { onComplete?.Invoke(response); }); - } - public static void UploadFile(string endPoint, LootLockerHTTPMethod httpMethod, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary body = null, Action onComplete = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User) { if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit()) @@ -762,28 +150,6 @@ public LootLockerServerRequest(string endpoint, LootLockerHTTPMethod httpMethod } } - public LootLockerServerRequest(string endpoint, LootLockerHTTPMethod httpMethod = LootLockerHTTPMethod.GET, Dictionary payload = null, Dictionary extraHeaders = null, Dictionary queryParams = null, - bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User) - { - this.retryCount = 0; - this.endpoint = endpoint; - this.httpMethod = httpMethod; - this.payload = payload != null && payload.Count == 0 ? null : payload; //Force payload to null if an empty dictionary was supplied - this.upload = null; - this.uploadName = null; - this.uploadType = null; - this.jsonPayload = null; - this.extraHeaders = extraHeaders != null && extraHeaders.Count == 0 ? null : extraHeaders; // Force extra headers to null if empty dictionary was supplied - this.queryParams = queryParams != null && queryParams.Count == 0 ? null : queryParams; - this.callerRole = callerRole; - bool isNonPayloadMethod = (this.httpMethod == LootLockerHTTPMethod.GET || this.httpMethod == LootLockerHTTPMethod.HEAD || this.httpMethod == LootLockerHTTPMethod.OPTIONS); - this.form = null; - if (this.payload != null && isNonPayloadMethod) - { - LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Warning)("Payloads should not be sent in GET, HEAD, OPTIONS, requests. Attempted to send a payload to: " + this.httpMethod.ToString() + " " + this.endpoint); - } - } - public LootLockerServerRequest(string endpoint, LootLockerHTTPMethod httpMethod = LootLockerHTTPMethod.GET, string payload = null, Dictionary extraHeaders = null, Dictionary queryParams = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User) { @@ -813,7 +179,8 @@ public LootLockerServerRequest(string endpoint, LootLockerHTTPMethod httpMethod /// public void Send(System.Action OnServerResponse) { - LootLockerServerApi.SendRequest(this, (response) => { OnServerResponse?.Invoke(response); }); + LootLockerHTTPClient.SendRequest(this, (response) => { OnServerResponse?.Invoke(response); }); } } } +#endif diff --git a/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs b/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs index 00525598b..c258bd70d 100644 --- a/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs +++ b/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs @@ -779,7 +779,7 @@ void Logout() private void OnDestroy() { - LootLockerServerApi.ResetInstance(); + LootLockerHTTPClient.ResetInstance(); } } diff --git a/Runtime/Editor/LootLockerAdminEndPoints.cs b/Runtime/Editor/LootLockerAdminEndPoints.cs index 90fcba2eb..c178d06dc 100644 --- a/Runtime/Editor/LootLockerAdminEndPoints.cs +++ b/Runtime/Editor/LootLockerAdminEndPoints.cs @@ -1,4 +1,5 @@ using UnityEngine; +using LootLocker.LootLockerEnums; #if UNITY_EDITOR && UNITY_2021_3_OR_NEWER namespace LootLocker @@ -6,17 +7,17 @@ namespace LootLocker public class LootLockerAdminEndPoints { [Header("API Keys")] - public static EndPointClass adminExtensionGetAllKeys = new EndPointClass("game/{0}/api_keys", LootLockerHTTPMethod.GET); - public static EndPointClass adminExtensionCreateKey = new EndPointClass("game/{0}/api_keys", LootLockerHTTPMethod.POST); + public static EndPointClass adminExtensionGetAllKeys = new EndPointClass("game/{0}/api_keys", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass adminExtensionCreateKey = new EndPointClass("game/{0}/api_keys", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("Admin Authentication")] - public static EndPointClass adminExtensionLogin = new EndPointClass("v1/session", LootLockerHTTPMethod.POST); - public static EndPointClass adminExtensionMFA = new EndPointClass("v1/2fa", LootLockerHTTPMethod.POST); + public static EndPointClass adminExtensionLogin = new EndPointClass("v1/session", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminExtensionMFA = new EndPointClass("v1/2fa", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("User Information")] - public static EndPointClass adminExtensionUserInformation = new EndPointClass("v1/user/all", LootLockerHTTPMethod.GET); - public static EndPointClass adminExtensionGetUserRole = new EndPointClass("roles/{0}", LootLockerHTTPMethod.GET); - public static EndPointClass adminExtensionGetGameInformation = new EndPointClass("v1/game/{0}", LootLockerHTTPMethod.GET); + public static EndPointClass adminExtensionUserInformation = new EndPointClass("v1/user/all", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass adminExtensionGetUserRole = new EndPointClass("roles/{0}", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass adminExtensionGetGameInformation = new EndPointClass("v1/game/{0}", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); } } #endif \ No newline at end of file diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index f080bd195..462d319b5 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -41,7 +41,7 @@ public static string GetCurrentPlatform() static bool initialized; static bool Init() { - LootLockerServerApi.Instantiate(); + LootLockerHTTPClient.Instantiate(); return LoadConfig(); } @@ -54,7 +54,7 @@ static bool Init() /// True if initialized successfully, false otherwise public static bool Init(string apiKey, string gameVersion, string domainKey) { - LootLockerServerApi.Instantiate(); + LootLockerHTTPClient.Instantiate(); return LootLockerConfig.CreateNewSettings(apiKey, gameVersion, domainKey); } @@ -6146,7 +6146,13 @@ public static void ListEntitlements(int count, string after, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + LootLockerServerRequest.CallAPI(endpoint, LootLockerEndPoints.listEntitlementHistory.httpMethod, onComplete: (serverResponse) => { +#if LOOTLOCKER_USE_NEWTONSOFTJSON + LootLockerResponse.Deserialize(onComplete, serverResponse); +#else + LootLockerResponse.Deserialize(onComplete, serverResponse, new JsonOptions(JsonSerializationOptions.UseXmlIgnore) ); +#endif + }); } /// @@ -6199,7 +6205,7 @@ public static void GetEntitlement(string entitlementId, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } - #endregion +#endregion #region Metadata /// diff --git a/Runtime/Game/Platforms/PlatformManager.cs b/Runtime/Game/Platforms/PlatformManager.cs index 6bcfcff94..e296c0cb3 100644 --- a/Runtime/Game/Platforms/PlatformManager.cs +++ b/Runtime/Game/Platforms/PlatformManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using UnityEngine; #if UNITY_EDITOR using UnityEditor; @@ -25,6 +26,12 @@ public enum Platforms ,Remote } + public class LootLockerPlatformSettings + { + public static List PlatformsWithRefreshTokens = new List { Platforms.AppleGameCenter, Platforms.AppleSignIn, Platforms.Epic, Platforms.Google, Platforms.Remote }; + public static List PlatformsWithStoredAuthData = new List { Platforms.Guest, Platforms.WhiteLabel, Platforms.AmazonLuna, Platforms.PlayStationNetwork, Platforms.XboxOne }; + } + public class CurrentPlatform { static CurrentPlatform() diff --git a/Runtime/Game/Requests/DropTableRequest.cs b/Runtime/Game/Requests/DropTableRequest.cs index 1c368eb4f..45f30329d 100644 --- a/Runtime/Game/Requests/DropTableRequest.cs +++ b/Runtime/Game/Requests/DropTableRequest.cs @@ -52,7 +52,7 @@ public static void ComputeAndLockDropTable(int tableInstanceId, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: true, callerRole: LootLocker.LootLockerEnums.LootLockerCallerRole.User); + LootLockerServerRequest.CallAPI(endPoint, requestEndPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: true); } public static void PickDropsFromDropTable(PickDropsFromDropTableRequest data, int tableInstanceId, Action onComplete) @@ -68,7 +68,7 @@ public static void PickDropsFromDropTable(PickDropsFromDropTableRequest data, in string endPoint = string.Format(requestEndPoint.endPoint, tableInstanceId); - LootLockerServerRequest.CallAPI(endPoint, requestEndPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: true, callerRole: LootLocker.LootLockerEnums.LootLockerCallerRole.User); + LootLockerServerRequest.CallAPI(endPoint, requestEndPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: true); } } } \ No newline at end of file diff --git a/Runtime/Game/Requests/WhiteLabelRequest.cs b/Runtime/Game/Requests/WhiteLabelRequest.cs old mode 100755 new mode 100644 index 99346d604..31b56f2bd --- a/Runtime/Game/Requests/WhiteLabelRequest.cs +++ b/Runtime/Game/Requests/WhiteLabelRequest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using LootLocker.Requests; namespace LootLocker.Requests @@ -82,10 +83,20 @@ public static void WhiteLabelLogin(LootLockerWhiteLabelUserRequest input, Action onComplete?.Invoke(LootLockerResponseFactory.InputUnserializableError()); return; } - + + if (LootLockerConfig.current.domainKey.ToString().Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); +#endif + onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); + + return; + } + string json = LootLockerJson.SerializeObject(input); - LootLockerServerRequest.CallDomainAuthAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + LootLockerServerRequest.CallAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders(), useAuthToken: false); } public static void WhiteLabelVerifySession(LootLockerWhiteLabelVerifySessionRequest input, Action onComplete) @@ -98,9 +109,19 @@ public static void WhiteLabelVerifySession(LootLockerWhiteLabelVerifySessionRequ return; } + if (LootLockerConfig.current.domainKey.ToString().Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); +#endif + onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); + + return; + } + string json = LootLockerJson.SerializeObject(input); - LootLockerServerRequest.CallDomainAuthAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + LootLockerServerRequest.CallAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders(), useAuthToken: false); } public static void WhiteLabelSignUp(LootLockerWhiteLabelUserRequest input, Action onComplete) @@ -113,33 +134,85 @@ public static void WhiteLabelSignUp(LootLockerWhiteLabelUserRequest input, Actio return; } + if (LootLockerConfig.current.domainKey.ToString().Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); +#endif + onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); + + return; + } + string json = LootLockerJson.SerializeObject(input); - LootLockerServerRequest.CallDomainAuthAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + LootLockerServerRequest.CallAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders(), useAuthToken: false); } public static void WhiteLabelRequestPasswordReset(string email, Action onComplete) { EndPointClass endPoint = LootLockerEndPoints.whiteLabelRequestPasswordReset; + if (LootLockerConfig.current.domainKey.ToString().Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); +#endif + onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); + + return; + } + var json = LootLockerJson.SerializeObject(new { email }); - LootLockerServerRequest.CallDomainAuthAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + LootLockerServerRequest.CallAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders(), useAuthToken: false); } public static void WhiteLabelRequestAccountVerification(int userID, Action onComplete) { EndPointClass endPoint = LootLockerEndPoints.whiteLabelRequestAccountVerification; + if (LootLockerConfig.current.domainKey.ToString().Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); +#endif + onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); + + return; + } + var json = LootLockerJson.SerializeObject(new { user_id = userID }); - LootLockerServerRequest.CallDomainAuthAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + LootLockerServerRequest.CallAPI(endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders(), useAuthToken: false); } public static void WhiteLabelRequestAccountVerification(string email, Action onComplete) { EndPointClass endPoint = LootLockerEndPoints.whiteLabelRequestAccountVerification; + if (LootLockerConfig.current.domainKey.ToString().Length == 0) + { +#if UNITY_EDITOR + LootLockerLogger.GetForLogLevel(LootLockerLogger.LogLevel.Error)("LootLocker domain key must be set in settings"); +#endif + onComplete?.Invoke(LootLockerResponseFactory.ClientError("LootLocker domain key must be set in settings")); + + return; + } + var json = LootLockerJson.SerializeObject(new { email = email }); - LootLockerServerRequest.CallDomainAuthAPI(endPoint.endPoint, endPoint.httpMethod, json, onComplete); + LootLockerServerRequest.CallAPI(endPoint.endPoint, endPoint.httpMethod, json, onComplete, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders(), useAuthToken: false); + } + + public static Dictionary GetDomainHeaders() + { + Dictionary headers = new Dictionary(); + headers.Add("domain-key", LootLockerConfig.current.domainKey); + + if (LootLockerConfig.current.apiKey.StartsWith("dev_")) + { + headers.Add("is-development", "true"); + } + return headers; } } } diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs index d45477192..896731122 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using LootLocker; +using LootLocker.LootLockerEnums; using UnityEngine; @@ -69,32 +70,32 @@ private IEnumerator RetrySendAfter(int retryAfter, string endPoint, LootLockerHT public class LootLockerTestConfigurationEndpoints { [Header("LootLocker Admin API Authentication")] - public static EndPointClass LoginEndpoint = new EndPointClass("v1/session", LootLockerHTTPMethod.POST); - public static EndPointClass SignupEndpoint = new EndPointClass("v1/signup", LootLockerHTTPMethod.POST); + public static EndPointClass LoginEndpoint = new EndPointClass("v1/session", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass SignupEndpoint = new EndPointClass("v1/signup", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Game Operations")] - public static EndPointClass CreateGame = new EndPointClass("v1/game", LootLockerHTTPMethod.POST); - public static EndPointClass DeleteGame = new EndPointClass("v1/game/#GAMEID#", LootLockerHTTPMethod.DELETE); + public static EndPointClass CreateGame = new EndPointClass("v1/game", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass DeleteGame = new EndPointClass("v1/game/#GAMEID#", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Key Operations")] - public static EndPointClass CreateKey = new EndPointClass("game/#GAMEID#/api_keys", LootLockerHTTPMethod.POST); + public static EndPointClass CreateKey = new EndPointClass("game/#GAMEID#/api_keys", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Platform Operations")] - public static EndPointClass UpdatePlatform = new EndPointClass("game/#GAMEID#/platforms/{0}", LootLockerHTTPMethod.PUT); + public static EndPointClass UpdatePlatform = new EndPointClass("game/#GAMEID#/platforms/{0}", LootLockerHTTPMethod.PUT, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Leaderboard Operations")] - public static EndPointClass createLeaderboard = new EndPointClass("game/#GAMEID#/leaderboards", LootLockerHTTPMethod.POST); - public static EndPointClass updateLeaderboard = new EndPointClass("game/#GAMEID#/leaderboards/{0}", LootLockerHTTPMethod.PUT); - public static EndPointClass updateLeaderboardSchedule = new EndPointClass("game/#GAMEID#/leaderboard/{0}/schedule", LootLockerHTTPMethod.POST); - public static EndPointClass addLeaderboardReward = new EndPointClass("game/#GAMEID#/leaderboard/{0}/reward", LootLockerHTTPMethod.POST); + public static EndPointClass createLeaderboard = new EndPointClass("game/#GAMEID#/leaderboards", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass updateLeaderboard = new EndPointClass("game/#GAMEID#/leaderboards/{0}", LootLockerHTTPMethod.PUT, LootLockerCallerRole.Admin); + public static EndPointClass updateLeaderboardSchedule = new EndPointClass("game/#GAMEID#/leaderboard/{0}/schedule", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass addLeaderboardReward = new EndPointClass("game/#GAMEID#/leaderboard/{0}/reward", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Asset Operations")] - public static EndPointClass getAssetContexts = new EndPointClass("/v1/game/#GAMEID#/assets/contexts", LootLockerHTTPMethod.GET); - public static EndPointClass createAsset = new EndPointClass("/v1/game/#GAMEID#/asset", LootLockerHTTPMethod.POST); - public static EndPointClass createReward = new EndPointClass("game/#GAMEID#/reward", LootLockerHTTPMethod.POST); + public static EndPointClass getAssetContexts = new EndPointClass("/v1/game/#GAMEID#/assets/contexts", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass createAsset = new EndPointClass("/v1/game/#GAMEID#/asset", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass createReward = new EndPointClass("game/#GAMEID#/reward", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Trigger Operations")] - public static EndPointClass createTrigger = new EndPointClass("game/#GAMEID#/triggers/cozy-crusader/v1", LootLockerHTTPMethod.POST); + public static EndPointClass createTrigger = new EndPointClass("game/#GAMEID#/triggers/cozy-crusader/v1", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); } #endregion } diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs index 328d9a65b..737a3adb4 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs @@ -59,6 +59,10 @@ public static void CreateGame(Action 99) + { + gameName = gameName.Substring(0, 99); + } CreateGameRequest createGameRequest = new CreateGameRequest { name = gameName, organisation_id = createdGame.OrganisationId }; diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs b/Tests/LootLockerTests/PlayMode/JsonTests.cs new file mode 100644 index 000000000..5dfd235ce --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs @@ -0,0 +1,473 @@ +using LootLocker; +using LootLocker.Requests; +using System; +using System.Collections; +using System.Collections.Generic; +#if !LOOTLOCKER_USE_NEWTONSOFTJSON +using System.Linq; +using LLlibs.ZeroDepJson; +#else +using Newtonsoft.Json; +#endif +using NUnit.Framework; +using UnityEngine; + +namespace LootLockerTests.PlayMode +{ + public class MultiDimensionalArrayClass + { + public string[][] multiDimensionalArray { get; set; } + } + + public class JsonTests + { + + [Test] + public void Json_DeserializingSimpleJson_Succeeds() + { + // Given + const string validGuestSessionResponse = + "{\n \"success\": true,\n \"session_token\": \"e6fa44946f077dd9fe67311ab3f188c596df9969\",\n \"player_id\": 3,\n \"public_uid\": \"TSEYDXD8\",\n \"player_identifier\": \"uuid-11223344\",\n \"player_created_at\": \"2022-05-30T07:56:01+00:00\",\n \"check_grant_notifications\": true,\n \"check_deactivation_notifications\": false,\n \"seen_before\": true\n}"; + + // When + LootLockerSessionRequest deserializedSessionRequest = + LootLockerJson.DeserializeObject(validGuestSessionResponse); + + // Then + Assert.NotNull(deserializedSessionRequest, "Not deserialized, is null"); + Assert.NotNull(deserializedSessionRequest.player_identifier, + "Not deserialized, does not contain player_identifier property"); + Assert.AreEqual(deserializedSessionRequest.player_identifier, "uuid-11223344", + "Not deserialized, does not contain player_identifier value"); + } + + [Test] + public void Json_DeserializingComplexArrayJson_Succeeds() + { + // Given + const string complexJson = + "{\n\"success\": true,\n\"loadouts\": [\n{\n\"character\": {\n\"id\": 3015691,\n\"type\": \"Wizard\",\n\"name\": \"Bb32\",\n\"is_default\": true\n},\n\"loadout\": []\n}\n]\n}"; + + // When + LootLockerClassLoadoutResponse deserializedCharacterLoadoutResponse = + LootLockerJson.DeserializeObject(complexJson); + + // Then + Assert.NotNull(deserializedCharacterLoadoutResponse, "Not deserialized, is null"); + Assert.NotNull(deserializedCharacterLoadoutResponse.GetClassess(), + "Not deserialized, does not contain characters"); + Assert.IsNotEmpty(deserializedCharacterLoadoutResponse.GetClassess(), + "Not deserialized, does not contain characters"); + Assert.AreEqual(deserializedCharacterLoadoutResponse.GetClass("Bb32").type, "Wizard", + "Not deserialized, does not contain the correct character"); + } + + [Test] + public void Json_DeserializingMultiDimensionalArray_Succeeds() + { + // Given + string multiDimJson = "{\"multi_dimensional_array\":[[\"1-1\",\"1-2\",\"1-3\",\"1-4\"],[\"2-1\",\"2-2\",\"2-3\"],[\"3-1\",\"3-2\"]]}"; + + // When + MultiDimensionalArrayClass deserializedMultiDimensionalArray = + LootLockerJson.DeserializeObject(multiDimJson); + + // Then + Assert.NotNull(deserializedMultiDimensionalArray, "Not deserialized, is null"); + Assert.NotNull(deserializedMultiDimensionalArray.multiDimensionalArray, + "Not deserialized, does not contain multi dimensional array"); + Assert.IsNotEmpty(deserializedMultiDimensionalArray.multiDimensionalArray, + "Not deserialized, does not contain multi dimensional array"); + Assert.AreEqual("2-2", deserializedMultiDimensionalArray.multiDimensionalArray[1][1], + "Not deserialized, does not contain the correct value"); + } + + + [Test] + public void Json_SerializingMultidimensionalArray_Succeeds() + { + // Given + MultiDimensionalArrayClass mdArray = new MultiDimensionalArrayClass(); + mdArray.multiDimensionalArray = new[] + { new[] { "1-1", "1-2", "1-3", "1-4" }, new[] { "2-1", "2-2", "2-3" }, new[] { "3-1", "3-2" } }; + + // When + string serializedJson = LootLockerJson.SerializeObject(mdArray); + + // Then + Assert.NotNull(serializedJson, "Not serialized, is null"); + Assert.AreNotEqual("{}", serializedJson, "Not serialized, empty"); + Assert.IsTrue(serializedJson.Contains("multi_dimensional_array"), + "Not Serialized, does not contain multiDimensionalArray property"); + Assert.IsTrue(serializedJson.Contains("3-1"), "Not Serialized, does not contain 3-1 value"); + } + + [Test] + public void Json_SerializingSimpleJson_Succeeds() + { + // Given + LootLockerSessionRequest SessionRequest = new LootLockerSessionRequest("uuid-11223344"); + + // When + string serializedJson = LootLockerJson.SerializeObject(SessionRequest); + + // Then + Assert.NotNull(serializedJson, "Not serialized, is null"); + Assert.AreNotEqual("{}", serializedJson, "Not serialized, empty"); + Assert.IsTrue(serializedJson.Contains("player_identifier"), + "Not Serialized, does not contain player_identifier property"); + Assert.IsTrue(serializedJson.Contains("uuid-11223344"), + "Not Serialized, does not contain player_identifier value"); + } + + public class ConditionalSerialization + { + +#if LOOTLOCKER_USE_NEWTONSOFTJSON + [JsonProperty("Prop1")] +#else + [Json(Name = "Prop1")] +#endif + public string AttributeRenamedProperty { get; set; } = "Hello"; +#if LOOTLOCKER_USE_NEWTONSOFTJSON + [JsonIgnore] +#else + [Json(IgnoreWhenSerializing = true, IgnoreWhenDeserializing = true)] +#endif + public string IgnoredProperty { get; set; } = "ignored"; + public bool Completed { get; set; } = false; + public int NormalProperty { get; set; } = 1234; + + public bool ShouldSerializeCompleted() + { + // don't serialize the Completed property if it is not set. + return Completed; + } + } + + [Test] + public void Json_ConditionalSerializationConfigured_OnlyConfiguredFieldsAreSerialized() + { + // Given + ConditionalSerialization conditionalSerialization = new ConditionalSerialization(); + + // When + string serializedJson = LootLockerJson.SerializeObject(conditionalSerialization); + Debug.Log(serializedJson); + // Then + Assert.NotNull(serializedJson, "Not serialized, is null"); + Assert.AreNotEqual("{}", serializedJson, "Not serialized, empty"); + Assert.IsFalse(serializedJson.Contains("ignored_property"), + "Not Serialized correctly, contains IgnoredProperty property"); + Assert.IsFalse(serializedJson.Contains("ignored"), + "Not Serialized correctly, contains ignored value"); + Assert.IsTrue(serializedJson.Contains("prop_1") || serializedJson.Contains("Prop1") /*For some reason Newtonsoft fails to snake case this one*/, + "Not Serialized correctly, does not contain Prop1 property"); + Assert.IsTrue(serializedJson.Contains("Hello"), + "Not Serialized correctly, does not contain Prop1 value"); + Assert.IsFalse(serializedJson.Contains("attribute_renamed_property"), + "Not Serialized correctly, contains AttributeRenamedProperty property"); + Assert.IsFalse(serializedJson.Contains("completed"), + "Not Serialized correctly, contains Completed property"); + Assert.IsFalse(serializedJson.Contains("false"), + "Not Serialized correctly, contains Completed value"); + Assert.IsTrue(serializedJson.Contains("normal_property"), + "Not Serialized correctly, does not contain NormalProperty property"); + Assert.IsTrue(serializedJson.Contains("1234"), + "Not Serialized correctly, does not contain NormalProperty value"); + + // Then Given + conditionalSerialization.Completed = true; + + // When + serializedJson = LootLockerJson.SerializeObject(conditionalSerialization); + + // Then + Assert.NotNull(serializedJson, "Not serialized, is null"); + Assert.AreNotEqual("{}", serializedJson, "Not serialized, empty"); + Assert.IsFalse(serializedJson.Contains("ignored_property"), + "Not Serialized correctly, contains IgnoredProperty property"); + Assert.IsFalse(serializedJson.Contains("ignored"), + "Not Serialized correctly, contains ignored value"); + Assert.IsTrue(serializedJson.Contains("prop_1") || serializedJson.Contains("Prop1") /*For some reason Newtonsoft fails to snake case this one*/, + "Not Serialized correctly, does not contain prop_1 property"); + Assert.IsTrue(serializedJson.Contains("Hello"), + "Not Serialized correctly, does not contain prop_1 value"); + Assert.IsFalse(serializedJson.Contains("attribute_renamed_property"), + "Not Serialized correctly, contains AttributeRenamedProperty property"); + Assert.IsTrue(serializedJson.Contains("completed"), + "Not Serialized correctly, does not contain Completed property"); + Assert.IsTrue(serializedJson.Contains("true"), + "Not Serialized correctly, does not contain Completed value"); + Assert.IsTrue(serializedJson.Contains("normal_property"), + "Not Serialized correctly, does not contain NormalProperty property"); + Assert.IsTrue(serializedJson.Contains("1234"), + "Not Serialized correctly, does not contain NormalProperty value"); + } + + public class CaseVariationClass + { + public string normalCamelCase { get; set; } = "n/a"; + public string PascalCase { get; set; } = "n/a"; + public string SCREAMINGCASE { get; set; } = "n/a"; + public string Upper_Snake_Case { get; set; } = "n/a"; + public string lower_snake_case { get; set; } = "n/a"; + public string number79CamelCase { get; set; } = "n/a"; + public string CamelCase69 { get; set; } = "n/a"; + public string MultiLetterISAok { get; set; } = "n/a"; + }; + + [Test] + public void Json_SerializationCaseConversion_CaseIsConvertedToSnake() + { + // Given + CaseVariationClass caseVariationClass = new CaseVariationClass(); + + // When + string serializedJson = LootLockerJson.SerializeObject(caseVariationClass); + + // Then + Assert.IsTrue(serializedJson.Contains("normal_camel_case"), "Field normalCamelCase was not serialized correctly, json: " + serializedJson); + Assert.IsTrue(serializedJson.Contains("pascal_case"), "Field PascalCase was not serialized correctly, json: " + serializedJson); + Assert.IsTrue(serializedJson.Contains("screamingcase"), "Field SCREAMINGCASE was not serialized correctly, json: " + serializedJson); + Assert.IsTrue(serializedJson.Contains("upper_snake_case"), "Field Upper_Snake_Case was not serialized correctly, json: " + serializedJson); + Assert.IsTrue(serializedJson.Contains("lower_snake_case"), "Field lower_snake_case was not serialized correctly, json: " + serializedJson); +#if !LOOTLOCKER_USE_NEWTONSOFTJSON + // I don't agree with how newtonsoft serializes these fields + Assert.IsTrue(serializedJson.Contains("number_79_camel_case"), "Field number79CamelCase was not serialized correctly, json: " + serializedJson); + Assert.IsTrue(serializedJson.Contains("camel_case_69"), "Field CamelCase69 was not serialized correctly, json: " + serializedJson); + Assert.IsTrue(serializedJson.Contains("multi_letter_isa_ok"), "Field MultiLetterISAok was not serialized correctly, json: " + serializedJson); +#endif + } + + [Test] + public void Json_DeserializationCaseConversion_CaseIsConvertedToSnake() + { + // Given + string caseVariationClassJson = + "{\"normal_camel_case\":\"1234\",\"pascal_case\":\"1234\",\"screamingcase\":\"1234\",\"upper_snake_case\":\"1234\",\"lower_snake_case\":\"1234\",\"number_79_camel_case\":\"1234\",\"camel_case_69\":\"1234\",\"multi_letter_isa_ok\":\"1234\"}"; + + // When + CaseVariationClass caseVariationClass = + LootLockerJson.DeserializeObject(caseVariationClassJson); + + // Then + Assert.AreEqual("1234", caseVariationClass.normalCamelCase, "Field: " + nameof(caseVariationClass.normalCamelCase)); + Assert.AreEqual("1234", caseVariationClass.PascalCase, "Field: " + nameof(caseVariationClass.PascalCase)); + Assert.AreEqual("1234", caseVariationClass.SCREAMINGCASE, "Field: " + nameof(caseVariationClass.SCREAMINGCASE)); + Assert.AreEqual("1234", caseVariationClass.Upper_Snake_Case, "Field: " + nameof(caseVariationClass.Upper_Snake_Case)); + Assert.AreEqual("1234", caseVariationClass.lower_snake_case, "Field: " + nameof(caseVariationClass.lower_snake_case)); +#if !LOOTLOCKER_USE_NEWTONSOFTJSON + // I don't agree with how newtonsoft deserializes these fields + Assert.AreEqual("1234", caseVariationClass.number79CamelCase, "Field: " + nameof(caseVariationClass.number79CamelCase)); + Assert.AreEqual("1234", caseVariationClass.CamelCase69, "Field: " + nameof(caseVariationClass.CamelCase69)); + Assert.AreEqual("1234", caseVariationClass.MultiLetterISAok, "Field: " + nameof(caseVariationClass.MultiLetterISAok)); +#endif + } + +#if !LOOTLOCKER_USE_NEWTONSOFTJSON + [Test] + public void Json_SimpleTypeSerialization_Succeeds() + { + Assert.AreEqual("true", Json.Serialize(true)); + Assert.AreEqual("false", Json.Serialize(false)); + Assert.AreEqual("12345678", Json.Serialize(12345678)); + Assert.AreEqual("12345678901234567890", Json.Serialize(12345678901234567890)); + Assert.AreEqual("1234567890123456789.0123456789", Json.Serialize(1234567890123456789.01234567890m)); + Assert.AreEqual("12345678", Json.Serialize((uint)12345678)); + Assert.AreEqual("128", Json.Serialize((byte)128)); + Assert.AreEqual("-56", Json.Serialize((sbyte)-56)); + Assert.AreEqual("-56", Json.Serialize((short)-56)); + Assert.AreEqual("12345", Json.Serialize((ushort)12345)); + Assert.AreEqual("\"héllo world\"", Json.Serialize("héllo world")); + var ts = new TimeSpan(12, 34, 56, 7, 8); + Assert.AreEqual("11625670080000", Json.Serialize(ts)); + Assert.AreEqual("\"13:10:56:07.008\"", Json.Serialize(ts, new JsonOptions { SerializationOptions = JsonSerializationOptions.TimeSpanAsText })); + var guid = Guid.NewGuid(); + Assert.AreEqual("\"" + guid + "\"", Json.Serialize(guid)); + Assert.AreEqual("\"https://github.com/smourier/ZeroDepJson\"", Json.Serialize(new Uri("https://github.com/smourier/ZeroDepJson"))); + Assert.AreEqual("2", Json.Serialize(UriKind.Relative)); + Assert.AreEqual("\"Relative\"", Json.Serialize(UriKind.Relative, new JsonOptions { SerializationOptions = JsonSerializationOptions.EnumAsText })); + Assert.AreEqual("\"x\"", Json.Serialize('x')); + Assert.AreEqual("1234.56775", Json.Serialize(1234.56775f)); + Assert.AreEqual("1234.5678", Json.Serialize(1234.5678d)); + } + + [Test] + public void Json_ListSerializationDeserialization_BackAndForthPreservesData() + { + var list = new List(); + for (var i = 0; i < 10; i++) + { + var customer = new Customer(); + customer.Index = i; + list.Add(customer); + } + + var json = Json.Serialize(list); + var list2 = Json.Deserialize>(json); + var json2 = Json.Serialize(list2); + Assert.AreEqual(json, json2); + } + + [Test] + public void Json_DictionarySerializationAndDeserialization_Succeeds() + { + var dic = new Dictionary(); + for (var i = 0; i < 10; i++) + { + var customer = new Customer(); + customer.Index = i; + customer.Name = "This is a name 这是一个名字" + Environment.TickCount; + var address1 = new Address(); + address1.ZipCode = 75000; + address1.City = new City(); + address1.City.Name = "Paris"; + address1.City.Country = new Country(); + address1.City.Country.Name = "France"; + + var address2 = new Address(); + address2.ZipCode = 10001; + address2.City = new City(); + address2.City.Name = "New York"; + address2.City.Country = new Country(); + address2.City.Country.Name = "USA"; + + customer.Addresses = new[] { address1, address2 }; + + dic[customer.Id] = customer; + } + + var json1 = Json.Serialize(dic); + var list2 = (Dictionary)Json.Deserialize(json1); + var json2 = Json.Serialize(list2); + Assert.AreEqual(json1, json2); + + var customers = list2.Values.Cast>().ToList(); + var json3 = Json.Serialize(customers); + var list3 = Json.Deserialize>(json3); + var json4 = Json.Serialize(list3); + Assert.AreEqual(json3, json4); + } + + [Test] + public void Json_CyclicJsonSerialization_ThrowsCyclicJsonException() + { + var person = new Person { Name = "foo" }; + var persons = new Person[] { person, person }; + try + { + var json = Json.Serialize(persons); + Assert.Fail(); + } + catch (JsonException ex) + { + Assert.IsTrue(ex.Code == 9); + } + } + + [Test] + public void Json_CyclicJsonSerializationWithCustomOptions_Succeeds() + { + var person = new Person { Name = "héllo" }; + var persons = new Person[] { person, person }; + var options = new CustomOptions(); + var json = Json.Serialize(persons, options); + Assert.IsTrue(json == "[{\"name\":\"héllo\"},{\"name\":\"héllo\"}]"); + } + } + + class CustomOptions : JsonOptions + { + public CustomOptions() + { + ObjectGraph = new CustomObjectGraph(); + } + + private class CustomObjectGraph : IDictionary, Json.IOptionsHolder + { + private readonly Dictionary _hash = new Dictionary(); + + public JsonOptions Options { get; set; } + + public void Add(object key, object value) + { + _hash[key] = Options.SerializationLevel; + } + + public bool ContainsKey(object key) + { + if (!_hash.TryGetValue(key, out var level)) + return false; + + if (Options.SerializationLevel == level) + return false; + + return true; + } + + public object this[object key] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public ICollection Keys => throw new NotImplementedException(); + public ICollection Values => throw new NotImplementedException(); + public int Count => throw new NotImplementedException(); + public bool IsReadOnly => throw new NotImplementedException(); + public void Add(KeyValuePair item) => throw new NotImplementedException(); + public void Clear() => throw new NotImplementedException(); + public bool Contains(KeyValuePair item) => throw new NotImplementedException(); + public void CopyTo(KeyValuePair[] array, int arrayIndex) => throw new NotImplementedException(); + public IEnumerator> GetEnumerator() => throw new NotImplementedException(); + public bool Remove(object key) => throw new NotImplementedException(); + public bool Remove(KeyValuePair item) => throw new NotImplementedException(); + public bool TryGetValue(object key, out object value) => throw new NotImplementedException(); + IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException(); + } + } + + class Person + { + public string Name { get; set; } + } + + public class Customer + { + public Customer() + { + Id = Guid.NewGuid(); + + } + + public Guid Id { get; } + public int Index { get; set; } + public string Name { get; set; } + + public Address[] Addresses { get; set; } + + public override string ToString() => Name; + } + + public class Address + { + public City City { get; set; } + public int ZipCode { get; set; } + + public override string ToString() => ZipCode.ToString(); + } + + public class City + { + public string Name { get; set; } + public Country Country { get; set; } + + public override string ToString() => Name; + } + + public class Country + { + public string Name { get; set; } + + public override string ToString() => Name; + } +#else + } +#endif +} diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs.meta b/Tests/LootLockerTests/PlayMode/JsonTests.cs.meta new file mode 100644 index 000000000..140dac714 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e4f38d89b93ca4166b76bc0f572cbe9e \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs b/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs index 11e75b4f0..505f3e144 100644 --- a/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs +++ b/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs @@ -92,6 +92,14 @@ public IEnumerator Setup() Assert.IsTrue(leaderboardSuccess, "Failed to create leaderboard"); + // Sign in client + bool guestLoginCompleted = false; + LootLockerSDKManager.StartGuestSession(GUID.Generate().ToString(), response => + { + SetupFailed |= !response.success; + guestLoginCompleted = true; + }); + yield return new WaitUntil(() => guestLoginCompleted); } [UnityTearDown] diff --git a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs new file mode 100644 index 000000000..7294e0a22 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs @@ -0,0 +1,136 @@ + +using LootLocker; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using System; +using System.Collections; +using System.IO; +using System.Net; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class PlayerFilesTest + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + + [UnitySetUp] + public IEnumerator Setup() + { + TestCounter++; + configCopy = LootLockerConfig.current; + + if (!LootLockerConfig.ClearSettings()) + { + Debug.LogError("Could not clear LootLocker config"); + } + + // Create game + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: "GuestSessionTest" + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) + { + gameCreationCallCompleted = true; + Debug.LogError(errorMessage); + SetupFailed = true; + } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if (SetupFailed) + { + yield break; + } + gameUnderTest?.SwitchToStageEnvironment(); + + // Enable guest platform + bool enableGuestLoginCallCompleted = false; + gameUnderTest?.EnableGuestLogin((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + enableGuestLoginCallCompleted = true; + }); + yield return new WaitUntil(() => enableGuestLoginCallCompleted); + if (SetupFailed) + { + yield break; + } + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker"); + + // Sign in client + bool guestLoginCompleted = false; + LootLockerSDKManager.StartGuestSession(Guid.NewGuid().ToString(), response => + { + SetupFailed |= !response.success; + guestLoginCompleted = true; + }); + yield return new WaitUntil(() => guestLoginCompleted); + + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (gameUnderTest != null) + { + bool gameDeletionCallCompleted = false; + gameUnderTest.DeleteGame(((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + } + + gameUnderTest = null; + gameDeletionCallCompleted = true; + })); + yield return new WaitUntil(() => gameDeletionCallCompleted); + } + + LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, + configCopy.currentDebugLevel, configCopy.allowTokenRefresh); + } + + + [UnityTest] + public IEnumerator PlayerFiles_UploadSimplePublicFile_Succeeds() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string path = Application.temporaryCachePath + "/PlayerFileCanBeCreatedWithPathUpdatedAndThenDeleted-creation.txt"; + string content = "First added line"; + TextWriter writer = new StreamWriter(path); + writer.WriteLine(content); + writer.Close(); + + // When + LootLockerPlayerFile actualResponse = new LootLockerPlayerFile(); + bool setToPublic = true; + bool playerFileUploadCompleted = false; + LootLockerSDKManager.UploadPlayerFile(path, "test", setToPublic, fileResponse => + { + actualResponse = fileResponse; + playerFileUploadCompleted = true; + }); + + // Wait for response + yield return new WaitUntil(() => playerFileUploadCompleted); + + // Then + Assert.IsTrue(actualResponse.success, "File upload failed"); + Assert.Greater(actualResponse.size, 0, "File Size was 0"); + Assert.AreEqual(setToPublic, actualResponse.is_public, "File does not have the same public setting"); + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs.meta b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs.meta new file mode 100644 index 000000000..f8091306e --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9b4c343d9c2d4332abb89e3942ae0d0 \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs b/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs new file mode 100644 index 000000000..d1aa3eb1d --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs @@ -0,0 +1,548 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using LootLocker; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class RateLimiterTests + { + + class TestRateLimiter : RateLimiter + { + public TestRateLimiter() : base() + { + EnableRateLimiter = true; + } + private DateTime _currentTime; + protected override DateTime GetTimeNow() + { + return _currentTime; + } + + public void SetTime(DateTime newTime) + { + _currentTime = newTime; + } + + public DateTime GetCurrentTime() + { + return _currentTime; + } + + public void AddSecondsToCurrentTime(int seconds) + { + _currentTime = _currentTime.AddSeconds(seconds); + } + + public int GetRateLimitSecondsTimeFrame() { return TripWireTimeFrameSeconds; } + public int GetCountMovingAverageAcrossNTimeFrames() { return CountMovingAverageAcrossNTripWireTimeFrames; } + public int GetRateLimitMaxRequestsPerTimeFrame() { return MaxRequestsPerTripWireTimeFrame; } + public int GetRateLimitSecondsBucketSize() { return SecondsPerBucket; } + public int GetBucketsPerTimeFrame() { return BucketsPerTimeFrame; } + public int GetRateLimitMovingAverageBucketCount() { return RateLimitMovingAverageBucketCount; } + + public override bool AddRequestAndCheckIfRateLimitHit() + { + bool wasRateLimited = isRateLimited; + bool didHitRateLimit = base.AddRequestAndCheckIfRateLimitHit(); + + // Debugging + if (!wasRateLimited && didHitRateLimit) + { + DrawDebugGraph(); + } + return didHitRateLimit; + } + + // Visualize the buckets + public void DrawDebugGraph() + { + char[][] bucketsCharMatrix = GetBucketsAsCharMatrix(); + + List graphStrings = new List(); + string firstAndLastRow = ""; + for (int widthIndex = 0; widthIndex < bucketsCharMatrix.Length + 4; widthIndex++) + { + firstAndLastRow += '@'; + } + + + int startOfTimeFrameIndex = (lastBucket + 1 - BucketsPerTimeFrame) < 0 + ? buckets.Length + (lastBucket + 1 - BucketsPerTimeFrame) + : lastBucket - BucketsPerTimeFrame; + int endOfTimeFrameIndex = lastBucket; + graphStrings.Add(firstAndLastRow); + for (int heightIndex = 0; heightIndex < bucketsCharMatrix[0].Length; heightIndex++) + { + string row = ""; + row += '@'; + for (int widthIndex = 0; widthIndex < bucketsCharMatrix.Length; widthIndex++) + { + if (widthIndex == startOfTimeFrameIndex) + row += '|'; + row += bucketsCharMatrix[widthIndex][heightIndex]; + if (widthIndex == endOfTimeFrameIndex) + row += '|'; + } + row += '@'; + + graphStrings.Add(row); + } + + graphStrings.Add(firstAndLastRow); + Debug.Log("### Rate Limiting graph ###"); + foreach (string s in graphStrings) + { + Debug.Log(s); + } + } + + private char[][] GetBucketsAsCharMatrix() + { + char[][] visualized = new char[RateLimitMovingAverageBucketCount][]; + int maxVal = GetMaxRequestsInSingleBucket(); + + for (var i = 0; i < visualized.Length; i++) + { + visualized[i] = new char[maxVal]; + } + + for (var i = 0; i < visualized.Length; i++) + { + int barHeight = buckets[i]; + for (var heightIndex = visualized[i].Length - 1; heightIndex >= 0; heightIndex--) + { + if (visualized[i].Length - heightIndex <= barHeight) + { + visualized[i][heightIndex] = '#'; + } + else + { + visualized[i][heightIndex] = '-'; + } + } + } + return visualized; + } + } + + private TestRateLimiter _rateLimiterUnderTest = null; + + [UnitySetUp] + public IEnumerator UnitySetUp() + { + _rateLimiterUnderTest = new TestRateLimiter(); + _rateLimiterUnderTest.SetTime(new DateTime(2021, 1, 1, 0, 0, 0)); + yield return null; + } + + [UnityTearDown] + public IEnumerator UnityTearDown() + { + // Cleanup + _rateLimiterUnderTest = null; + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_NormalAmountOfAverageRequests_DoesNotHitRateLimit() + { + // Given + int secondsToRunTest = 360; + int requestsPerSecond = 3; + bool wasRateLimitHit = false; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + // When + for (int i = 0; i < secondsToRunTest; i++) + { + for (int j = 0; j < requestsPerSecond; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsFalse(wasRateLimitHit, "Rate limit was hit when it should not have been"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_UndulatingLowLevelOfRequests_DoesNotHitRateLimit() + { + // Given + int secondsToRunTest = 360; + int undulatingModuloMax = 6; // 1 + 2 + 3 + 4 + 5 + 6 gives an average of 3.5 which is less than the 18 per bucket that triggers moving average rate limit + bool wasRateLimitHit = false; + + // When + for (int i = 0; i < secondsToRunTest; i++) + { + int requestsThisSecond = (i % undulatingModuloMax) + 1; + for (int j = 0; j < requestsThisSecond; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + + if (wasRateLimitHit) + { + break; + } + } + + // Then + Assert.IsFalse(wasRateLimitHit, "Rate limit was hit when it should not have been"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_FrequentSmallBursts_DoesNotHitRateLimit() + { + // Given + int secondsToRunTest = 360; + int requestPerBurst = 9; + int sendRequestsEveryXSeconds = 3; + bool wasRateLimitHit = false; + int rateLimitHitAfterSeconds = -1; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + // When + for (int i = 0; i < secondsToRunTest; i++) + { + if (i % sendRequestsEveryXSeconds == 0) + { + for (int j = 0; j < requestPerBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + } + + if (wasRateLimitHit) + { + rateLimitHitAfterSeconds = i; + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsTrue(rateLimitHitAfterSeconds < 0, "Rate limit was hit after " + rateLimitHitAfterSeconds + " seconds, expected it not to be hit"); + Assert.IsFalse(wasRateLimitHit, "Rate limit was hit when it should not have been"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_InfrequentLargeBursts_DoesNotHitRateLimit() + { + // Given + int secondsToRunTest = 360; + int sendBurstsEveryXSeconds = 10; + int requestsPerBurst = 35; + bool wasRateLimitHit = false; + int rateLimitHitAfterSeconds = -1; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + // When + for (int i = 0; i < secondsToRunTest; i++) + { + if (i % sendBurstsEveryXSeconds == 0) + { + for (int j = 0; j < requestsPerBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + if (wasRateLimitHit) + { + break; + } + } + } + + if (wasRateLimitHit) + { + rateLimitHitAfterSeconds = i; + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsFalse(wasRateLimitHit, "Rate limit was hit when it should not have been"); + Assert.IsTrue(rateLimitHitAfterSeconds < 0, "Rate limit was hit after " + rateLimitHitAfterSeconds + " seconds, expected it not to be hit"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_ExcessiveQuickSuccessionRequests_HitsTripwireRateLimit() + { + // Given + int maxSecondsToRunTest = 90; + int requestsPerSecond = 6; + bool wasRateLimitHit = false; + int rateLimitHitAfterSeconds = -1; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + // When + for (int i = 0; i < maxSecondsToRunTest; i++) + { + for (int j = 0; j < requestsPerSecond; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + + if (wasRateLimitHit) + { + rateLimitHitAfterSeconds = i; + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsTrue(wasRateLimitHit, "Rate limit wasn't hit within the time limit"); + Assert.IsTrue(rateLimitHitAfterSeconds < 56, "Rate limit was hit after " + rateLimitHitAfterSeconds + " seconds, expected less than 56"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_LowLevelBackgroundRequestsWithIntermittentBursts_HitsRateLimit() + { + // Given + int maxSecondsToRunTest = 360; + int requestsPerSecond = 2; + int requestsPerBurst = 110; + int sendBurstsEveryXSeconds = 29; + bool wasRateLimitHit = false; + + // When + for (int i = 0; i < maxSecondsToRunTest; i++) + { + for (int j = 0; j < requestsPerSecond; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + + if (i % sendBurstsEveryXSeconds == 0) + { + for (int j = 0; j < requestsPerBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + if (wasRateLimitHit) + { + break; + } + } + } + + if (wasRateLimitHit) + { + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsTrue(wasRateLimitHit, "Rate limit wasn't hit within the time limit"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_SuddenHugeBurstBelowLimit_DoesNotTriggerRateLimit() + { + // Given + int maxSecondsToRunTest = 90; + int requestsPerBurst = 275; + int sendBurstsEveryXSeconds = 80; + bool wasRateLimitHit = false; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + // When + for (int i = 1; i < maxSecondsToRunTest; i++) + { + if (i % sendBurstsEveryXSeconds == 0) + { + for (int j = 0; j < requestsPerBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + if (wasRateLimitHit) + { + break; + } + } + } + + if (wasRateLimitHit) + { + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsFalse(wasRateLimitHit, "Rate limit was hit when it should not have been"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_SuddenHugeBurstAbove_LimitTriggersRateLimit() + { + // Given + int maxSecondsToRunTest = 90; + int requestsPerBurst = 300; + int sendBurstsEveryXSeconds = 80; + bool wasRateLimitHit = false; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + // When + for (int i = 1; i < maxSecondsToRunTest; i++) + { + if (i % sendBurstsEveryXSeconds == 0) + { + for (int j = 0; j < requestsPerBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + if (wasRateLimitHit) + { + break; + } + } + } + + if (wasRateLimitHit) + { + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsTrue(wasRateLimitHit, "Rate limit wasn't hit within the time limit"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_SuddenHugeBurstBelowLimitFollowedByAFewRequests_TriggersRateLimit() + { + // Given + int maxSecondsToRunTest = 120; + int requestsPerBurst = 260; + int requestsPerSecondAfterBurst = 2; + int sendBurstsEveryXSeconds = 80; + bool wasRateLimitHit = false; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + // When + for (int i = 1; i < maxSecondsToRunTest; i++) + { + if (i % sendBurstsEveryXSeconds == 0) + { + for (int j = 0; j < requestsPerBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + if (wasRateLimitHit) + { + break; + } + } + } + + if (i > sendBurstsEveryXSeconds) + { + for (int j = 0; j < requestsPerSecondAfterBurst; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + if (wasRateLimitHit) + { + break; + } + } + } + + if (wasRateLimitHit) + { + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsTrue(wasRateLimitHit, "Rate limit wasn't hit within the time limit"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_ConstantRequestsBelowTripWire_HitsMovingAverageRateLimit() + { + // Given + int maxSecondsToRunTest = 360; + int requestsPerSecond = 4; + bool wasRateLimitHit = false; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + + // When + for (int i = 0; i < maxSecondsToRunTest; i++) + { + for (int j = 0; j < requestsPerSecond; j++) + { + wasRateLimitHit |= _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsTrue(wasRateLimitHit, "Rate limit wasn't hit within the time limit"); + yield return null; + } + + [UnityTest] + public IEnumerator RateLimiter_RateLimiterHit_ResetsAfter3Minutes() + { + // Given + int maxSecondsToRunTest = 480; + int expectedMaxSecondsToReset = 180; + int expectedMinSecondsToReset = 120; + int actualSecondsToReset = 0; + int requestsPerSecond = 20; + bool isRateLimited = false; + + _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + + // When + for (int i = 0; i < maxSecondsToRunTest; i++) + { + bool wasRateLimited = isRateLimited; + for (int j = 0; j < requestsPerSecond; j++) + { + isRateLimited = _rateLimiterUnderTest.AddRequestAndCheckIfRateLimitHit(); + } + + if (isRateLimited) + { + actualSecondsToReset++; + } + + if (wasRateLimited && !isRateLimited) + { + break; + } + _rateLimiterUnderTest.AddSecondsToCurrentTime(1); + } + + // Then + Assert.IsFalse(isRateLimited, "Rate Limit did not reset in the allotted period"); + Assert.IsTrue(actualSecondsToReset < expectedMaxSecondsToReset, "Rate Limiting was not reset in the expected time frame. Reset too slowly."); + Assert.IsTrue(actualSecondsToReset > expectedMinSecondsToReset, "Rate Limiting was not reset in the expected time frame. Reset too quickly."); + yield return null; + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs.meta b/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs.meta new file mode 100644 index 000000000..d004d96dc --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0727fd88598d4a1aa08807f0628efd2 \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs b/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs new file mode 100644 index 000000000..1525d0f58 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs @@ -0,0 +1,181 @@ +using LootLocker; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using System.Collections; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class SessionRefreshTest + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + + [UnitySetUp] + public IEnumerator Setup() + { + TestCounter++; + configCopy = LootLockerConfig.current; + + if (!LootLockerConfig.ClearSettings()) + { + Debug.LogError("Could not clear LootLocker config"); + } + + // Create game + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: "GuestSessionTest" + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) + { + gameCreationCallCompleted = true; + Debug.LogError(errorMessage); + SetupFailed = true; + } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if(gameUnderTest == null) + { + SetupFailed = true; + } + if (SetupFailed) + { + yield break; + } + gameUnderTest.SwitchToStageEnvironment(); + + // Enable Whitelabel platform + bool enableWLLogin = false; + gameUnderTest.EnableWhiteLabelLogin((success, errorMessage) => + { + SetupFailed = !success; + enableWLLogin = true; + }); + yield return new WaitUntil(() => enableWLLogin); + + SetupFailed |= !gameUnderTest.InitializeLootLockerSDK(); + if (SetupFailed) + { + yield break; + } + + string email = GetRandomName() + "@lootlocker.com"; + + bool whiteLabelSignUpCompleted = false; + LootLockerSDKManager.WhiteLabelSignUp(email, "123456789", (response) => + { + SetupFailed |= !response.success; + whiteLabelSignUpCompleted = true; + }); + yield return new WaitUntil(() => whiteLabelSignUpCompleted); + if (SetupFailed) + { + yield break; + } + bool whiteLabelLoginCompleted = false; + LootLockerSDKManager.WhiteLabelLoginAndStartSession(email, "123456789", true, (response) => + { + SetupFailed |= !response.success; + whiteLabelLoginCompleted = true; + }); + yield return new WaitUntil(() => whiteLabelLoginCompleted); + if (SetupFailed) + { + yield break; + } + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (gameUnderTest != null) + { + bool gameDeletionCallCompleted = false; + gameUnderTest.DeleteGame(((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + } + + gameUnderTest = null; + gameDeletionCallCompleted = true; + })); + yield return new WaitUntil(() => gameDeletionCallCompleted); + } + + LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, + configCopy.currentDebugLevel, configCopy.allowTokenRefresh); + } + + public string GetRandomName() + { + return LootLockerTestConfigurationUtilities.GetRandomNoun() + + LootLockerTestConfigurationUtilities.GetRandomVerb(); + } + + [UnityTest] + public IEnumerator RefreshSession_ExpiredWhiteLabelSessionAndAutoRefreshEnabled_SessionIsAutomaticallyRefreshed() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given + const string invalidToken = "ThisIsANonExistentToken"; + LootLockerConfig.current.token = invalidToken; + LootLockerConfig.current.allowTokenRefresh = true; + LootLockerPingResponse actualPingResponse = null; + + // When + bool completed = false; + LootLockerSDKManager.Ping(response => + { + actualPingResponse = response; + completed = true; + }); + + // Wait for response + yield return new WaitUntil(() => completed); + + // Then + Assert.NotNull(actualPingResponse, "Request did not execute correctly"); + Assert.IsTrue(actualPingResponse.success, "Ping failed"); + Assert.AreNotEqual(invalidToken, LootLockerConfig.current.token, "Token was not refreshed"); + } + + [UnityTest] + public IEnumerator RefreshSession_ExpiredWhiteLabelSessionButAutoRefreshDisabled_SessionDoesNotRefresh() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given + const string invalidToken = "ThisIsANonExistentToken"; + LootLockerConfig.current.currentDebugLevel = LootLockerConfig.DebugLevel.AllAsNormal; + LootLockerConfig.current.token = invalidToken; + LootLockerConfig.current.allowTokenRefresh = false; + LootLockerPingResponse actualPingResponse = null; + + // When + bool completed = false; + LootLockerSDKManager.Ping(response => + { + actualPingResponse = response; + completed = true; + }); + + // Wait for response + yield return new WaitUntil(() => completed); + + // Then + Assert.NotNull(actualPingResponse, "Request did not execute correctly"); + Assert.IsFalse(actualPingResponse.success, "Ping failed"); + Assert.AreEqual(invalidToken, LootLockerConfig.current.token, "Token was not refreshed"); + } + + } +} \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs.meta b/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs.meta new file mode 100644 index 000000000..252f8707a --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 17a5460fdc7f64337b8fb4a745e55fe9 \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs index 7ff79e797..44bc81bfc 100644 --- a/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs @@ -47,7 +47,7 @@ public IEnumerator Setup() } gameUnderTest?.SwitchToStageEnvironment(); - // Enable guest platform + // Enable Whitelabel platform bool enableWLLogin = false; gameUnderTest?.EnableWhiteLabelLogin((success, errorMessage) => { diff --git a/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs b/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs index 72910b69b..1b2ff050b 100644 --- a/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs +++ b/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs @@ -175,7 +175,7 @@ public IEnumerator Leaderboard_ListRewards_Succeeds() var leaderboard = gameUnderTest.GetLeaderboardByKey(leaderboardKey); leaderboard.AddLeaderboardReward((response) => { - leaderboardRewardUpdateSuccess = response.success; + leaderboardRewardUpdateSuccess = response != null ? response.success : false; leaderboardRewardUpdateComplete = true; }); diff --git a/package.json b/package.json index 9818cb1ad..0c9bde4f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.lootlocker.lootlockersdk", - "version": "3.6.1", + "version": "4.0.0", "displayName": "LootLocker", "description": "LootLocker is a game backend-as-a-service with plug and play tools to upgrade your game and give your players the best experience possible. Designed for teams of all shapes and sizes, on mobile, PC and console. From solo developers, indie teams, AAA studios, and publishers. Built with cross-platform in mind.\n\n▪ Manage your game\nSave time and upgrade your game with leaderboards, progression, and more. Completely off-the-shelf features, built to work with any game and platform.\n\n▪ Manage your content\nTake charge of your game's content on all platforms, in one place. Sort, edit and manage everything, from cosmetics to currencies, UGC to DLC. Without breaking a sweat.\n\n▪ Manage your players\nStore your players' data together in one place. Access their profile and friends list cross-platform. Manage reports, messages, refunds and gifts to keep them hooked.\n", "unity": "2019.2",