248 lines
9.9 KiB
C#
248 lines
9.9 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<KvResult> GetAsync(CancellationToken token = default)
|
|
{
|
|
var body = new JObject();
|
|
return SendAsync("Get", body, false, token);
|
|
}
|
|
|
|
public Task<KvResult> SetAsync(IDictionary<string, string> 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<KvResult> DeleteAsync(IEnumerable<string> 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<string> keys, out List<string> snapshot)
|
|
{
|
|
snapshot = new List<string>();
|
|
if (keys == null)
|
|
{
|
|
return false;
|
|
}
|
|
foreach (var key in keys)
|
|
{
|
|
if (!ValidKey(key))
|
|
{
|
|
return false;
|
|
}
|
|
snapshot.Add(key);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static Task<KvResult> Invalid() => Task.FromResult(new KvResult(KVError.Validation));
|
|
|
|
private async Task<KvResult> 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<string, string> ParseData(string text, out Dictionary<string, DateTime> 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<string, string>(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<string>());
|
|
}
|
|
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<string, DateTime>(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<string>();
|
|
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<string, string> Data
|
|
{
|
|
get;
|
|
}
|
|
// Null means the response did not supply timestamp metadata; never substitute read time.
|
|
public IReadOnlyDictionary<string, DateTime> 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<string, string> data = null, bool mayHaveCommitted = false,
|
|
Dictionary<string, DateTime> updatedAtUtc = null)
|
|
{
|
|
Error = error;
|
|
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
|
|
UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary<string, DateTime>(updatedAtUtc);
|
|
MayHaveCommitted = mayHaveCommitted;
|
|
}
|
|
}
|
|
}
|