using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Core.KV { /// /// Private KV protocol for one fixed authenticated account/environment. /// Caller owns the HttpClient, cookies and cancellation lifetime; never reuse /// that transport for another account. This class neither retries nor logs payloads. /// Production login/storage integration is deliberately outside this adapter. /// public sealed class KvClient { private readonly HttpClient http; private readonly Uri api; public KvClient(HttpClient http, Uri apiBaseUri) { this.http = http ?? throw new ArgumentNullException(nameof(http)); if (apiBaseUri == null || !apiBaseUri.IsAbsoluteUri || (apiBaseUri.Scheme != "http" && apiBaseUri.Scheme != "https") || !string.IsNullOrEmpty(apiBaseUri.UserInfo) || !string.IsNullOrEmpty(apiBaseUri.Query) || !string.IsNullOrEmpty(apiBaseUri.Fragment)) { throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(apiBaseUri)); } api = new Uri(apiBaseUri.AbsoluteUri.TrimEnd('/') + "/"); } public Task GetAsync(CancellationToken token = default) { var body = new JObject(); return SendAsync("Get", body, false, token); } public Task SetAsync(IDictionary data, CancellationToken token = default) { if (data == null) { return Invalid(); } var snapshot = new JObject(); foreach (var pair in data) { if (!ValidKey(pair.Key) || pair.Value == null) { return Invalid(); } snapshot.Add(pair.Key, pair.Value); } return SendAsync("Set", new JObject { ["data"] = snapshot }, true, token); } public Task DeleteAsync(IEnumerable keys, CancellationToken token = default) { if (!CopyKeys(keys, out var snapshot)) { return Invalid(); } return SendAsync("Delete", new JObject { ["keys"] = new JArray(snapshot) }, true, token); } private static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; private static bool CopyKeys(IEnumerable keys, out List snapshot) { snapshot = new List(); if (keys == null) { return false; } foreach (var key in keys) { if (!ValidKey(key)) { return false; } snapshot.Add(key); } return true; } private static Task Invalid() => Task.FromResult(new KvResult(KVError.Validation)); private async Task SendAsync(string action, JObject body, bool mutation, CancellationToken token) { if (token.IsCancellationRequested) { return new KvResult(KVError.Cancelled); } int? status = null; bool enteredTransport = false; try { using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(api, "playerdata/" + action))) { request.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json"); token.ThrowIfCancellationRequested(); enteredTransport = true; using (var response = await http.SendAsync(request, token)) { status = (int)response.StatusCode; token.ThrowIfCancellationRequested(); if (response.StatusCode != HttpStatusCode.OK) { return new KvResult(status == 401 || status == 403 ? KVError.Unauthorized : KVError.Http, mayHaveCommitted: mutation); } if (mutation) { return new KvResult(KVError.None); } var text = await response.Content.ReadAsStringAsync(); token.ThrowIfCancellationRequested(); var data = ParseData(text, out var updatedAtUtc); return new KvResult(KVError.None, data, updatedAtUtc: updatedAtUtc); } } } catch (OperationCanceledException) { return new KvResult(token.IsCancellationRequested ? KVError.Cancelled : KVError.Timeout, mayHaveCommitted: mutation && enteredTransport); } catch (ObjectDisposedException) { return new KvResult(KVError.Cancelled, mayHaveCommitted: mutation && enteredTransport); } catch (HttpRequestException) { return new KvResult(KVError.Network, mayHaveCommitted: mutation && enteredTransport); } catch (IOException) { return new KvResult(KVError.Network, mayHaveCommitted: mutation && enteredTransport); } catch (JsonException) { return new KvResult(KVError.Protocol, mayHaveCommitted: mutation && enteredTransport); } } private static Dictionary ParseData(string text, out Dictionary updatedAtUtc) { updatedAtUtc = null; using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) { var body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); if (reader.Read() || !(body["data"] is JObject data)) { throw new JsonReaderException("Invalid KV response."); } var result = new Dictionary(StringComparer.Ordinal); foreach (var property in data.Properties()) { if (property.Value.Type != JTokenType.String) { throw new JsonReaderException("KV value must be a string."); } result.Add(property.Name, property.Value.Value()); } var timestamps = body.Property("updatedAtUtc"); if (timestamps != null) { if (!(timestamps.Value is JObject times) || times.Count != result.Count) { throw new JsonReaderException("Invalid KV timestamp metadata."); } updatedAtUtc = new Dictionary(StringComparer.Ordinal); var formats = new[] { "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'" }; foreach (var property in times.Properties()) { if (!result.ContainsKey(property.Name) || property.Value.Type != JTokenType.String) { throw new JsonReaderException("Invalid KV timestamp entry."); } var value = property.Value.Value(); if ((value.Length != 20 && (value.Length < 22 || value.Length > 28)) || !DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp)) { throw new JsonReaderException("KV timestamp must be UTC ISO 8601."); } updatedAtUtc.Add(property.Name, timestamp); } } return result; } } } public enum KVError { None, Validation, Unauthorized, Http, Network, Timeout, Cancelled, Protocol } public sealed class KvResult { public bool Success => Error == KVError.None; public KVError Error { get; } // Only a successful Get has Data. Failure must never look like an empty save. public IReadOnlyDictionary Data { get; } // Null means the response did not supply timestamp metadata; never substitute read time. public IReadOnlyDictionary UpdatedAtUtc { get; } // A failed write that entered the transport may already have reached the server. // This is conservative, including HTTP failures. Do not blindly replay the write. public bool MayHaveCommitted { get; } internal KvResult(KVError error, Dictionary data = null, bool mayHaveCommitted = false, Dictionary updatedAtUtc = null) { Error = error; Data = data == null ? null : new ReadOnlyDictionary(data); UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary(updatedAtUtc); MayHaveCommitted = mayHaveCommitted; } } }