using System.Globalization; using System.Net; using System.Net.Http; using System.Text.Json; namespace OpenNest.Data.MaterialLedger; /// /// Read-only client for MaterialLedger's API. It never writes to the ledger, and what it /// reads is a snapshot, not a reservation. Expected failures come back as result cases /// (, ); only the caller's own /// cancellation throws. /// public sealed class MaterialLedgerClient { /// Stock lists are larger than the nest-name lookup, so this waits longer than its 3 s. public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); public const string ApiKeyHeader = "X-Api-Key"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly HttpClient http; private readonly MaterialLedgerSettings settings; private readonly TimeSpan timeout; /// Per-request limit; when null. public MaterialLedgerClient(HttpClient http, MaterialLedgerSettings settings, TimeSpan? timeout = null) { this.http = http ?? throw new ArgumentNullException(nameof(http)); this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); this.timeout = timeout ?? RequestTimeout; } /// /// The free sheets and drops for an OpenNest material. ML resolves the /// material/grade/thickness mapping itself; is inches. /// public async Task GetStockAsync(string material, string? grade, double thicknessIn, CancellationToken cancellationToken) { if (!settings.IsConfigured) return new LedgerStockResult.NotConfigured(); var query = "api/stock/opennest" + "?material=" + Uri.EscapeDataString(material ?? "") + "&grade=" + Uri.EscapeDataString(grade ?? "") + "&thicknessIn=" + System.Math.Round(thicknessIn, 4).ToString("0.####", CultureInfo.InvariantCulture); var reply = await GetJsonAsync(query, "stock", cancellationToken); if (reply.Unauthorized) return new LedgerStockResult.Unauthorized(); if (reply.Body is not { } stock) return new LedgerStockResult.Unreachable(reply.Failure!); if (!stock.Mapped) return new LedgerStockResult.Unmapped(); return new LedgerStockResult.Ok(stock.Specs ?? Array.Empty(), stock.Items ?? Array.Empty()); } /// /// Every hold ML has for the sheets of server nest , in /// whatever status; the caller decides which are open. /// public async Task GetHoldsAsync(Guid openNestNestId, CancellationToken cancellationToken) { if (!settings.IsConfigured) return new LedgerHoldsResult.NotConfigured(); var query = "api/stock/holds?openNestNestId=" + openNestNestId.ToString("D"); var reply = await GetJsonAsync(query, "holds", cancellationToken); if (reply.Unauthorized) return new LedgerHoldsResult.Unauthorized(); if (reply.Body is not { } holds) return new LedgerHoldsResult.Unreachable(reply.Failure!); return new LedgerHoldsResult.Ok((holds.Holds ?? Array.Empty()) .Where(hold => hold != null) .Select(hold => hold with { ItemCode = hold.ItemCode ?? "", Status = hold.Status ?? "" }) .ToList()); } /// /// GETs below the ledger's base URL with the API key and the /// per-request timeout. names the route in the 404 message. /// private async Task> GetJsonAsync(string pathAndQuery, string route, CancellationToken cancellationToken) where T : class { if (!Uri.TryCreate(settings.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var baseUri) || baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps) return Reply.Failed($"'{settings.BaseUrl}' is not an http or https address."); using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(timeout); try { using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, pathAndQuery)); if (!string.IsNullOrEmpty(settings.ApiKey)) request.Headers.TryAddWithoutValidation(ApiKeyHeader, settings.ApiKey); using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) return new Reply(null, true, null); if (response.StatusCode == HttpStatusCode.NotFound) return Reply.Failed($"This MaterialLedger has no OpenNest {route} route; update MaterialLedger."); if (!response.IsSuccessStatusCode) return Reply.Failed($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd()); await using var body = await response.Content.ReadAsStreamAsync(timeoutSource.Token); var value = await JsonSerializer.DeserializeAsync(body, JsonOptions, timeoutSource.Token); return value == null ? Reply.Failed("The ledger returned an empty response.") : new Reply(value, false, null); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return Reply.Failed( $"No answer within {timeout.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} s."); } catch (HttpRequestException ex) { return Reply.Failed(ex.Message); } catch (JsonException) { return Reply.Failed("The ledger's response could not be read."); } catch (IOException ex) { return Reply.Failed(ex.Message); } } /// A parsed body, a rejected key, or why the ledger was unreachable. private readonly record struct Reply(T? Body, bool Unauthorized, string? Failure) where T : class { public static Reply Failed(string reason) => new(null, false, reason); } }