This is an automated email from the ASF dual-hosted git repository. lukaszzborek pushed a commit to branch feat/csharp-connection-string in repository https://gitbox.apache.org/repos/asf/iggy.git
commit fc113e58a95785094b1f349d3b3c14d5bebad190 Author: Łukasz Zborek <[email protected]> AuthorDate: Tue Sep 29 23:23:48 2026 +0200 feat(csharp): implement connection string parsing --- .../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 | 399 +++++++++++++++++++ .../ClientTests/ConnectionStringTests.cs | 442 +++++++++++++++++++++ .../UtilityTests/IggyDurationParserTests.cs | 227 +++++++++++ foreign/csharp/README.md | 44 ++ 9 files changed, 1537 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..4fd341f88 --- /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 = retries <= int.MaxValue ? (int)retries : 0; + } + + 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..8580304ff --- /dev/null +++ b/foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs @@ -0,0 +1,399 @@ +// 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; + var zeros = true; + while (Next() is { } c) + { + if (c == '0') + { + denominator = Mul(denominator, 10); + if (!zeros) + { + numerator = Mul(numerator, 10); + } + } + else if (IsDigit(c)) + { + zeros = false; + 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..3c179840e --- /dev/null +++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/ConnectionStringTests.cs @@ -0,0 +1,442 @@ +// 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)] + [InlineData("3", "4294967295", true, 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", int.MaxValue)] + [InlineData("2147483648", 0)] + [InlineData("4294967295", 0)] + public void FromConnectionString_WithRetriesUpToU32Max_Accepts(string retries, int expected) + { + var config = IggyClientConfigurator.FromConnectionString( + $"iggy://iggy:secret@localhost:8090?reconnection_retries={retries}"); + + Assert.True(config.ReconnectionSettings.Enabled); + Assert.Equal(expected, 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 41feb6919..98de470a7 100644 --- a/foreign/csharp/README.md +++ b/foreign/csharp/README.md @@ -148,6 +148,50 @@ 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. + +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
