Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/bitween-api-cicd-gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,12 @@ jobs:
container-registry: 'docker.io'
image-name: 'simplify9/bitween'

# The SDK and the adapter contract package (SimplyWorks.Bitween.Adapters) adapters reference.
# The SDK, the adapter contract package (SimplyWorks.Bitween.Adapters) adapters reference, and
# the client (SimplyWorks.Bitween.Client) clients' own programs call Bitween with.
nuget-projects: >-
SW.Bitween.Sdk/SW.Bitween.Sdk.csproj
SW.Bitween.Adapters/SW.Bitween.Adapters.csproj
SW.Bitween.Client/SW.Bitween.Client.csproj
Comment thread
hamzahalq marked this conversation as resolved.
Resolved
run-tests: 'true'
# Integration tests run against real Postgres/RabbitMQ/MailHog via
# Testcontainers - self-contained, no extra CI services needed, since
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/dotnet-pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ jobs:
--no-build --configuration Release
--logger "trx;LogFileName=unit-tests.trx"
--results-directory TestResults
# SimplyWorks.Bitween.Client against a real Bitween (Testcontainers): clients build their own
# screens on it, so a change that breaks it is stopped here rather than found after they upgrade.
- run: >
dotnet test SW.Bitween.HttpTests/SW.Bitween.HttpTests.csproj
--no-build --configuration Release
--filter "FullyQualifiedName~BitweenClientTests"
--logger "trx;LogFileName=client-tests.trx"
--results-directory TestResults
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
Expand Down
42 changes: 38 additions & 4 deletions .github/workflows/sdk-release.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
name: SDK release

# Publishes the adapter contract for Python to PyPI as simplyworks-bitween and for JavaScript and
# TypeScript to npm as @simplyworks/bitween. Never run by hand or by a tag: the release run on
# TypeScript to npm as @simplyworks/bitween, and the client for JavaScript and TypeScript to npm as
# @simplyworks/bitween-client. Never run by hand or by a tag: the release run on
# releases/r10.0 (bitween-api-cicd-gateway.yml) calls it with the version it computed, so they carry
# Bitween's version. Only a release version is published. Each is tested first against
# SW-Serverless's SDK from its default branch, and a version already on its registry is left alone,
# so a failed run can simply be run again.
# Bitween's version. Only a release version is published. Each is tested first (the adapter
# contracts against SW-Serverless's SDK from its default branch), and a version already on its
# registry is left alone, so a failed run can simply be run again.
on:
workflow_call:
inputs:
Expand Down Expand Up @@ -93,3 +94,36 @@ jobs:
else
npm publish --access public
fi

