using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace Arm20.Api13; public sealed class Api13Client { private readonly HttpClient _http; private readonly string _apiKey; public Api13Client(HttpClient http, string apiKey) { _http = http ?? throw new ArgumentNullException(nameof(http)); _apiKey = string.IsNullOrWhiteSpace(apiKey) ? throw new ArgumentException("API key is required.", nameof(apiKey)) : apiKey; _http.BaseAddress ??= new Uri("https://api13.arm20.com/"); _http.Timeout = TimeSpan.FromSeconds(25); } public Task> ValidateAsync( string method, TParams parameters, object? id = null, CancellationToken cancellationToken = default) => SendAsync( "v1/rpc/validate", method, parameters, id, idempotencyKey: null, retryReads: false, cancellationToken); public Task> ReadAsync( string method, TParams parameters, object? id = null, CancellationToken cancellationToken = default) => SendAsync( "v1/rpc", method, parameters, id, idempotencyKey: null, retryReads: true, cancellationToken); public Task> WriteAsync( string method, TParams parameters, string idempotencyKey, object? id = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(idempotencyKey)) throw new ArgumentException("Idempotency-Key is required.", nameof(idempotencyKey)); return SendAsync( "v1/rpc", method, parameters, id, idempotencyKey, retryReads: false, cancellationToken); } private async Task> SendAsync( string path, string method, TParams parameters, object? id, string? idempotencyKey, bool retryReads, CancellationToken cancellationToken) { var requestBody = new RpcRequest(method, parameters, id); var attempts = retryReads ? 3 : 1; for (var attempt = 1; attempt <= attempts; attempt++) { using var request = new HttpRequestMessage(HttpMethod.Post, path); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Add("X-Request-Id", $"csharp-{Guid.NewGuid():N}"); if (idempotencyKey is not null) request.Headers.Add("Idempotency-Key", idempotencyKey); request.Content = JsonContent.Create(requestBody); using var response = await _http.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (retryReads && attempt < attempts && response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable) { var delay = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromMilliseconds(250 * Math.Pow(2, attempt - 1)); await Task.Delay(delay > TimeSpan.FromSeconds(30) ? TimeSpan.FromSeconds(30) : delay, cancellationToken); continue; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var envelope = await JsonSerializer.DeserializeAsync>( stream, Api13JsonContext.Default.Options, cancellationToken); if (envelope is null) throw new Api13Exception("INVALID_RESPONSE", (int)response.StatusCode, null); if (!response.IsSuccessStatusCode || !envelope.Ok) throw new Api13Exception( envelope.Error?.Code ?? $"HTTP_{(int)response.StatusCode}", (int)response.StatusCode, envelope.RequestId); return envelope; } throw new Api13Exception("ATTEMPTS_EXHAUSTED", 0, null); } } public sealed record RpcRequest( [property: JsonPropertyName("method")] string Method, [property: JsonPropertyName("params")] TParams Params, [property: JsonPropertyName("id")] object? Id); public sealed record Api13Envelope( [property: JsonPropertyName("ok")] bool Ok, [property: JsonPropertyName("data")] T? Data, [property: JsonPropertyName("error")] Api13Error? Error, [property: JsonPropertyName("request_id")] string? RequestId); public sealed record Api13Error( [property: JsonPropertyName("code")] string Code, [property: JsonPropertyName("message")] string Message, [property: JsonPropertyName("details")] JsonElement Details); public sealed record ValidationResult( [property: JsonPropertyName("valid")] bool Valid, [property: JsonPropertyName("executed")] bool Executed, [property: JsonPropertyName("method")] string Method, [property: JsonPropertyName("mutating")] bool Mutating, [property: JsonPropertyName("idempotency_required_for_execution")] bool IdempotencyRequired); public sealed record RpcResult( [property: JsonPropertyName("method")] string Method, [property: JsonPropertyName("result")] JsonElement Result, [property: JsonPropertyName("id")] JsonElement Id); public sealed class Api13Exception : Exception { public string ApiCode { get; } public int HttpStatus { get; } public string? RequestId { get; } public Api13Exception(string apiCode, int httpStatus, string? requestId) : base($"API13 request failed: {apiCode}.") { ApiCode = apiCode; HttpStatus = httpStatus; RequestId = requestId; } } [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] [JsonSerializable(typeof(Api13Envelope))] [JsonSerializable(typeof(Api13Envelope))] internal partial class Api13JsonContext : JsonSerializerContext { }