Repository navigation
feat: client SDK for .NET and Node #413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| using System.Net; | ||
| using System.Net.Http.Headers; | ||
| using System.Net.Http.Json; | ||
| using System.Text.Json; | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace SW.Bitween.Client; | ||
|
|
||
| /// <summary> | ||
| /// One Bitween's API, as a service account. The account's token is traded for a session on the first | ||
| /// call, and traded again whenever the session runs out or is refused, so the token is all a caller | ||
| /// hands over. Keep one for the life of the program: it can be used from many threads at once, and | ||
| /// they share one session. What the account may do is what its roles allow: <c>exchanges.view</c> to | ||
| /// read, and <c>exchanges.operate</c> to retry. | ||
| /// </summary> | ||
| /// <example> | ||
| /// <code> | ||
| /// using var bitween = new BitweenClient("https://bitween.example.com", "bwt_..."); | ||
| /// var failed = await bitween.Exchanges.SearchAsync(new ExchangeQuery { Status = ExchangeStatus.Failed }); | ||
| /// </code> | ||
| /// </example> | ||
| public sealed class BitweenClient : IDisposable | ||
| { | ||
| internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) | ||
| { | ||
| Converters = { new JsonStringEnumConverter() }, | ||
| }; | ||
|
|
||
| readonly HttpClient http; | ||
| readonly bool ownsHttp; | ||
| readonly string token; | ||
| readonly Lock signInLock = new(); | ||
| Task<Session>? signingIn; | ||
| volatile Session? session; | ||
|
|
||
| /// <param name="url">The Bitween's address, such as <c>https://bitween.example.com</c>.</param> | ||
| /// <param name="token">A service account's token, starting <c>bwt_</c>.</param> | ||
| public BitweenClient(string url, string token) | ||
| : this(new HttpClient { BaseAddress = new Uri(url.TrimEnd('/') + "/"), Timeout = TimeSpan.FromMinutes(5) }, token) | ||
| { | ||
| ownsHttp = true; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// With an <see cref="HttpClient"/> of the caller's, whose <see cref="HttpClient.BaseAddress"/> is the | ||
| /// Bitween's address, ending in <c>/</c> when it has a path. Disposing the client leaves it open. | ||
| /// </summary> | ||
| public BitweenClient(HttpClient http, string token) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(http); | ||
| if (http.BaseAddress == null) throw new ArgumentException("The HttpClient needs a BaseAddress: the Bitween's address.", nameof(http)); | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(token); | ||
| this.http = http; | ||
| this.token = token.Trim(); | ||
| Exchanges = new Exchanges(this); | ||
| ScheduledRetries = new ScheduledRetries(this); | ||
| } | ||
|
|
||
| /// <summary>Search exchanges, read their files, and retry or export them.</summary> | ||
| public Exchanges Exchanges { get; } | ||
|
|
||
| /// <summary>The automatic retries waiting to run, and running one now.</summary> | ||
| public ScheduledRetries ScheduledRetries { get; } | ||
|
|
||
| internal async Task<T> GetAsync<T>(string path, CancellationToken cancellation) | ||
| { | ||
| using var response = await SendAsync(() => new HttpRequestMessage(HttpMethod.Get, path), | ||
| HttpCompletionOption.ResponseContentRead, cancellation); | ||
| return (await response.Content.ReadFromJsonAsync<T>(Json, cancellation))!; | ||
| } | ||
|
|
||
| internal async Task<T> PostAsync<T>(string path, object body, CancellationToken cancellation) | ||
| { | ||
| using var response = await SendAsync(() => Post(path, body), HttpCompletionOption.ResponseContentRead, cancellation); | ||
| return (await response.Content.ReadFromJsonAsync<T>(Json, cancellation))!; | ||
| } | ||
|
|
||
| internal async Task PostAsync(string path, object body, CancellationToken cancellation) | ||
| { | ||
| using var _ = await SendAsync(() => Post(path, body), HttpCompletionOption.ResponseContentRead, cancellation); | ||
| } | ||
|
|
||
| /// <summary>The answer's body as it comes, for a file too big to hold: the caller disposes it.</summary> | ||
| internal async Task<Stream> PostForStreamAsync(string path, object body, CancellationToken cancellation) | ||
| { | ||
| var response = await SendAsync(() => Post(path, body), HttpCompletionOption.ResponseHeadersRead, cancellation); | ||
| return await response.Content.ReadAsStreamAsync(cancellation); | ||
| } | ||
|
|
||
| static HttpRequestMessage Post(string path, object body) => | ||
| new(HttpMethod.Post, path) { Content = JsonContent.Create(body, body.GetType(), options: Json) }; | ||
|
|
||
| async Task<HttpResponseMessage> SendAsync(Func<HttpRequestMessage> make, HttpCompletionOption completion, | ||
| CancellationToken cancellation) | ||
| { | ||
| var used = await SessionAsync(null, cancellation); | ||
| var response = await SendOnceAsync(make(), used, completion, cancellation); | ||
| if (response.StatusCode == HttpStatusCode.Unauthorized) | ||
| { | ||
| // Ended before its time, as by the account's roles changing: sign in again once and try again. | ||
| response.Dispose(); | ||
| used = await SessionAsync(used, cancellation); | ||
| response = await SendOnceAsync(make(), used, completion, cancellation); | ||
| } | ||
|
|
||
| if (response.IsSuccessStatusCode) return response; | ||
| using (response) throw await BitweenClientException.FromAsync(response, cancellation); | ||
| } | ||
|
|
||
| async Task<HttpResponseMessage> SendOnceAsync(HttpRequestMessage request, Session with, | ||
| HttpCompletionOption completion, CancellationToken cancellation) | ||
| { | ||
| using (request) | ||
| { | ||
| request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", with.Jwt); | ||
| return await http.SendAsync(request, completion, cancellation); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The session to send: the one held, unless it is about to end or is <paramref name="refused"/>, | ||
| /// when the token is traded for a new one. Calls that find it so together share one trade and its | ||
| /// answer, a refusal included: a bad token sent once a call would soon lock the address out. | ||
| /// </summary> | ||
| async Task<Session> SessionAsync(Session? refused, CancellationToken cancellation) | ||
| { | ||
| var held = session; | ||
| if (Usable(held, refused)) return held!; | ||
|
|
||
| Task<Session> signIn; | ||
| lock (signInLock) | ||
| { | ||
| held = session; | ||
| if (Usable(held, refused)) return held!; | ||
| // Started apart from this lock, so it can't finish, and clear itself, before it is kept. | ||
| signIn = signingIn ??= Task.Run(SignInAsync); | ||
| } | ||
| return await signIn.WaitAsync(cancellation); | ||
| } | ||
|
|
||
| async Task<Session> SignInAsync() | ||
| { | ||
| try | ||
| { | ||
| using var response = await http.PostAsJsonAsync("api/accounts/servicetoken", new { token }, Json); | ||
| if (!response.IsSuccessStatusCode) throw await BitweenClientException.FromAsync(response, CancellationToken.None); | ||
| var answer = await response.Content.ReadFromJsonAsync<SignIn>(Json); | ||
| if (string.IsNullOrEmpty(answer?.Jwt)) | ||
| throw new BitweenClientException("Bitween answered the sign-in without a session; is this a Bitween?", response.StatusCode); | ||
|
|
||
|
Comment on lines
+148
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| // Renewed a minute early, so no call goes out on a session that ends on its way; halfway | ||
| // through one shorter than two minutes, so it is still used. | ||
| var early = Math.Min(60, answer.ExpiresIn / 2); | ||
| return session = new Session(answer.Jwt, DateTimeOffset.UtcNow.AddSeconds(answer.ExpiresIn - early)); | ||
| } | ||
| finally | ||
| { | ||
| lock (signInLock) signingIn = null; | ||
| } | ||
| } | ||
|
|
||
| static bool Usable(Session? held, Session? refused) => | ||
| held != null && held != refused && DateTimeOffset.UtcNow < held.RenewAfter; | ||
|
|
||
| public void Dispose() | ||
| { | ||
| if (ownsHttp) http.Dispose(); | ||
| } | ||
|
|
||
| sealed record Session(string Jwt, DateTimeOffset RenewAfter); | ||
|
|
||
| sealed record SignIn(string Jwt, int ExpiresIn); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| using System.Net; | ||
| using System.Text.Json; | ||
| using System.Text.Json.Nodes; | ||
|
|
||
| namespace SW.Bitween.Client; | ||
|
|
||
| /// <summary>What a Bitween refused, and why.</summary> | ||
| public class BitweenClientException(string message, HttpStatusCode status, IReadOnlyDictionary<string, string[]>? errors = null) | ||
| : Exception(message) | ||
| { | ||
| public HttpStatusCode Status { get; } = status; | ||
|
|
||
| /// <summary> | ||
| /// Bitween's reasons by code, such as <c>ALREADY_RETRIED</c> or <c>DATABASE_TIMED_OUT</c>, each with | ||
| /// its messages. Empty when it gave none. | ||
| /// </summary> | ||
| public IReadOnlyDictionary<string, string[]> Errors { get; } = errors ?? new Dictionary<string, string[]>(); | ||
|
|
||
| /// <summary>The first of <see cref="Errors"/>' codes, or null when there are none.</summary> | ||
| public string? Code => Errors.Keys.FirstOrDefault(); | ||
|
|
||
| internal static async Task<BitweenClientException> FromAsync(HttpResponseMessage response, CancellationToken cancellation) | ||
| { | ||
| var body = await response.Content.ReadAsStringAsync(cancellation); | ||
| var errors = ErrorsIn(body); | ||
| var messages = errors.SelectMany(e => e.Value).Where(m => !string.IsNullOrWhiteSpace(m)).ToList(); | ||
| var message = messages.Count > 0 ? string.Join(" ", messages) : response.StatusCode switch | ||
| { | ||
| HttpStatusCode.Unauthorized => "Bitween refused the service token, or the session made with it.", | ||
| HttpStatusCode.Forbidden => "The service account's roles don't have the permission this needs.", | ||
| HttpStatusCode.NotFound => "Bitween has no such thing.", | ||
| _ => $"Bitween answered {(int)response.StatusCode}{(string.IsNullOrWhiteSpace(body) ? "" : ": " + body.Trim())}", | ||
| }; | ||
| return new BitweenClientException(message, response.StatusCode, errors); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Validation answers as <c>{ CODE: [messages] }</c>, and problem details with <c>errors</c> in that | ||
| /// shape. Problem details without them say only the status, so they give no reasons. | ||
| /// </summary> | ||
| static Dictionary<string, string[]> ErrorsIn(string body) | ||
| { | ||
| var errors = new Dictionary<string, string[]>(); | ||
| try | ||
| { | ||
| if (JsonNode.Parse(body) is not JsonObject answer) return errors; | ||
| var reasons = answer["errors"] as JsonObject ?? (answer.ContainsKey("status") ? new JsonObject() : answer); | ||
| foreach (var (code, value) in reasons) | ||
| errors[code] = value switch | ||
| { | ||
| JsonArray list => list.Select(m => m?.ToString() ?? "").ToArray(), | ||
| null => [], | ||
| _ => [value.ToString()], | ||
| }; | ||
| } | ||
| catch (JsonException) { } | ||
| return errors; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: simplify9/Bitween-api
Length of output: 4076
Script executed:
Repository: simplify9/Bitween-api
Length of output: 6260
Share sign-in refusals across waiting calls instead of re-sending the token.
After an unknown-token refusal, each waiting call posts the same token again. Five refusals lock that address for 15 minutes, and during the lock the server refuses new sign-ins from it, including valid service accounts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in b8bbc06: calls that find the session spent now share one sign-in task and its answer, refusal included. A new HttpTests case sends 10 calls at once with a bad token and then signs in with a good token from the same address. It fails on the old code with 'Too many invalid tokens from this address' and passes now.