Repository navigation
Release develop to r10.0: client SDK #415
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
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
20d11c0
feat: client SDK for .NET and Node
hamzahalq b8bbc06
fix: client SDK review findings
hamzahalq 9594204
Merge pull request #413 from simplify9/hamza/feat/client-sdk
hamzahalq d48b561
fix: client SDK release review findings
hamzahalq 49064a5
Merge pull request #416 from simplify9/hamza/fix/client-sdk-review
hamzahalq c7e34ee
chore: license the client SDK under MIT
hamzahalq fcde161
Merge pull request #418 from simplify9/hamza/fix/client-sdk-mit
hamzahalq b3c3234
fix: client SDK second review findings
hamzahalq 014dd8a
Merge pull request #419 from simplify9/hamza/fix/client-sdk-review-2
hamzahalq File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| 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); | ||
| try | ||
| { | ||
| return await response.Content.ReadAsStreamAsync(cancellation); | ||
| } | ||
| catch | ||
| { | ||
| response.Dispose(); | ||
| throw; | ||
| } | ||
| } | ||
|
|
||
| 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); | ||
|
|
||
| // 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.