node-client:
if: ${{ !contains(inputs.version, '-') }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: sdk/node-client
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- name: Version
id: version
env:
VERSION: ${{ inputs.version }}
run: |
v="$VERSION"
npm version "$v" --no-git-tag-version --allow-same-version
echo "value=$v" >> "$GITHUB_OUTPUT"
- name: Test
run: node --test
- name: Publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
name=$(node -p "require('./package.json').name")
if npm view "$name@${{ steps.version.outputs.value }}" version >/dev/null 2>&1; then
echo "$name@${{ steps.version.outputs.value }} is already on npm."
else
npm publish --access public
fi
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ Instances of abusive, harassing, or otherwise unacceptable behavior may be repor

By submitting a contribution to Bitween, you agree that:

- your contribution is licensed under the GNU Affero General Public License v3.0, or under the MIT License if it is in `SW.Bitween.Sdk` or a `SW.Bitween.Sample*` project;
- your contribution is licensed under the GNU Affero General Public License v3.0, or under the MIT License if it is in `SW.Bitween.Sdk`, the client SDK (`SW.Bitween.Client`, `sdk/node-client`) or a `SW.Bitween.Sample*` project;
- you grant Simplify9 a perpetual, worldwide, royalty-free right to also license your contribution under other terms, including commercial licenses; and
- you have the right to make the contribution (for example, your employer permits it).

Expand Down
8 changes: 5 additions & 3 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ network. Section 13 still applies when anyone else interacts with it remotely.
COMPONENTS UNDER THE MIT LICENSE
--------------------------------

So that custom adapters built on them are not affected by the AGPL, the
following directories remain under the MIT License (see the LICENSE file in
each directory):
So that custom adapters and client apps built on them are not affected by the
AGPL, the following directories remain under the MIT License (see the LICENSE
file in each directory):

SW.Bitween.Sdk (NuGet: SimplyWorks.Bitween.Sdk)
SW.Bitween.Client (NuGet: SimplyWorks.Bitween.Client)
sdk/node-client (npm: @simplyworks/bitween-client)
SW.Bitween.Sample* (sample adapters, handlers and mappers)


Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ The database schema is created on first start. A new database refuses to start w
| [Adapters](docs/adapters.md) | Every built-in adapter and its properties, custom adapters in .NET, Python and JavaScript, publishing, the adapter editor and the source viewer |
| [Adapter contract](docs/adapter-contract.md) | What Bitween calls on an adapter of each kind and what it passes, in every language |
| [The bitween CLI](docs/cli.md) | Signing in from a terminal, and writing, building, checking and publishing adapters |
| [Client SDK](docs/client-sdk.md) | Exchanges from your own program, in .NET or JavaScript: search, read files, retry, bulk retry and export |
| [Mapping](docs/mapping.md) | The rules-based mapper and the legacy Scriban JSON mapper |
| [Data sources](docs/data-sources.md) | Long-lived connections: enabling them, placement, health and resource limits |
| [External brokers](docs/external-brokers.md) | Reading from and publishing to a customer's RabbitMQ or Amazon SQS |
Expand Down Expand Up @@ -146,6 +147,7 @@ Deeper reference written alongside the features:
| `SW.Bitween.Adapters` | The adapter contract and its .NET interfaces, published to NuGet as `SimplyWorks.Bitween.Adapters` |
| `SW.Bitween.Adapters.Tooling` | Bitween's adapters on SW-Serverless's tooling: the contract, templates and Python and Node packages the CLI and the adapter editor use |
| `SW.Bitween.Cli` | The `bitween` command |
| `SW.Bitween.Client`, `sdk/node-client` | The client SDK, for exchanges from your own program: `SimplyWorks.Bitween.Client` on NuGet and `@simplyworks/bitween-client` on npm |
| `sdk/python`, `sdk/node` | The adapter contract for Python (`simplyworks-bitween`) and for JavaScript and TypeScript (`@simplyworks/bitween`) |
| `SW.Bitween.PgSql`, `SW.Bitween.MySql`, `SW.Bitween.MsSql` | Provider-specific database contexts and migrations |
| `SW.Bitween.Sample*` | Sample custom adapters, including a resident handler |
Expand All @@ -162,7 +164,7 @@ Bitween is licensed under the [GNU Affero General Public License v3.0](LICENSE),
- You can use, run and modify Bitween for free, including in production inside your organization.
- If you distribute Bitween or offer it as a service to others, you must release your source code under the AGPL and keep the "Based on Bitween by Simplify9" attribution.
- Forks and modified versions must use a different name. "Bitween" is a Simplify9 trademark.
- `SW.Bitween.Sdk` (the `SimplyWorks.Bitween.Sdk` NuGet package) and the `SW.Bitween.Sample*` projects stay under the MIT License, so your custom adapters can use any license.
- `SW.Bitween.Sdk` (the `SimplyWorks.Bitween.Sdk` NuGet package), the client SDK (`SW.Bitween.Client` and `sdk/node-client`) and the `SW.Bitween.Sample*` projects stay under the MIT License, so your custom adapters and client apps can use any license.
- Versions released before the license change remain available under the MIT License.

A commercial license is available from Simplify9 ([info@simplify9.com](mailto:info@simplify9.com)) for embedding Bitween in closed-source products or hosting it for third parties without releasing source code.
181 changes: 181 additions & 0 deletions SW.Bitween.Client/BitweenClient.cs
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);
}
59 changes: 59 additions & 0 deletions SW.Bitween.Client/BitweenClientException.cs
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;
}
}
Loading
Loading