This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 9536916bd feat(csharp): implement connection string parsing (#4363)
9536916bd is described below
commit 9536916bd0853129c2c89e9a09cd0aea49d2b618
Author: Łukasz Zborek <[email protected]>
AuthorDate: Sat Oct 10 14:17:39 2026 +0200
feat(csharp): implement connection string parsing (#4363)
---
.../ConnectionStringTests.cs | 104 +++++
.../IggyTlsConnectionTests.cs | 16 +
.../Iggy_SDK/Configuration/ConnectionString.cs | 250 ++++++++++++
.../Configuration/IggyClientConfigurator.cs | 38 ++
.../csharp/Iggy_SDK/Factory/IggyClientFactory.cs | 21 +-
.../csharp/Iggy_SDK/Utils/IggyDurationParser.cs | 394 ++++++++++++++++++
.../ClientTests/ConnectionStringTests.cs | 441 +++++++++++++++++++++
.../UtilityTests/IggyDurationParserTests.cs | 227 +++++++++++
foreign/csharp/README.md | 45 +++
9 files changed, 1532 insertions(+), 4 deletions(-)
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ConnectionStringTests.cs
b/foreign/csharp/Iggy_SDK.Tests.Integration/ConnectionStringTests.cs
new file mode 100644
index 000000000..3e677b059
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ConnectionStringTests.cs
@@ -0,0 +1,104 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Factory;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+
+namespace Apache.Iggy.Tests.Integrations;
+
+public class ConnectionStringTests
+{
+ // Below the fixture's server heartbeat, or an idle client is evicted
mid-test.
+ private const string Heartbeat = "heartbeat_interval=1s";
+
+ [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+ public required IggyServerFixture Fixture { get; init; }
+
+ [Test]
+ public async Task ConnectionString_WithEveryOption_Should_SignIn()
+ {
+ var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+ var connectionString =
$"iggy://iggy:iggy@{address}?nodelay=true&reconnection_retries=1"
+ +
$"&reconnection_interval=1s&reestablish_after=5s&tls=false&{Heartbeat}";
+
+ var config =
IggyClientConfigurator.FromConnectionString(connectionString);
+ config.ReconnectionSettings.MaxRetries.ShouldBe(1);
+
config.ReconnectionSettings.InitialDelay.ShouldBe(TimeSpan.FromSeconds(1));
+ config.HeartbeatInterval.ShouldBe(TimeSpan.FromSeconds(1));
+ config.TlsSettings.Enabled.ShouldBeFalse();
+
+ using var client = IggyClientFactory.CreateClient(config);
+ await client.ConnectAsync();
+
+ await Should.NotThrowAsync(client.PingAsync());
+ var me = await client.GetMeAsync();
+ me.ShouldNotBeNull();
+ me.UserId.ShouldBe(0u);
+ }
+
+ [Test]
+ [Arguments("iggy")]
+ [Arguments("iggy+tcp")]
+ public async Task ConnectionString_WithPassword_Should_SignIn(string
scheme)
+ {
+ var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+ using var client =
IggyClientFactory.CreateClient($"{scheme}://iggy:iggy@{address}?{Heartbeat}");
+ await client.ConnectAsync();
+
+ var me = await client.GetMeAsync();
+ me.ShouldNotBeNull();
+ me.UserId.ShouldBe(0u);
+ }
+
+ [Test]
+ public async Task ConnectionString_WithPersonalAccessToken_Should_SignIn()
+ {
+ using var admin = await
Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+ var name = $"cs-{Guid.NewGuid():N}"[..20];
+ var token = await admin.CreatePersonalAccessTokenAsync(name,
TimeSpan.FromHours(1));
+ var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+ using var client =
IggyClientFactory.CreateClient($"iggy+tcp://{token!.Token}@{address}?{Heartbeat}");
+ await client.ConnectAsync();
+
+ var me = await client.GetMeAsync();
+ me.ShouldNotBeNull();
+ me.UserId.ShouldBe(0u);
+ }
+
+ [Test]
+ [Arguments("reconnection_retries=unlimited")]
+ [Arguments("reconnection_retries=10&reconnection_interval=250ms")]
+ [Arguments("reconnection_interval=1m30s")]
+ [Arguments("reconnection_retries=0")]
+ [Arguments("nodelay=false")]
+ [Arguments("reestablish_after=7s")]
+ [Arguments("reestablish_after=0")]
+ public async Task
ConnectionString_WithAcceptedOptionValues_Should_ReachTheServer(string options)
+ {
+ var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+ using var client =
IggyClientFactory.CreateClient($"iggy://iggy:iggy@{address}?{options}&{Heartbeat}");
+ await client.ConnectAsync();
+
+ await Should.NotThrowAsync(client.PingAsync());
+ }
+}
diff --git
a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
index a89da78be..e5ac343f3 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
@@ -101,4 +101,20 @@ public class IggyTlsConnectionTests
loginResult.ShouldNotBeNull();
}
+
+ [Test]
+ public async Task Connect_WithTlsConnectionString_Should_SignIn()
+ {
+ var address = await Fixture.GetIggyAddressAsync(Protocol.Tcp);
+
+ // The heartbeat stays below the fixture's server heartbeat, or an
idle client is evicted mid-test.
+ using var client = IggyClientFactory.CreateClient(
+
$"iggy://iggy:iggy@{address}?tls=true&tls_domain=localhost&tls_ca_file=Certs/iggy_ca_cert.pem"
+ + "&reconnection_retries=0&heartbeat_interval=1s");
+ await client.ConnectAsync();
+
+ var me = await client.GetMeAsync();
+ me.ShouldNotBeNull();
+ me.UserId.ShouldBe(0u);
+ }
}
diff --git a/foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs
b/foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs
new file mode 100644
index 000000000..8595574af
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs
@@ -0,0 +1,250 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Configuration;
+
+// Parsed by hand because Uri escaping and host normalization accept strings
this format rejects.
+internal static class ConnectionString
+{
+ private const string DefaultScheme = "iggy";
+ private const string TcpScheme = "iggy+tcp";
+ private const string SchemePrefix = "iggy+";
+
+ // Unbracketed hosts may not contain ':', so 2001:db8::1:8090 and host:1:2
are rejected.
+ private static readonly Regex AuthorityPattern =
new(@"^(?:\[([^\]\s\p{Cc}]+)\]|([^:\[\]\s\p{Cc}]+)):([0-9]+)\z",
+ RegexOptions.CultureInvariant);
+
+ internal static IggyClientConfigurator Parse(string connectionString)
+ {
+ ArgumentNullException.ThrowIfNull(connectionString);
+
+ var protocolParts = connectionString.Split("://");
+ if (protocolParts.Length != 2)
+ {
+ throw Invalid();
+ }
+
+ ValidateScheme(protocolParts[0]);
+
+ var parts = protocolParts[1].Split('@');
+ if (parts.Length != 2)
+ {
+ throw Invalid();
+ }
+
+ var autoLoginSettings = ParseCredentials(parts[0]);
+
+ var serverAndOptions = parts[1].Split('?');
+ if (serverAndOptions.Length > 2)
+ {
+ throw Invalid();
+ }
+
+ var serverAddress = serverAndOptions[0];
+ var serverHost = ParseHost(serverAddress);
+ var (tlsSettings, reconnectionSettings, heartbeatInterval) =
+ ParseOptions(serverAndOptions.ElementAtOrDefault(1), serverHost);
+
+ return new IggyClientConfigurator
+ {
+ BaseAddress = serverAddress,
+ Protocol = Protocol.Tcp,
+ HeartbeatInterval = heartbeatInterval,
+ TlsSettings = tlsSettings,
+ ReconnectionSettings = reconnectionSettings,
+ AutoLoginSettings = autoLoginSettings
+ };
+ }
+
+ private static void ValidateScheme(string scheme)
+ {
+ if (scheme == DefaultScheme || scheme == TcpScheme)
+ {
+ return;
+ }
+
+ if (scheme.Equals(DefaultScheme, StringComparison.OrdinalIgnoreCase)
+ || scheme.Equals(TcpScheme, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new FormatException(
+ $"Connection string schemes are case-sensitive, use
\"{DefaultScheme}\" or \"{TcpScheme}\".");
+ }
+
+ // Only a plain transport name is echoed, a malformed scheme may carry
credentials.
+ if (scheme.StartsWith(SchemePrefix, StringComparison.Ordinal)
+ && scheme.Length > SchemePrefix.Length
+ && scheme[SchemePrefix.Length..].All(char.IsAsciiLetter))
+ {
+ throw new FormatException(
+ $"Unsupported transport \"{scheme[SchemePrefix.Length..]}\",
connection strings support tcp only.");
+ }
+
+ throw Invalid();
+ }
+
+ private static AutoLoginSettings ParseCredentials(string userInfo)
+ {
+ var credentials = userInfo.Split(':');
+ if (credentials.Length > 2 || credentials.Any(string.IsNullOrEmpty))
+ {
+ throw Invalid();
+ }
+
+ return credentials.Length == 1
+ ? AutoLoginSettings.ForPersonalAccessToken(credentials[0])
+ : AutoLoginSettings.For(credentials[0], credentials[1]);
+ }
+
+ private static string ParseHost(string serverAddress)
+ {
+ var addressMatch = AuthorityPattern.Match(serverAddress);
+ if (!addressMatch.Success ||
!ushort.TryParse(addressMatch.Groups[3].Value, NumberStyles.None,
+ CultureInfo.InvariantCulture, out _))
+ {
+ throw Invalid();
+ }
+
+ return addressMatch.Groups[1].Success
+ ? addressMatch.Groups[1].Value
+ : addressMatch.Groups[2].Value;
+ }
+
+ private static (TlsSettings, ReconnectionSettings, TimeSpan)
ParseOptions(string? query, string serverHost)
+ {
+ // The certificate name check needs a hostname when tls_domain is
missing.
+ var tlsSettings = new TlsSettings { Hostname = serverHost };
+ var reconnectionSettings = new ReconnectionSettings
+ {
+ Enabled = true,
+ MaxRetries = 0,
+ InitialDelay = TimeSpan.FromSeconds(1),
+ UseExponentialBackoff = false
+ };
+ var heartbeatInterval = TimeSpan.FromSeconds(5);
+
+ foreach (var option in query?.Split('&') ?? [])
+ {
+ var (name, value) = SplitOption(option);
+ switch (name)
+ {
+ case "tls":
+ tlsSettings.Enabled = ParseBoolean(name, value);
+ break;
+ case "tls_domain":
+ tlsSettings.Hostname = value.Length == 0 ? serverHost :
value;
+ break;
+ case "tls_ca_file":
+ tlsSettings.CertificatePath = value;
+ break;
+ case "reconnection_retries":
+ ApplyReconnectionRetries(reconnectionSettings, name,
value);
+ break;
+ case "reconnection_interval":
+ reconnectionSettings.InitialDelay = ParseInterval(name,
value);
+ break;
+ case "reestablish_after":
+ // Validated only, reconnection_interval paces every
redial.
+ ParseDuration(name, value, IggyDurationParser.ParseParts);
+ break;
+ case "heartbeat_interval":
+ heartbeatInterval = ParseInterval(name, value);
+ break;
+ case "nodelay":
+ // Validated only, the TCP client always disables Nagle.
+ ParseBoolean(name, value);
+ break;
+ default:
+ throw new FormatException($"Unknown option \"{name}\".");
+ }
+ }
+
+ if (tlsSettings.Enabled &&
string.IsNullOrEmpty(tlsSettings.CertificatePath))
+ {
+ throw new FormatException("Option \"tls\" requires
\"tls_ca_file\".");
+ }
+
+ return (tlsSettings, reconnectionSettings, heartbeatInterval);
+ }
+
+ private static (string Name, string Value) SplitOption(string option)
+ {
+ var optionParts = option.Split('=');
+ if (optionParts.Length != 2)
+ {
+ throw Invalid();
+ }
+
+ return (optionParts[0], optionParts[1]);
+ }
+
+ private static void ApplyReconnectionRetries(ReconnectionSettings
settings, string name, string value)
+ {
+ var unlimited = value == "unlimited";
+ uint retries = 0;
+ if (!unlimited && !uint.TryParse(value, NumberStyles.None,
CultureInfo.InvariantCulture, out retries))
+ {
+ throw new FormatException($"Option \"{name}\" must be
\"unlimited\" or an integer up to {uint.MaxValue}.");
+ }
+
+ // MaxRetries treats 0 as unlimited, so zero retries means
reconnection off.
+ settings.Enabled = unlimited || retries != 0;
+ settings.MaxRetries = (int)Math.Min(retries, int.MaxValue);
+ }
+
+ private static T ParseDuration<T>(string name, string value, Func<string,
T> parse)
+ {
+ try
+ {
+ return parse(value);
+ }
+ catch (FormatException e)
+ {
+ throw new FormatException($"Option \"{name}\" has an invalid
duration: {e.Message}.", e);
+ }
+ }
+
+ private static TimeSpan ParseInterval(string name, string value)
+ {
+ var interval = ParseDuration(name, value, IggyDurationParser.Parse);
+ if (interval < IggyClientConfigurator.MinInterval || interval >
IggyClientConfigurator.MaxInterval)
+ {
+ throw new FormatException($"Option \"{name}\" must be between 1
millisecond and about 49 days.");
+ }
+
+ return interval;
+ }
+
+ private static bool ParseBoolean(string name, string value)
+ {
+ return value switch
+ {
+ "true" => true,
+ "false" => false,
+ _ => throw new FormatException($"Option \"{name}\" must be true or
false.")
+ };
+ }
+
+ private static FormatException Invalid()
+ {
+ return new FormatException("Invalid connection string.");
+ }
+}
diff --git a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
index d959886c0..e126c6681 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
@@ -27,6 +27,11 @@ namespace Apache.Iggy.Configuration;
/// </summary>
public sealed class IggyClientConfigurator
{
+ // The bounds Task.Delay and PeriodicTimer accept, which the reconnect
loop and heartbeat run on. Below the
+ // minimum the delay truncates to zero and redials in a hot loop.
+ internal static readonly TimeSpan MinInterval =
TimeSpan.FromMilliseconds(1);
+ internal static readonly TimeSpan MaxInterval =
TimeSpan.FromMilliseconds(uint.MaxValue - 1);
+
/// <summary>
/// The base address of the Iggy server.
/// </summary>
@@ -100,4 +105,37 @@ public sealed class IggyClientConfigurator
/// would silently skip the batch. Does not affect <see
cref="Consumers.IggyConsumer" /> commit modes.
/// </summary>
public bool AllowAutoCommitWithEncryptor { get; set; }
+
+ /// <summary>
+ /// Creates a TCP configuration from a connection string in the format
shared by every Iggy SDK:
+ /// <c>iggy://username:password@host:port?options</c>, or
<c>iggy://token@host:port</c> to sign in with a
+ /// personal access token. The <c>iggy+tcp://</c> scheme is accepted
as well. The credentials become the
+ /// <see cref="AutoLoginSettings" />; the remaining settings can still
be changed on the returned instance, except
+ /// <see cref="HeartbeatInterval" />, which is init-only:
<c>heartbeat_interval</c> in the string is the only
+ /// way to set it.
+ /// </summary>
+ /// <remarks>
+ /// Credentials are taken literally: they are not percent-decoded and
must not contain <c>@</c> or <c>:</c>.
+ /// Supported options: <c>tls</c>, <c>tls_domain</c>,
<c>tls_ca_file</c>, <c>reconnection_retries</c>
+ /// (a count or <c>unlimited</c>), <c>reconnection_interval</c>,
<c>heartbeat_interval</c>,
+ /// <c>reestablish_after</c> and <c>nodelay</c>. Durations look like
<c>500ms</c>, <c>5s</c> or <c>1m30s</c>.
+ /// Reconnection defaults to unlimited retries every second, and after
each reconnect the client still waits
+ /// <see cref="ReconnectionSettings.WaitAfterReconnect" /> (1 second
by default). <c>reestablish_after</c> is
+ /// validated but ignored, since <c>reconnection_interval</c> paces
every redial. <c>nodelay</c> is validated
+ /// but ignored too: sockets are always opened with <c>NoDelay</c>, so
<c>nodelay=false</c> does not turn
+ /// Nagle back on. <c>tls=true</c> requires
+ /// <c>tls_ca_file</c>, and one <c>tls_domain</c> covers every node
the client dials.
+ /// <c>reconnection_retries=0</c> turns reconnection off entirely, so
the client does not reconnect after a
+ /// lost connection either.
+ /// </remarks>
+ /// <param name="connectionString">The connection string.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref
name="connectionString" /> is null.</exception>
+ /// <exception cref="FormatException">
+ /// Thrown when the connection string is malformed, names a transport
other than TCP or holds an unknown or
+ /// invalid option. The message never contains the connection string
itself.
+ /// </exception>
+ public static IggyClientConfigurator FromConnectionString(string
connectionString)
+ {
+ return ConnectionString.Parse(connectionString);
+ }
}
diff --git a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
index 091565ace..5316d7a11 100644
--- a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
+++ b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
@@ -61,6 +61,19 @@ public static class IggyClientFactory
};
}
+ /// <summary>
+ /// Creates a TCP client from a connection string such as
<c>iggy://iggy:[email protected]:8090</c>. See
+ /// <see cref="IggyClientConfigurator.FromConnectionString" /> for the
format and the supported options.
+ /// </summary>
+ /// <param name="connectionString">The connection string.</param>
+ /// <returns>An unconnected <see cref="IIggyClient" /> that signs in with
the connection string credentials.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref
name="connectionString" /> is null.</exception>
+ /// <exception cref="FormatException">Thrown when the connection string is
invalid.</exception>
+ public static IIggyClient CreateClient(string connectionString)
+ {
+ return
CreateClient(IggyClientConfigurator.FromConnectionString(connectionString));
+ }
+
private static void Validate(IggyClientConfigurator options)
{
if (options.Protocol == Protocol.Tcp && options.MaxResponseFrameSize <
VsrHeader.HEADER_SIZE)
@@ -81,10 +94,10 @@ public static class IggyClientFactory
options.SendBufferSize, "SendBufferSize must be greater than 0
when set.");
}
- // The bounds PeriodicTimer accepts; anything outside them would fault
the heartbeat task at start
- // instead of failing the caller here.
- if (options.HeartbeatInterval < TimeSpan.FromMilliseconds(1) ||
- options.HeartbeatInterval >
TimeSpan.FromMilliseconds(uint.MaxValue - 1))
+ // Outside these bounds PeriodicTimer would fault the heartbeat task
at start instead of failing the
+ // caller here.
+ if (options.HeartbeatInterval < IggyClientConfigurator.MinInterval ||
+ options.HeartbeatInterval > IggyClientConfigurator.MaxInterval)
{
throw new ArgumentOutOfRangeException(nameof(options),
options.HeartbeatInterval,
"HeartbeatInterval must be between 1 millisecond and about 49
days.");
diff --git a/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs
b/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs
new file mode 100644
index 000000000..3933803cd
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs
@@ -0,0 +1,394 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Text;
+
+namespace Apache.Iggy.Utils;
+
+/// <summary>
+/// Parses humantime durations such as <c>5s</c> or <c>1h 30m</c>. The
input is lowercased and the zero spellings
+/// are mapped first. The grammar keeps u64 overflow checks and exact
fraction division, so a value that loses
+/// precision is rejected, not rounded.
+/// </summary>
+internal static class IggyDurationParser
+{
+ private const ulong NanosPerSecond = 1_000_000_000;
+
+ private const string OverflowMessage =
+ "number is too large or cannot be represented without a lack of
precision (values below 1ns are not supported)";
+
+ private static readonly Dictionary<string, Unit> Units = new()
+ {
+ ["nanos"] = Unit.Nanosecond,
+ ["nsec"] = Unit.Nanosecond,
+ ["ns"] = Unit.Nanosecond,
+ ["usec"] = Unit.Microsecond,
+ ["us"] = Unit.Microsecond,
+ ["µs"] = Unit.Microsecond,
+ ["millis"] = Unit.Millisecond,
+ ["msec"] = Unit.Millisecond,
+ ["ms"] = Unit.Millisecond,
+ ["seconds"] = Unit.Second,
+ ["second"] = Unit.Second,
+ ["secs"] = Unit.Second,
+ ["sec"] = Unit.Second,
+ ["s"] = Unit.Second,
+ ["minutes"] = Unit.Minute,
+ ["minute"] = Unit.Minute,
+ ["min"] = Unit.Minute,
+ ["mins"] = Unit.Minute,
+ ["m"] = Unit.Minute,
+ ["hours"] = Unit.Hour,
+ ["hour"] = Unit.Hour,
+ ["hr"] = Unit.Hour,
+ ["hrs"] = Unit.Hour,
+ ["h"] = Unit.Hour,
+ ["days"] = Unit.Day,
+ ["day"] = Unit.Day,
+ ["d"] = Unit.Day,
+ ["weeks"] = Unit.Week,
+ ["week"] = Unit.Week,
+ ["wk"] = Unit.Week,
+ ["wks"] = Unit.Week,
+ ["w"] = Unit.Week,
+ ["months"] = Unit.Month,
+ ["month"] = Unit.Month,
+ ["years"] = Unit.Year,
+ ["year"] = Unit.Year,
+ ["yr"] = Unit.Year,
+ ["yrs"] = Unit.Year,
+ ["y"] = Unit.Year
+ };
+
+ private enum Unit
+ {
+ Nanosecond,
+ Microsecond,
+ Millisecond,
+ Second,
+ Minute,
+ Hour,
+ Day,
+ Week,
+ Month,
+ Year
+ }
+
+ /// <summary>
+ /// Parses a duration such as <c>5s</c>, <c>1h 30m</c> or
<c>unlimited</c> (zero).
+ /// </summary>
+ /// <exception cref="FormatException">
+ /// Thrown when the value does not parse or does not fit in a <see
cref="TimeSpan" />.
+ /// </exception>
+ internal static TimeSpan Parse(string value)
+ {
+ var (seconds, nanoseconds) = ParseParts(value);
+ try
+ {
+ // TimeSpan counts 100 ns ticks, so a sub-tick remainder is
dropped.
+ return TimeSpan.FromTicks(checked((long)seconds *
TimeSpan.TicksPerSecond + nanoseconds / 100));
+ }
+ catch (OverflowException)
+ {
+ throw new FormatException(OverflowMessage);
+ }
+ }
+
+ /// <summary>
+ /// Parses like <see cref="Parse" />, but without the <see
cref="TimeSpan" /> range limit.
+ /// </summary>
+ /// <exception cref="FormatException">Thrown when the value does not
parse.</exception>
+ internal static (ulong Seconds, uint Nanoseconds) ParseParts(string value)
+ {
+ var lowered = value.ToLowerInvariant();
+ return lowered is "0" or "unlimited" or "disabled" or "none" ? (0, 0)
: ParseHumantime(lowered);
+ }
+
+ /// <summary>
+ /// Parses a lowercase humantime duration into whole seconds plus the
subsecond nanoseconds.
+ /// </summary>
+ /// <exception cref="FormatException">Thrown when the value does not
parse.</exception>
+ internal static (ulong Seconds, uint Nanoseconds) ParseHumantime(string
input)
+ {
+ try
+ {
+ return new Parser(input).Parse();
+ }
+ catch (OverflowException)
+ {
+ throw new FormatException(OverflowMessage);
+ }
+ }
+
+ private static ulong Mul(ulong left, ulong right)
+ {
+ return checked(left * right);
+ }
+
+ private static ulong Add(ulong left, ulong right)
+ {
+ return checked(left + right);
+ }
+
+ // A division that is not exact would lose precision, which humantime
reports as an overflow.
+ private static ulong Div(ulong dividend, ulong divisor)
+ {
+ if (dividend % divisor != 0)
+ {
+ throw new FormatException(OverflowMessage);
+ }
+
+ return dividend / divisor;
+ }
+
+ private readonly record struct Fraction(ulong Numerator, ulong
Denominator);
+
+ /// <summary>
+ /// Walks the input by char. Every accepted character, including
Unicode whitespace and <c>µ</c>, is a single
+ /// UTF-16 char, so a surrogate fails at its first half. Error offsets
are UTF-8 byte offsets.
+ /// </summary>
+ private sealed class Parser(string source)
+ {
+ private int _index;
+ private ulong _nanoseconds;
+ private ulong _seconds;
+
+ internal (ulong Seconds, uint Nanoseconds) Parse()
+ {
+ var number = ParseFirstChar() ?? throw new FormatException("value
was empty");
+ while (true)
+ {
+ Fraction? fraction = null;
+ var offset = _index;
+ while (Next() is { } c)
+ {
+ if (IsDigit(c))
+ {
+ number = Add(Mul(number, 10), Digit(c));
+ }
+ else if (char.IsWhiteSpace(c))
+ {
+ }
+ else if (IsUnitChar(c))
+ {
+ break;
+ }
+ else if (c == '.')
+ {
+ fraction = ParseFractionalPart(ref offset);
+ break;
+ }
+ else
+ {
+ throw InvalidCharacter(offset);
+ }
+
+ offset = _index;
+ }
+
+ var start = offset;
+ offset = _index;
+ var nextNumber = false;
+ while (Next() is { } c)
+ {
+ if (IsDigit(c))
+ {
+ AddUnit(number, fraction, start, offset);
+ number = Digit(c);
+ nextNumber = true;
+ break;
+ }
+
+ if (char.IsWhiteSpace(c))
+ {
+ break;
+ }
+
+ if (!IsUnitChar(c))
+ {
+ throw InvalidCharacter(offset);
+ }
+
+ offset = _index;
+ }
+
+ if (nextNumber)
+ {
+ continue;
+ }
+
+ AddUnit(number, fraction, start, offset);
+ if (ParseFirstChar() is not { } next)
+ {
+ return (_seconds, (uint)_nanoseconds);
+ }
+
+ number = next;
+ }
+ }
+
+ private ulong? ParseFirstChar()
+ {
+ var offset = _index;
+ while (Next() is { } c)
+ {
+ if (IsDigit(c))
+ {
+ return Digit(c);
+ }
+
+ if (!char.IsWhiteSpace(c))
+ {
+ throw new FormatException($"expected number at
{ByteOffset(offset)}");
+ }
+ }
+
+ return null;
+ }
+
+ private Fraction ParseFractionalPart(ref int offset)
+ {
+ ulong numerator = 0;
+ ulong denominator = 1;
+ while (Next() is { } c)
+ {
+ if (c == '0')
+ {
+ denominator = Mul(denominator, 10);
+ numerator = Mul(numerator, 10);
+ }
+ else if (IsDigit(c))
+ {
+ denominator = Mul(denominator, 10);
+ numerator = Add(Mul(numerator, 10), Digit(c));
+ }
+ else if (char.IsWhiteSpace(c))
+ {
+ }
+ else if (IsUnitChar(c))
+ {
+ break;
+ }
+ else
+ {
+ throw InvalidCharacter(offset);
+ }
+
+ offset = _index;
+ }
+
+ // No digits after the separator, e.g. "1.".
+ if (denominator == 1)
+ {
+ throw InvalidCharacter(offset);
+ }
+
+ return new Fraction(numerator, denominator);
+ }
+
+ private void AddUnit(ulong number, Fraction? fraction, int start, int
end)
+ {
+ var unitText = source[start..end];
+ if (!Units.TryGetValue(unitText, out var unit))
+ {
+ throw new FormatException(unitText.Length == 0
+ ? $"time unit needed, for example {number}sec or
{number}ms"
+ : $"unknown time unit \"{unitText}\", supported units: ns,
us/µs, ms, sec, min, hours, days, "
+ + "weeks, months, years (and few variations)");
+ }
+
+ var (seconds, nanoseconds) = unit switch
+ {
+ Unit.Nanosecond => (0UL, number),
+ Unit.Microsecond => (0UL, Mul(number, 1000)),
+ Unit.Millisecond => (0UL, Mul(number, 1_000_000)),
+ Unit.Second => (number, 0UL),
+ Unit.Minute => (Mul(number, 60), 0UL),
+ Unit.Hour => (Mul(number, 3600), 0UL),
+ Unit.Day => (Mul(number, 86_400), 0UL),
+ Unit.Week => (Mul(number, 86_400 * 7), 0UL),
+ Unit.Month => (Mul(number, 2_630_016), 0UL),
+ _ => (Mul(number, 31_557_600), 0UL)
+ };
+ AddCurrent(seconds, nanoseconds);
+
+ if (fraction is not { } part)
+ {
+ return;
+ }
+
+ var (numerator, denominator) = (part.Numerator, part.Denominator);
+ (seconds, nanoseconds) = unit switch
+ {
+ Unit.Nanosecond => throw new FormatException(OverflowMessage),
+ Unit.Microsecond => (0UL, Div(Mul(numerator, 1000),
denominator)),
+ Unit.Millisecond => (0UL, Div(Mul(numerator, 1_000_000),
denominator)),
+ Unit.Second => (0UL, Div(Mul(numerator, NanosPerSecond),
denominator)),
+ Unit.Minute => (0UL, Div(Mul(numerator, 60 * NanosPerSecond),
denominator)),
+ Unit.Hour => (Div(Mul(numerator, 3600), denominator), 0UL),
+ Unit.Day => (Div(Mul(numerator, 86_400), denominator), 0UL),
+ Unit.Week => (Div(Mul(numerator, 86_400 * 7), denominator),
0UL),
+ Unit.Month => (Div(Mul(numerator, 2_630_016), denominator),
0UL),
+ _ => (Div(Mul(numerator, 31_557_600), denominator), 0UL)
+ };
+ AddCurrent(seconds, nanoseconds);
+ }
+
+ private void AddCurrent(ulong seconds, ulong nanoseconds)
+ {
+ nanoseconds = Add(_nanoseconds, nanoseconds);
+ if (nanoseconds >= NanosPerSecond)
+ {
+ seconds = Add(seconds, nanoseconds / NanosPerSecond);
+ nanoseconds %= NanosPerSecond;
+ }
+
+ _seconds = Add(_seconds, seconds);
+ _nanoseconds = nanoseconds;
+ }
+
+ private char? Next()
+ {
+ return _index < source.Length ? source[_index++] : null;
+ }
+
+ private FormatException InvalidCharacter(int offset)
+ {
+ return new FormatException($"invalid character at
{ByteOffset(offset)}");
+ }
+
+ private int ByteOffset(int offset)
+ {
+ return Encoding.UTF8.GetByteCount(source.AsSpan(0, offset));
+ }
+
+ private static bool IsDigit(char c)
+ {
+ return c is >= '0' and <= '9';
+ }
+
+ private static ulong Digit(char c)
+ {
+ return (ulong)(c - '0');
+ }
+
+ private static bool IsUnitChar(char c)
+ {
+ return c is >= 'a' and <= 'z' or 'µ';
+ }
+ }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs
b/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs
new file mode 100644
index 000000000..46947ef01
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs
@@ -0,0 +1,441 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Factory;
+
+namespace Apache.Iggy.Tests.ClientTests;
+
+public sealed class ConnectionStringTests
+{
+ [Theory]
+ [InlineData("iggy://user:[email protected]:1234")]
+ [InlineData("iggy+tcp://user:[email protected]:1234")]
+ public void FromConnectionString_WithoutOptions_AppliesDefaults(string
connectionString)
+ {
+ var config =
IggyClientConfigurator.FromConnectionString(connectionString);
+
+ Assert.Equal("127.0.0.1:1234", config.BaseAddress);
+ Assert.Equal(Protocol.Tcp, config.Protocol);
+ Assert.True(config.AutoLoginSettings.Enabled);
+ Assert.Equal("user", config.AutoLoginSettings.Username);
+ Assert.Equal("secret", config.AutoLoginSettings.Password);
+ Assert.Empty(config.AutoLoginSettings.PersonalAccessToken);
+
+ Assert.False(config.TlsSettings.Enabled);
+ Assert.Equal("127.0.0.1", config.TlsSettings.Hostname);
+ Assert.Empty(config.TlsSettings.CertificatePath);
+ Assert.Equal(TimeSpan.FromSeconds(5), config.HeartbeatInterval);
+
+ Assert.True(config.ReconnectionSettings.Enabled);
+ Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(1),
config.ReconnectionSettings.InitialDelay);
+ Assert.False(config.ReconnectionSettings.UseExponentialBackoff);
+ }
+
+ [Theory]
+ [InlineData("iggy://[email protected]:1234")]
+ [InlineData("iggy+tcp://iggypat-1234567890abcdef@localhost:1234")]
+ public void
FromConnectionString_WithPersonalAccessToken_SignsInWithToken(string
connectionString)
+ {
+ var config =
IggyClientConfigurator.FromConnectionString(connectionString);
+
+ Assert.True(config.AutoLoginSettings.Enabled);
+ Assert.Equal("iggypat-1234567890abcdef",
config.AutoLoginSettings.PersonalAccessToken);
+ Assert.Empty(config.AutoLoginSettings.Username);
+ Assert.Empty(config.AutoLoginSettings.Password);
+ Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(5), config.HeartbeatInterval);
+ }
+
+ [Theory]
+ [InlineData("reconnection_retries=3&heartbeat_interval=10s", 3)]
+ [InlineData("heartbeat_interval=10s&reconnection_retries=10", 10)]
+ public void FromConnectionString_WithRetriesAndHeartbeat_MapsBoth(string
options, int retries)
+ {
+ var config =
IggyClientConfigurator.FromConnectionString($"iggy+tcp://user:[email protected]:1234?{options}");
+
+ Assert.Equal("127.0.0.1:1234", config.BaseAddress);
+ Assert.Equal("user", config.AutoLoginSettings.Username);
+ Assert.Equal("secret", config.AutoLoginSettings.Password);
+ Assert.False(config.TlsSettings.Enabled);
+ Assert.Equal(TimeSpan.FromSeconds(10), config.HeartbeatInterval);
+ Assert.True(config.ReconnectionSettings.Enabled);
+ Assert.Equal(retries, config.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(1),
config.ReconnectionSettings.InitialDelay);
+ }
+
+ [Fact]
+ public void
FromConnectionString_WithReconnectionOptions_MapsIntervalAndRetries()
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
"iggy+tcp://iggy:secret@localhost:8090?reconnection_retries=3&reconnection_interval=5s&heartbeat_interval=10s");
+
+ Assert.True(config.ReconnectionSettings.Enabled);
+ Assert.Equal(3, config.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(5),
config.ReconnectionSettings.InitialDelay);
+ Assert.False(config.ReconnectionSettings.UseExponentialBackoff);
+ Assert.Equal(TimeSpan.FromSeconds(10), config.HeartbeatInterval);
+ }
+
+ [Fact]
+ public void
FromConnectionString_WithPartialReconnectionOptions_KeepsTheOtherDefault()
+ {
+ var retriesOnly = IggyClientConfigurator.FromConnectionString(
+ "iggy://iggy:secret@localhost:8090?reconnection_retries=3");
+ var intervalOnly = IggyClientConfigurator.FromConnectionString(
+ "iggy://iggy:secret@localhost:8090?reconnection_interval=5s");
+
+ Assert.Equal(3, retriesOnly.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(1),
retriesOnly.ReconnectionSettings.InitialDelay);
+ Assert.Equal(0, intervalOnly.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(5),
intervalOnly.ReconnectionSettings.InitialDelay);
+ }
+
+ [Fact]
+ public void FromConnectionString_WithTlsOptions_MapsTlsSettings()
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
"iggy://iggy:secret@localhost:8090?tls=true&tls_domain=iggy.apache.org&tls_ca_file=/does/not/exist.pem");
+
+ Assert.True(config.TlsSettings.Enabled);
+ Assert.Equal("iggy.apache.org", config.TlsSettings.Hostname);
+ // Stored as a path: the file is read when the TLS stream is created,
not at parse time.
+ Assert.Equal("/does/not/exist.pem",
config.TlsSettings.CertificatePath);
+ Assert.Equal("localhost:8090", config.BaseAddress);
+ }
+
+ [Theory]
+ [InlineData("tls=false")]
+ [InlineData("nodelay=true")]
+ [InlineData("nodelay=false")]
+ public void FromConnectionString_WithBooleanOptions_Accepts(string options)
+ {
+ var config =
IggyClientConfigurator.FromConnectionString($"iggy://iggy:secret@localhost:8090?{options}");
+
+ Assert.False(config.TlsSettings.Enabled);
+ }
+
+ [Fact]
+ public void
FromConnectionString_WithUnlimitedRetries_KeepsRetriesUnlimited()
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
"iggy://iggy:secret@localhost:8090?reconnection_retries=unlimited");
+
+ Assert.True(config.ReconnectionSettings.Enabled);
+ Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+ }
+
+ [Fact]
+ public void FromConnectionString_WithZeroRetries_DisablesReconnection()
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+ "iggy://iggy:secret@localhost:8090?reconnection_retries=0");
+
+ Assert.False(config.ReconnectionSettings.Enabled);
+ }
+
+ [Theory]
+ [InlineData("0", "5", true, 5)]
+ [InlineData("3", "unlimited", true, 0)]
+ [InlineData("3", "0", false, 0)]
+ public void
FromConnectionString_WithRepeatedRetries_UsesTheLastValue(string first, string
last, bool enabled,
+ int maxRetries)
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
$"iggy://iggy:secret@localhost:8090?reconnection_retries={first}&reconnection_retries={last}");
+
+ Assert.Equal(enabled, config.ReconnectionSettings.Enabled);
+ Assert.Equal(maxRetries, config.ReconnectionSettings.MaxRetries);
+ }
+
+ [Theory]
+ [InlineData("2147483647")]
+ [InlineData("2147483648")]
+ [InlineData("4294967295")]
+ public void
FromConnectionString_WithRetriesAboveInt32Max_CapsAtInt32Max(string retries)
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
$"iggy://iggy:secret@localhost:8090?reconnection_retries={retries}");
+
+ Assert.True(config.ReconnectionSettings.Enabled);
+ Assert.Equal(int.MaxValue, config.ReconnectionSettings.MaxRetries);
+ }
+
+ [Theory]
+ [InlineData("4294967296")]
+ [InlineData("99999999999999")]
+ [InlineData("three")]
+ [InlineData("-1")]
+ [InlineData("+3")]
+ [InlineData("")]
+ public void FromConnectionString_WithInvalidRetries_Throws(string retries)
+ {
+ Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(
+
$"iggy://iggy:secret@localhost:8090?reconnection_retries={retries}"));
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("0ms")]
+ [InlineData("500us")]
+ [InlineData("none")]
+ [InlineData("50d")]
+ public void
FromConnectionString_WithReconnectionIntervalOutOfRange_Throws(string interval)
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(
+
$"iggy://iggy:secret@localhost:8090?reconnection_interval={interval}"));
+
+ Assert.Contains("must be between 1 millisecond", exception.Message);
+ }
+
+ [Fact]
+ public void
FromConnectionString_WithReconnectionIntervalAtTimerCeiling_Accepts()
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+ "iggy://iggy:secret@localhost:8090?reconnection_interval=49d");
+
+ Assert.Equal(TimeSpan.FromDays(49),
config.ReconnectionSettings.InitialDelay);
+ }
+
+ [Fact]
+ public void FromConnectionString_WithNegativeReconnectionInterval_Throws()
+ {
+ Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(
+ "iggy://iggy:secret@localhost:8090?reconnection_interval=-1s"));
+ }
+
+ [Theory]
+ [InlineData("none")]
+ [InlineData("0")]
+ [InlineData("0ms")]
+ [InlineData("500us")]
+ [InlineData("50d")]
+ public void
FromConnectionString_WithHeartbeatIntervalOutOfRange_Throws(string interval)
+ {
+ Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(
+
$"iggy+tcp://user:[email protected]:1234?heartbeat_interval={interval}"));
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("10s")]
+ [InlineData("100000y")]
+ public void
FromConnectionString_WithReestablishAfter_ValidatesAndIgnoresIt(string value)
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
$"iggy+tcp://user:[email protected]:1234?reestablish_after={value}");
+
+ Assert.True(config.ReconnectionSettings.Enabled);
+ Assert.Equal(0, config.ReconnectionSettings.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(1),
config.ReconnectionSettings.InitialDelay);
+ Assert.Equal(TimeSpan.FromSeconds(1),
config.ReconnectionSettings.WaitAfterReconnect);
+ }
+
+ [Theory]
+ [InlineData("reconnection_interval=1m30s", 90_000)]
+ [InlineData("reconnection_interval=250ms", 250)]
+ [InlineData("reconnection_interval=1.5s", 1500)]
+ public void FromConnectionString_WithDurationSyntax_ParsesInterval(string
options, int milliseconds)
+ {
+ var config =
IggyClientConfigurator.FromConnectionString($"iggy://iggy:secret@localhost:8090?{options}");
+
+ Assert.Equal(TimeSpan.FromMilliseconds(milliseconds),
config.ReconnectionSettings.InitialDelay);
+ }
+
+ [Fact]
+ public void FromConnectionString_WithTlsAndNoDomain_UsesTheServerHost()
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+ "iggy://iggy:secret@[::1]:8090?tls=true&tls_ca_file=/ca.pem");
+
+ Assert.Equal("::1", config.TlsSettings.Hostname);
+ }
+
+ [Theory]
+ [InlineData("tls_domain=")]
+ [InlineData("tls_domain=iggy.apache.org&tls_domain=")]
+ public void
FromConnectionString_WithEmptyTlsDomain_UsesTheServerHost(string options)
+ {
+ var config = IggyClientConfigurator.FromConnectionString(
+
$"iggy://iggy:secret@localhost:8090?tls=true&tls_ca_file=/ca.pem&{options}");
+
+ Assert.Equal("localhost", config.TlsSettings.Hostname);
+ }
+
+ [Fact]
+ public void FromConnectionString_WithTlsAndNoCaFile_Throws()
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(
+
"iggy://iggy:hunter2@localhost:8090?tls=true&tls_domain=iggy.apache.org"));
+
+ Assert.Contains("tls_ca_file", exception.Message);
+ }
+
+ [Fact]
+ public void
FromConnectionString_WithSchemeTailHoldingCredentials_DoesNotEchoThem()
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(
+ "iggy+user:pw@host:8090?x=a://b"));
+
+ Assert.DoesNotContain("pw", exception.Message);
+ }
+
+ [Fact]
+ public void
FromConnectionString_WithBracketedIpv6_KeepsTheBracketsForTheDialer()
+ {
+ var config =
IggyClientConfigurator.FromConnectionString("iggy://iggy:secret@[::1]:8090");
+
+ Assert.Equal("[::1]:8090", config.BaseAddress);
+ }
+
+ [Theory]
+ [InlineData("iggy+quic://iggy:secret@localhost:8090")]
+ [InlineData("iggy+ws://iggy:secret@localhost:8090")]
+ [InlineData("iggy+http://iggy:secret@localhost:3000")]
+ [InlineData("iggy+http://iggypat-1234567890abcdef@localhost:3000")]
+ public void FromConnectionString_WithUnsupportedTransport_Throws(string
connectionString)
+ {
+ var exception = Assert.Throws<FormatException>(
+ () =>
IggyClientConfigurator.FromConnectionString(connectionString));
+
+ Assert.Contains("Unsupported transport", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("IGGY://iggy:secret@localhost:8090")]
+ [InlineData("iggy+TCP://iggy:secret@localhost:8090")]
+ [InlineData("Iggy+Tcp://iggy:secret@localhost:8090")]
+ public void
FromConnectionString_WithUppercaseScheme_ThrowsCaseSensitiveError(string
connectionString)
+ {
+ var exception = Assert.Throws<FormatException>(
+ () =>
IggyClientConfigurator.FromConnectionString(connectionString));
+
+ Assert.Contains("case-sensitive", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("iggy")]
+ [InlineData("iggy://")]
+ [InlineData("invalid+tcp://user:[email protected]:1234")]
+ [InlineData("tcp://user:[email protected]:1234")]
+ [InlineData("iggy://:[email protected]:1234")]
+ [InlineData("iggy+tcp://:[email protected]:1234")]
+ [InlineData("iggy://user:@127.0.0.1:1234")]
+ [InlineData("iggy+tcp://user:@127.0.0.1:1234")]
+ [InlineData("iggy://@127.0.0.1:1234")]
+ [InlineData("iggy://user:secret:[email protected]:1234")]
+ [InlineData("iggy://user:[email protected]:1234@other")]
+ [InlineData("iggy://user:secret@:1234")]
+ [InlineData("iggy+tcp://user:secret@:1234")]
+ [InlineData("iggy://user:[email protected]:")]
+ [InlineData("iggy+tcp://user:[email protected]:")]
+ [InlineData("iggy://user:[email protected]")]
+ [InlineData("iggy://user:secret@localhost:port")]
+ [InlineData("iggy://user:secret@localhost:70000")]
+ [InlineData("iggy://user:secret@localhost:-1")]
+ [InlineData("iggy://user:secret@localhost:+80")]
+ [InlineData("iggy://user:secret@localhost:8090\n")]
+ [InlineData("iggy://user:secret@local host:8090")]
+ [InlineData("iggy://user:secret@local\nhost:8090")]
+ [InlineData("iggy://user:secret@[::1 ]:8090")]
+ [InlineData("iggy://user:secret@[::1:8090")]
+ [InlineData("iggy://user:secret@[]:8090")]
+ [InlineData("iggy://user:secret@[::1]x:8090")]
+ [InlineData("iggy://user:secret@2001:db8::1:8090")]
+ [InlineData("iggy://user:secret@host:8090:9090")]
+ [InlineData("iggy://user:[email protected]:1234?invalid_option=invalid")]
+ [InlineData("iggy+tcp://user:[email protected]:?invalid_option=invalid")]
+ [InlineData("iggy://user:secret@localhost:8090?unknown=value")]
+ [InlineData("iggy://user:secret@localhost:8090?tls=maybe")]
+ [InlineData("iggy://user:secret@localhost:8090?tls=TRUE")]
+ [InlineData("iggy://user:secret@localhost:8090?nodelay=1")]
+ [InlineData("iggy://user:secret@localhost:8090?")]
+ [InlineData("iggy://user:secret@localhost:8090?&")]
+ [InlineData("iggy://user:secret@localhost:8090?tls")]
+ [InlineData("iggy://user:secret@localhost:8090?tls=true=false")]
+ [InlineData("iggy://user:secret@localhost:8090?tls=true?nodelay=true")]
+ [InlineData("iggy://user:secret@localhost:8090?reestablish_after=garbage")]
+ [InlineData("iggy://user:secret@localhost:8090?heartbeat_interval=5")]
+ [InlineData("iggy://iggy://user:secret@localhost:8090")]
+ public void FromConnectionString_WithMalformedValue_Throws(string
connectionString)
+ {
+ Assert.Throws<FormatException>(() =>
IggyClientConfigurator.FromConnectionString(connectionString));
+ }
+
+ [Fact]
+ public void FromConnectionString_WithNull_Throws()
+ {
+ Assert.Throws<ArgumentNullException>(() =>
IggyClientConfigurator.FromConnectionString(null!));
+ }
+
+ [Theory]
+ [InlineData("iggy://iggy:hunter2@localhost")]
+ [InlineData("iggy+tcp://iggypat-1234567890abcdef@localhost")]
+ [InlineData("iggy://iggy:hunter2@localhost:8090?unknown=value")]
+ [InlineData("iggy://iggy:hunter2@localhost:8090?tls=maybe")]
+
[InlineData("iggy://iggy:hunter2@localhost:8090?reconnection_retries=three")]
+ [InlineData("iggy://iggy:hunter2@localhost:8090?reconnection_interval=0")]
+
[InlineData("iggy://iggy:hunter2@localhost:8090?heartbeat_interval=garbage")]
+ [InlineData("iggy://iggy:hunter2@localhost:70000")]
+ [InlineData("iggy+quic://iggy:hunter2@localhost:8090")]
+ public void
FromConnectionString_ErrorMessages_NeverContainCredentials(string
connectionString)
+ {
+ var exception = Assert.Throws<FormatException>(
+ () =>
IggyClientConfigurator.FromConnectionString(connectionString));
+
+ Assert.DoesNotContain("hunter2", exception.Message);
+ Assert.DoesNotContain("iggypat-1234567890abcdef", exception.Message);
+ Assert.DoesNotContain("hunter2", exception.InnerException?.Message ??
string.Empty);
+ }
+
+ [Fact]
+ public void
FromConnectionString_ReturnsAConfiguratorTheCallerCanStillAdjust()
+ {
+ var config =
IggyClientConfigurator.FromConnectionString("iggy://iggy:secret@localhost:8090");
+ config.ReceiveBufferSize = 4096;
+
+ Assert.Equal(4096, config.ReceiveBufferSize);
+ Assert.Equal(64 * 1024 * 1024, config.MaxResponseFrameSize);
+ Assert.Null(config.MessageEncryptor);
+ }
+
+ [Fact]
+ public void CreateClient_FromConnectionString_CreatesTcpClient()
+ {
+ using var client =
IggyClientFactory.CreateClient("iggy://iggy:[email protected]:8090") as
IDisposable;
+
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void CreateClient_FromMalformedConnectionString_Throws()
+ {
+ Assert.Throws<FormatException>(() =>
IggyClientFactory.CreateClient("iggy://iggy:[email protected]"));
+ }
+
+ [Theory]
+ [InlineData("500us")]
+ [InlineData("50d")]
+ public void
CreateClient_FromConnectionString_ValidatesHeartbeatRange(string interval)
+ {
+ Assert.Throws<FormatException>(() => IggyClientFactory.CreateClient(
+
$"iggy://iggy:[email protected]:8090?heartbeat_interval={interval}"));
+ }
+}
diff --git
a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IggyDurationParserTests.cs
b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IggyDurationParserTests.cs
new file mode 100644
index 000000000..1b25169dd
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IggyDurationParserTests.cs
@@ -0,0 +1,227 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Tests.UtilityTests;
+
+public sealed class IggyDurationParserTests
+{
+ private const string OverflowMessage =
+ "number is too large or cannot be represented without a lack of
precision (values below 1ns are not supported)";
+
+ private const string SupportedUnits =
+ "supported units: ns, us/µs, ms, sec, min, hours, days, weeks, months,
years (and few variations)";
+
+ [Theory]
+ [InlineData("17nsec", 0UL, 17U)]
+ [InlineData("17nanos", 0UL, 17U)]
+ [InlineData("33ns", 0UL, 33U)]
+ [InlineData("3usec", 0UL, 3000U)]
+ [InlineData("78us", 0UL, 78_000U)]
+ [InlineData("163µs", 0UL, 163_000U)]
+ [InlineData("31msec", 0UL, 31_000_000U)]
+ [InlineData("31millis", 0UL, 31_000_000U)]
+ [InlineData("6ms", 0UL, 6_000_000U)]
+ [InlineData("3000s", 3000UL, 0U)]
+ [InlineData("300secs", 300UL, 0U)]
+ [InlineData("50seconds", 50UL, 0U)]
+ [InlineData("1second", 1UL, 0U)]
+ [InlineData("100m", 6000UL, 0U)]
+ [InlineData("12mins", 720UL, 0U)]
+ [InlineData("1min", 60UL, 0U)]
+ [InlineData("7minutes", 420UL, 0U)]
+ [InlineData("1minute", 60UL, 0U)]
+ [InlineData("2h", 7200UL, 0U)]
+ [InlineData("1hr", 3600UL, 0U)]
+ [InlineData("7hrs", 25_200UL, 0U)]
+ [InlineData("1hour", 3600UL, 0U)]
+ [InlineData("24hours", 86_400UL, 0U)]
+ [InlineData("1day", 86_400UL, 0U)]
+ [InlineData("2days", 172_800UL, 0U)]
+ [InlineData("365d", 31_536_000UL, 0U)]
+ [InlineData("1week", 604_800UL, 0U)]
+ [InlineData("7weeks", 4_233_600UL, 0U)]
+ [InlineData("1wk", 604_800UL, 0U)]
+ [InlineData("104wks", 62_899_200UL, 0U)]
+ [InlineData("52w", 31_449_600UL, 0U)]
+ [InlineData("1month", 2_630_016UL, 0U)]
+ [InlineData("3months", 3 * 2_630_016UL, 0U)]
+ [InlineData("1year", 31_557_600UL, 0U)]
+ [InlineData("15yrs", 15 * 31_557_600UL, 0U)]
+ [InlineData("10yr", 10 * 31_557_600UL, 0U)]
+ [InlineData("17y", 536_479_200UL, 0U)]
+ public void ParseHumantime_ParsesEveryUnitSpelling(string input, ulong
seconds, uint nanoseconds)
+ {
+ Assert.Equal((seconds, nanoseconds),
IggyDurationParser.ParseHumantime(input));
+ }
+
+ [Theory]
+ [InlineData("2h 37min", 9420UL, 0U)]
+ [InlineData("2h 15m", 8100UL, 0U)]
+ [InlineData("1h 1m 1s", 3661UL, 0U)]
+ [InlineData("1h30m", 5400UL, 0U)]
+ [InlineData("20 min 17 nsec ", 1200UL, 17U)]
+ [InlineData(" 5s", 5UL, 0U)]
+ [InlineData("1.234s0.345ms0.678us0ns", 1UL, 234_345_678U)]
+ [InlineData("1.234s 1.345ms 1.678us 1ns", 1UL, 235_346_679U)]
+ [InlineData("999999999ns 1ns", 1UL, 0U)]
+ public void ParseHumantime_CombinesTokensWithAndWithoutWhitespace(string
input, ulong seconds,
+ uint nanoseconds)
+ {
+ Assert.Equal((seconds, nanoseconds),
IggyDurationParser.ParseHumantime(input));
+ }
+
+ [Theory]
+ [InlineData("4.2s", 4UL, 200_000_000U)]
+ [InlineData("1.5minute", 90UL, 0U)]
+ [InlineData("0.01m", 0UL, 600_000_000U)]
+ [InlineData("0.5h", 1800UL, 0U)]
+ [InlineData("1.123456789s", 1UL, 123_456_789U)]
+ [InlineData("31.000001ms", 0UL, 31_000_001U)]
+ [InlineData("1.5us", 0UL, 1500U)]
+ public void ParseHumantime_SupportsExactFractions(string input, ulong
seconds, uint nanoseconds)
+ {
+ Assert.Equal((seconds, nanoseconds),
IggyDurationParser.ParseHumantime(input));
+ }
+
+ [Theory]
+ [InlineData("0.000123456789s")]
+ [InlineData("31.0000001ms")]
+ [InlineData("1.0000000002s")]
+ [InlineData("0.0000000002s")]
+ [InlineData("1.5ns")]
+ public void ParseHumantime_RejectsPrecisionLossAsOverflow(string input)
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyDurationParser.ParseHumantime(input));
+
+ Assert.Equal(OverflowMessage, exception.Message);
+ }
+
+ [Theory]
+ [InlineData("100000000000000000000ns")]
+ [InlineData("100000000000000ms")]
+ [InlineData("10000000000000000000m")]
+ [InlineData("100000000000000000d")]
+ [InlineData("10000000000000y")]
+ public void ParseHumantime_ReportsU64Overflow(string input)
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyDurationParser.ParseHumantime(input));
+
+ Assert.Equal(OverflowMessage, exception.Message);
+ }
+
+ [Theory]
+ [InlineData("1.s", "invalid character at 1")]
+ [InlineData("1..s", "invalid character at 1")]
+ [InlineData(".1s", "expected number at 0")]
+ [InlineData(".", "expected number at 0")]
+ public void ParseHumantime_RejectsMalformedFractions(string input, string
message)
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyDurationParser.ParseHumantime(input));
+
+ Assert.Equal(message, exception.Message);
+ }
+
+ [Theory]
+ [InlineData("123", "time unit needed, for example 123sec or 123ms")]
+ [InlineData("10 months 1", "time unit needed, for example 1sec or 1ms")]
+ [InlineData("10nights", "unknown time unit \"nights\", " + SupportedUnits)]
+ [InlineData("222nsec221nanosmsec7s5msec572s", "unknown time unit
\"nanosmsec\", " + SupportedUnits)]
+ [InlineData("\0", "expected number at 0")]
+ [InlineData("\r", "value was empty")]
+ [InlineData("", "value was empty")]
+ [InlineData("1~", "invalid character at 1")]
+ [InlineData("1nå", "invalid character at 2")]
+ [InlineData("1N", "invalid character at 1")]
+ [InlineData("1µå", "invalid character at 3")]
+ public void ParseHumantime_ProducesErrorMessages(string input, string
message)
+ {
+ var exception = Assert.Throws<FormatException>(() =>
IggyDurationParser.ParseHumantime(input));
+
+ Assert.Equal(message, exception.Message);
+ }
+
+ [Theory]
+ [InlineData("500ms", 500)]
+ [InlineData("5s", 5000)]
+ [InlineData("2m", 120_000)]
+ [InlineData("1h", 3_600_000)]
+ [InlineData("0.5s", 500)]
+ [InlineData("1h 1m 1s", 3_661_000)]
+ [InlineData("1h30m", 5_400_000)]
+ [InlineData("5d", 432_000_000)]
+ [InlineData("2w", 1_209_600_000)]
+ [InlineData("1y", 31_557_600_000)]
+ [InlineData("5sec", 5000)]
+ [InlineData("5msec", 5)]
+ [InlineData("1.005s", 1005)]
+ [InlineData("0s", 0)]
+ public void Parse_ConvertsToTimeSpan(string input, long milliseconds)
+ {
+ Assert.Equal(TimeSpan.FromMilliseconds(milliseconds),
IggyDurationParser.Parse(input));
+ }
+
+ [Fact]
+ public void Parse_KeepsSubMillisecondPrecisionDownToOneTick()
+ {
+ Assert.Equal(TimeSpan.FromMicroseconds(5),
IggyDurationParser.Parse("5usec"));
+ Assert.Equal(TimeSpan.FromTicks(5),
IggyDurationParser.Parse("500nsec"));
+ Assert.Equal(TimeSpan.Zero, IggyDurationParser.Parse("17ns"));
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("unlimited")]
+ [InlineData("disabled")]
+ [InlineData("none")]
+ [InlineData("UNLIMITED")]
+ [InlineData("None")]
+ [InlineData("Disabled")]
+ public void Parse_MapsZeroSpellingsToZeroCaseInsensitively(string input)
+ {
+ Assert.Equal(TimeSpan.Zero, IggyDurationParser.Parse(input));
+ }
+
+ [Fact]
+ public void Parse_IsCaseInsensitive()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(3661), IggyDurationParser.Parse("1H
1M 1S"));
+ }
+
+ [Theory]
+ [InlineData("5")]
+ [InlineData("-1s")]
+ [InlineData("ms")]
+ [InlineData("")]
+ [InlineData("abc")]
+ [InlineData("s")]
+ [InlineData("1 hour and 30 minutes")]
+ public void Parse_RejectsInvalidDurations(string input)
+ {
+ Assert.Throws<FormatException>(() => IggyDurationParser.Parse(input));
+ }
+
+ [Fact]
+ public void Parse_RejectsDurationsBeyondTimeSpan()
+ {
+ // Past the ~29,000 years a TimeSpan covers.
+ var exception = Assert.Throws<FormatException>(() =>
IggyDurationParser.Parse("100000y"));
+
+ Assert.Equal(OverflowMessage, exception.Message);
+ }
+}
diff --git a/foreign/csharp/README.md b/foreign/csharp/README.md
index d2a9fb5d5..205f3dd4f 100644
--- a/foreign/csharp/README.md
+++ b/foreign/csharp/README.md
@@ -159,6 +159,51 @@ await client.ConnectAsync();
TCP applies the connection, heartbeat, TLS, and auto-login settings. HTTP
`ConnectAsync` does no
network work and requires explicit login. For TLS configuration, see the
[TcpTls example](../../examples/csharp/README.md#tcptls).
+### Connection String
+
+A TCP client can also be created from the connection string format shared with
the other SDKs. The
+credentials become the auto-login settings, so the client signs in on
`ConnectAsync`:
+
+```c#
+using var client =
IggyClientFactory.CreateClient("iggy://iggy:[email protected]:8090");
+await client.ConnectAsync();
+
+// A personal access token instead of a username and password, plus options
+using var tokenClient = IggyClientFactory.CreateClient(
+
"iggy+tcp://[email protected]:8090?heartbeat_interval=10s&reconnection_retries=5");
+
+// Parse into a configurator to adjust the remaining settings
+var config =
IggyClientConfigurator.FromConnectionString("iggy://iggy:[email protected]:8090");
+config.LoggerFactory = loggerFactory;
+```
+
+| Option | Default | Description
|
+|-------------------------|-------------|--------------------------------------------------------------------------------|
+| `tls` | `false` | `true` or `false`
|
+| `tls_domain` | server host | Server name for the TLS handshake,
used for every node the client dials |
+| `tls_ca_file` | empty | CA certificate path, read when
connecting, required with `tls=true` |
+| `reconnection_retries` | `unlimited` | Count or `unlimited`. `0` turns
reconnection off, also after a lost connection |
+| `reconnection_interval` | `1s` | Fixed delay between attempts and
before the first redial, at least `1ms` |
+| `heartbeat_interval` | `5s` | Between `1ms` and about 49 days
|
+| `reestablish_after` | - | Validated, then ignored.
`reconnection_interval` paces the first redial |
+| `nodelay` | - | Validated, then ignored. The client
always disables Nagle |
+
+`reestablish_after` and `nodelay` are accepted so that strings shared with
other SDKs keep parsing, but
+they change nothing. The client has no `reestablish_after` window after a lost
connection, and it always opens
+sockets with `NoDelay`, so `nodelay=false` does not turn Nagle back on even
though the Rust and Node.js SDKs leave
+Nagle on by default.
+
+`reconnection_retries=0` differs from the Rust SDK. After a lost connection,
Rust still makes one reconnect
+attempt, but this client fails the request at once. It also differs from
`ReconnectionSettings.MaxRetries`,
+where `0` means unlimited, so the parser maps `reconnection_retries=0` to
`Enabled = false` instead.
+
+Reconnection uses no exponential backoff, and after each reconnect the client
waits
+`ReconnectionSettings.WaitAfterReconnect` (1 second by default). Credentials
are taken literally: they are
+not percent-decoded and must not contain `@` or `:`. Bracketed IPv6 hosts such
as
+`iggy://iggy:iggy@[::1]:8090` are accepted. Durations look like `500ms`, `5s`
or `1m30s`. Only `iggy://` and
+`iggy+tcp://` are supported. A malformed string throws `FormatException`,
whose message never
+contains the connection string.
+
## Viewstamped Replication (VSR)
Over TCP every request is wrapped in a 256-byte consensus header, the client
registers a consensus session at