using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using UnityEngine; [assembly: InternalsVisibleTo("Crowdin-Tests")] [assembly: InternalsVisibleTo("Crowdin-Tests-ServerAPI")] namespace Crowdin.Plugin.Server_API { using Helpers; using Serializable; using Services; internal sealed class CrowdinHttpClient { private const int MaxRetryAttempts = 5; private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan FileUploadTimeout = TimeSpan.FromMinutes(5); // Timeouts are enforced per-call via a linked CancellationTokenSource in SendWithRetryAsync, // not via HttpClient.Timeout - a single client-level Timeout would apply to every request // regardless of the per-call budget (e.g. it used to cut off PostFileAsync's 5-minute // upload window at 60s, since both shared the same HttpClient). private static readonly Lazy SharedClient = new Lazy(() => new HttpClient { Timeout = Timeout.InfiniteTimeSpan }); private static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), NullValueHandling = NullValueHandling.Ignore }; public static CrowdinHttpClient Default { get; } = new CrowdinHttpClient(); private readonly HttpClient _httpClient; public CrowdinHttpClient() : this(null) { } internal CrowdinHttpClient(HttpMessageHandler handler) { _httpClient = handler is null ? SharedClient.Value : new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; } public Task GetAsync(string url, CancellationToken ct) => SendAsync(HttpMethod.Get, url, null, ct); public Task PostAsync(string url, object body, CancellationToken ct) => SendAsync(HttpMethod.Post, url, body, ct); public Task PutAsync(string url, object body, CancellationToken ct) => SendAsync(HttpMethod.Put, url, body, ct); public Task PatchAsync(string url, object body, CancellationToken ct) => SendAsync(new HttpMethod("PATCH"), url, body, ct); public async Task DeleteAsync(string url, CancellationToken ct) { HttpRequestMessage RequestFactory() => new HttpRequestMessage(HttpMethod.Delete, url); await SendWithRetryAsync(RequestFactory, ct, DefaultTimeout); } public async Task PostFileAsync(string url, Stream file, string fileName, CancellationToken ct) { byte[] fileBytes; using (var memoryStream = new MemoryStream()) { await file.CopyToAsync(memoryStream); fileBytes = memoryStream.ToArray(); } HttpRequestMessage RequestFactory() { var content = new ByteArrayContent(fileBytes); content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; request.Headers.Add("Crowdin-API-FileName", fileName); return request; } string responseJson = await SendWithRetryAsync(RequestFactory, ct, FileUploadTimeout); return Unwrap(responseJson); } public async Task> ListAllAsync(string url, int limit, CancellationToken ct) { List rawItems = await ListAllRawAsync(url, limit, ct); var serializer = JsonSerializer.Create(SerializerSettings); return rawItems.Select(item => Deserialize(item, serializer)).ToList(); } /// Deserializes one list item, wrapping a model/JSON mismatch in a CrowdinApiException so it is /// handled the same way as any other API failure instead of escaping as a bare JsonException. internal static T Deserialize(JObject item, JsonSerializer serializer = null) { try { return item.ToObject(serializer ?? JsonSerializer.Create(SerializerSettings)); } catch (JsonException e) { throw new CrowdinApiException((HttpStatusCode)0, "Failed to parse response from Crowdin API.", innerException: e); } } /// Same pagination/envelope handling as , but returns each item's inner /// "data" object undeserialized so a caller can inspect/filter fields (e.g. a discriminator) before /// choosing how to deserialize it. internal async Task> ListAllRawAsync(string url, int limit, CancellationToken ct) { var result = new List(); int offset = 0; int itemsCount; do { string pagedUrl = AppendQuery(url, $"limit={limit}&offset={offset}"); HttpRequestMessage RequestFactory() => new HttpRequestMessage(HttpMethod.Get, pagedUrl); string responseJson = await SendWithRetryAsync(RequestFactory, ct, DefaultTimeout); JObject jObject = ParseJson(responseJson); if (!jObject.TryGetValue("data", out JToken arrayToken) || arrayToken is not JArray array) { throw new CrowdinApiException((HttpStatusCode)0, "Expected a \"data\" array in the response."); } itemsCount = array.Count; foreach (JObject item in array.OfType()) { if (item.TryGetValue("data", out JToken innerData) && innerData is JObject innerObject) { result.Add(innerObject); } } offset += itemsCount; } while (itemsCount >= limit); return result; } public Task DownloadAsync(string absoluteUrl, CancellationToken ct) { HttpRequestMessage RequestFactory() => new HttpRequestMessage(HttpMethod.Get, absoluteUrl); return SendWithRetryAsync( RequestFactory, ct, FileUploadTimeout, addAuthHeaders: false, response => response.Content.ReadAsByteArrayAsync()); } private async Task SendAsync(HttpMethod method, string url, object body, CancellationToken ct) { string json = body != null ? JsonConvert.SerializeObject(body, SerializerSettings) : null; HttpRequestMessage RequestFactory() { var request = new HttpRequestMessage(method, url); if (json != null) { request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } return request; } string responseJson = await SendWithRetryAsync(RequestFactory, ct, DefaultTimeout); return Unwrap(responseJson); } private Task SendWithRetryAsync( Func requestFactory, CancellationToken callerCt, TimeSpan timeout) => SendWithRetryAsync( requestFactory, callerCt, timeout, addAuthHeaders: true, response => response.Content.ReadAsStringAsync()); private async Task SendWithRetryAsync( Func requestFactory, CancellationToken callerCt, TimeSpan timeout, bool addAuthHeaders, Func> readSuccess) { for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) { using HttpRequestMessage request = requestFactory(); if (addAuthHeaders) { AddAuthHeaders(request); } using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(callerCt); linkedCts.CancelAfter(timeout); HttpResponseMessage response; try { response = await _httpClient.SendAsync(request, linkedCts.Token); } catch (OperationCanceledException) when (callerCt.IsCancellationRequested) { throw new OperationCanceledException(callerCt); } catch (OperationCanceledException e) { throw new CrowdinApiException((HttpStatusCode)0, "Request timed out.", innerException: e); } catch (HttpRequestException e) { throw new CrowdinApiException((HttpStatusCode)0, e.Message, innerException: e); } using (response) { if (response.StatusCode == HttpStatusCode.TooManyRequests) { if (attempt == MaxRetryAttempts) { throw await CreateExceptionAsync(response); } TimeSpan delay = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); Debug.LogWarning( $"Received HTTP 429 Too Many Requests from Crowdin API. Retrying after {delay.TotalSeconds} second(s) ..."); await Task.Delay(delay, callerCt); continue; } if (!response.IsSuccessStatusCode) { throw await CreateExceptionAsync(response); } return await readSuccess(response); } } throw new InvalidOperationException("Unreachable: retry loop exited without returning or throwing."); } private static void AddAuthHeaders(HttpRequestMessage request) { string unityVersion = Application.unityVersion; string unityLocalization = BaseApiService.unityLocalization ?? "1.5.13"; string crowdinUnityPlugin = BaseApiService.crowdinUnityPlugin ?? "0.13.0"; if (!request.Headers.Contains("Authorization")) { request.Headers.Add("Authorization", $"Bearer {SettingsHelper.GetToken()}"); } if (!request.Headers.Contains("User-Agent")) { request.Headers.Add( "User-Agent", $"crowdin-unity-plugin/{crowdinUnityPlugin} unity/{unityVersion} unity-localization/{unityLocalization}"); } } private static T Unwrap(string responseJson) { if (string.IsNullOrWhiteSpace(responseJson)) { return default; } RequestData envelope; try { envelope = JsonConvert.DeserializeObject>(responseJson, SerializerSettings); } catch (JsonException e) { throw new CrowdinApiException((HttpStatusCode)0, "Failed to parse response from Crowdin API.", innerException: e); } return envelope is null ? default : envelope.data; } /// Parses a successful response body as JSON, wrapping a malformed body (e.g. an HTML page from a /// proxy or captive portal returned with a 200 status) in a CrowdinApiException instead of letting /// a bare JsonException escape uncaught. private static JObject ParseJson(string responseJson) { try { return JObject.Parse(responseJson); } catch (JsonException e) { throw new CrowdinApiException((HttpStatusCode)0, "Failed to parse response from Crowdin API.", innerException: e); } } private static string AppendQuery(string url, string queryToAppend) { char separator = url.Contains('?') ? '&' : '?'; return $"{url}{separator}{queryToAppend}"; } private static async Task CreateExceptionAsync(HttpResponseMessage response) { string body = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(); JObject jObject = null; if (!string.IsNullOrWhiteSpace(body)) { try { jObject = JObject.Parse(body); } catch (JsonException) { jObject = null; } } if (response.StatusCode == HttpStatusCode.BadRequest && jObject != null && jObject.TryGetValue("errors", out JToken errorsToken) && errorsToken is JArray errorsArray) { List validationErrors = errorsArray .OfType() .Where(errorOuter => errorOuter["error"]?["errors"] != null) .SelectMany(errorOuter => errorOuter["error"]["errors"] .Select(subError => $"Key \"{errorOuter["error"]["key"]}\": Code \"{subError["code"]}\": {subError["message"]}")) .ToList(); if (validationErrors.Count > 0) { foreach (string validationError in validationErrors) { Debug.LogWarning($"Crowdin API validation error: {validationError}"); } return new CrowdinApiException(response.StatusCode, validationErrors[0], validationErrors); } } string apiMessage = jObject?["error"]?["message"]?.Value(); if (string.IsNullOrWhiteSpace(apiMessage)) { apiMessage = response.ReasonPhrase; } return new CrowdinApiException(response.StatusCode, apiMessage); } } }