diff --git a/src/Exceptionless.Core/Configuration/MessageBusOptions.cs b/src/Exceptionless.Core/Configuration/MessageBusOptions.cs index a0a668c54a..444058818a 100644 --- a/src/Exceptionless.Core/Configuration/MessageBusOptions.cs +++ b/src/Exceptionless.Core/Configuration/MessageBusOptions.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Diagnostics.CodeAnalysis; +using Exceptionless.Core.Extensions; using Foundatio.Utility; using Microsoft.Extensions.Configuration; @@ -6,6 +7,9 @@ namespace Exceptionless.Core.Configuration; public class MessageBusOptions { + private const string RabbitMqProvider = "rabbitmq"; + private const string ServerKey = "server"; + public string? ConnectionString { get; internal set; } public string? Provider { get; internal set; } public Dictionary Data { get; internal set; } = null!; @@ -24,12 +28,24 @@ public static MessageBusOptions ReadFromConfiguration(IConfiguration config, App if (cs != null) { + if (TryGetRabbitMqConnectionString(config, cs, out string? connectionString)) + { + options.Provider = RabbitMqProvider; + options.ConnectionString = connectionString; + options.Data = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [nameof(options.Provider)] = options.Provider + }; + options.Data[ServerKey] = connectionString; + + return options; + } + options.Data = cs.ParseConnectionString(); options.Provider = options.Data.GetString(nameof(options.Provider)); string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null; - var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server"); - options.Data ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: ServerKey); options.Data.AddRange(providerOptions); options.ConnectionString = options.Data.BuildConnectionString(new HashSet { nameof(options.Provider) }); @@ -47,4 +63,34 @@ public static MessageBusOptions ReadFromConfiguration(IConfiguration config, App return options; } + + private static bool TryGetRabbitMqConnectionString(IConfiguration config, string selector, [NotNullWhen(true)] out string? connectionString) + { + connectionString = null; + + int separatorIndex = selector.IndexOf(';'); + string providerSelector = separatorIndex >= 0 ? selector[..separatorIndex] : selector; + var providerData = providerSelector.ParseConnectionString(); + + if (!String.Equals(providerData.GetString(nameof(Provider)), RabbitMqProvider, StringComparison.OrdinalIgnoreCase)) + return false; + + string? configuredConnectionString = separatorIndex >= 0 ? selector[(separatorIndex + 1)..] : null; + if (String.IsNullOrWhiteSpace(configuredConnectionString)) + configuredConnectionString = config.GetConnectionString(RabbitMqProvider); + + if (String.IsNullOrWhiteSpace(configuredConnectionString)) + return false; + + connectionString = TrimMatchingQuotes(configuredConnectionString.Trim()); + return true; + } + + private static string TrimMatchingQuotes(string value) + { + if (value.Length >= 2 && ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\''))) + return value[1..^1]; + + return value; + } } diff --git a/src/Exceptionless.Job/appsettings.Development.yml b/src/Exceptionless.Job/appsettings.Development.yml index 7b7a1b7601..df8f086dfe 100644 --- a/src/Exceptionless.Job/appsettings.Development.yml +++ b/src/Exceptionless.Job/appsettings.Development.yml @@ -4,6 +4,10 @@ ConnectionStrings: # Elasticsearch: server=https://elastic:elastic@localhost:9200 # Cache: provider=redis; # MessageBus: provider=redis; +# RabbitMQ examples (choose inline or separate): +# MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' +# MessageBus: 'provider=rabbitmq' +# rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis; # Storage: provider=folder;path=..\Exceptionless.Web\storage Email: smtp://localhost:1025 diff --git a/src/Exceptionless.Job/appsettings.Production.yml b/src/Exceptionless.Job/appsettings.Production.yml index b01a5221e1..2a32af4d01 100644 --- a/src/Exceptionless.Job/appsettings.Production.yml +++ b/src/Exceptionless.Job/appsettings.Production.yml @@ -3,6 +3,10 @@ ConnectionStrings: # Elasticsearch: server=http://localhost:9200 # Cache: provider=redis;server="localhost,abortConnect=false" # MessageBus: provider=redis;server="localhost,abortConnect=false" +# RabbitMQ examples (choose inline or separate): +# MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' +# MessageBus: 'provider=rabbitmq' +# rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis;server="localhost,abortConnect=false" # Storage: '' # Email: 'smtps://user:password@domain.com:587' diff --git a/src/Exceptionless.Job/appsettings.Staging.yml b/src/Exceptionless.Job/appsettings.Staging.yml index db415b6211..4b2d6f0278 100644 --- a/src/Exceptionless.Job/appsettings.Staging.yml +++ b/src/Exceptionless.Job/appsettings.Staging.yml @@ -4,6 +4,10 @@ ConnectionStrings: # Elasticsearch: server=http://localhost:9200;replicas=0 # Cache: provider=redis; # MessageBus: provider=redis; +# RabbitMQ examples (choose inline or separate): +# MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' +# MessageBus: 'provider=rabbitmq' +# rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis; # Storage: provider=folder;path=.\storage= OAuth: FacebookId=395178683904310;GitHubId=7ef1dd5bfbc4ccf7f5ef;GoogleId=809763155066-enkkdmt4ierc33q9cft9nf5d5c02h30q.apps.googleusercontent.com;MicrosoftId=000000004C137E8B;SlackId=34500115540.177239122322; diff --git a/src/Exceptionless.Web/appsettings.Development.yml b/src/Exceptionless.Web/appsettings.Development.yml index b2a91a34bd..913c3aa64c 100644 --- a/src/Exceptionless.Web/appsettings.Development.yml +++ b/src/Exceptionless.Web/appsettings.Development.yml @@ -4,6 +4,10 @@ ConnectionStrings: # Elasticsearch: server=https://elastic:elastic@localhost:9200 # Cache: provider=redis; # MessageBus: provider=redis; +# RabbitMQ examples (choose inline or separate): +# MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' +# MessageBus: 'provider=rabbitmq' +# rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis; # Storage: provider=folder;path=.\storage # LDAP: '' diff --git a/src/Exceptionless.Web/appsettings.Production.yml b/src/Exceptionless.Web/appsettings.Production.yml index a6bbd82962..b3ccd3fb91 100644 --- a/src/Exceptionless.Web/appsettings.Production.yml +++ b/src/Exceptionless.Web/appsettings.Production.yml @@ -3,6 +3,10 @@ ConnectionStrings: # Elasticsearch: server=http://localhost:9200 # Cache: provider=redis;server="localhost,abortConnect=false" # MessageBus: provider=redis;server="localhost,abortConnect=false" +# RabbitMQ examples (choose inline or separate): +# MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' +# MessageBus: 'provider=rabbitmq' +# rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis;server="localhost,abortConnect=false" # Storage: '' # Email: 'smtps://user:password@domain.com:587' diff --git a/src/Exceptionless.Web/appsettings.Staging.yml b/src/Exceptionless.Web/appsettings.Staging.yml index 9d97c7b5a4..a17384b868 100644 --- a/src/Exceptionless.Web/appsettings.Staging.yml +++ b/src/Exceptionless.Web/appsettings.Staging.yml @@ -4,6 +4,10 @@ ConnectionStrings: # Elasticsearch: server=http://localhost:9200;replicas=0 # Cache: provider=redis; # MessageBus: provider=redis; +# RabbitMQ examples (choose inline or separate): +# MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' +# MessageBus: 'provider=rabbitmq' +# rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis; # Storage: provider=folder;path=.\storage= OAuth: FacebookId=395178683904310;GitHubId=7ef1dd5bfbc4ccf7f5ef;GoogleId=809763155066-enkkdmt4ierc33q9cft9nf5d5c02h30q.apps.googleusercontent.com;MicrosoftId=000000004C137E8B;SlackId=34500115540.177239122322; diff --git a/tests/Exceptionless.Tests/Configuration/MessageBusOptionsTests.cs b/tests/Exceptionless.Tests/Configuration/MessageBusOptionsTests.cs new file mode 100644 index 0000000000..1bb6f3c1a3 --- /dev/null +++ b/tests/Exceptionless.Tests/Configuration/MessageBusOptionsTests.cs @@ -0,0 +1,92 @@ +using Exceptionless.Core; +using Exceptionless.Core.Configuration; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Exceptionless.Tests.Configuration; + +public class MessageBusOptionsTests +{ + [Theory] + [InlineData("provider=rabbitmq;amqp://localhost/%2F", "amqp://localhost/%2F")] + [InlineData("provider=rabbitmq;\"amqp://localhost/%2F\"", "amqp://localhost/%2F")] + [InlineData("provider=rabbitmq;'amqp://localhost/%2F'", "amqp://localhost/%2F")] + [InlineData( + " PROVIDER = \"RABBITMQ\" ; 'amqps://user:p%40ss@rabbit.example.com:5671/team%2Fprod?heartbeat=30&connection_timeout=10000' ", + "amqps://user:p%40ss@rabbit.example.com:5671/team%2Fprod?heartbeat=30&connection_timeout=10000")] + public void ReadFromConfiguration_WithInlineRabbitMqUri_PreservesRawConnectionString(string configuredConnectionString, string expectedConnectionString) + { + var options = ReadOptions(new Dictionary + { + ["ConnectionStrings:MessageBus"] = configuredConnectionString + }); + + Assert.Equal("rabbitmq", options.Provider); + Assert.Equal(expectedConnectionString, options.ConnectionString); + Assert.Equal(expectedConnectionString, options.Data["server"]); + } + + [Theory] + [InlineData("provider=rabbitmq", "amqp://localhost/%2F", "amqp://localhost/%2F")] + [InlineData("provider=rabbitmq;", "'amqp://localhost/%2F'", "amqp://localhost/%2F")] + [InlineData( + "provider=RaBbItMq", + "\"amqps://user:p%40ss@rabbit.example.com:5671/team%2Fprod?heartbeat=30\"", + "amqps://user:p%40ss@rabbit.example.com:5671/team%2Fprod?heartbeat=30")] + public void ReadFromConfiguration_WithNamedRabbitMqUri_PreservesRawConnectionString(string selector, string configuredConnectionString, string expectedConnectionString) + { + var options = ReadOptions(new Dictionary + { + ["ConnectionStrings:MessageBus"] = selector, + ["ConnectionStrings:rabbitmq"] = configuredConnectionString + }); + + Assert.Equal("rabbitmq", options.Provider); + Assert.Equal(expectedConnectionString, options.ConnectionString); + Assert.Equal(expectedConnectionString, options.Data["server"]); + } + + [Fact] + public void ReadFromConfiguration_WithRedisProviderSettings_MergesLegacyKeyValueData() + { + var options = ReadOptions(new Dictionary + { + ["ConnectionStrings:MessageBus"] = "provider=redis;ssl=true", + ["ConnectionStrings:redis"] = "server=localhost:6379;abortConnect=false" + }); + + Assert.Equal("redis", options.Provider); + Assert.Equal("ssl=true;server=localhost:6379;abortConnect=false", options.ConnectionString); + Assert.Equal("true", options.Data["ssl"]); + Assert.Equal("localhost:6379", options.Data["server"]); + Assert.Equal("false", options.Data["abortConnect"]); + } + + [Fact] + public void ReadFromConfiguration_WithInlineRedisConnectionString_PreservesLegacyFormatting() + { + var options = ReadOptions(new Dictionary + { + ["ConnectionStrings:MessageBus"] = "provider=redis;server=localhost:6379,abortConnect=false" + }); + + Assert.Equal("redis", options.Provider); + Assert.Equal("server=localhost:6379,abortConnect=false", options.ConnectionString); + Assert.Equal("localhost:6379,abortConnect=false", options.Data["server"]); + } + + private static MessageBusOptions ReadOptions(Dictionary values) + { + var configuration = CreateConfiguration(values); + var appOptions = new AppOptions { AppScope = "production" }; + + return MessageBusOptions.ReadFromConfiguration(configuration, appOptions); + } + + private static IConfiguration CreateConfiguration(Dictionary values) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + } +} diff --git a/tests/Exceptionless.Tests/appsettings.yml b/tests/Exceptionless.Tests/appsettings.yml index 5914619e15..3d26819a88 100644 --- a/tests/Exceptionless.Tests/appsettings.yml +++ b/tests/Exceptionless.Tests/appsettings.yml @@ -4,6 +4,10 @@ ConnectionStrings: # Elasticsearch: server=https://elastic:elastic@localhost:9200 # Cache: provider=redis; # MessageBus: provider=redis; + # RabbitMQ examples (choose inline or separate): + # MessageBus: 'provider=rabbitmq;amqp://localhost/%2F' + # MessageBus: 'provider=rabbitmq' + # rabbitmq: 'amqp://localhost/%2F' # Queue: provider=redis; Storage: provider=folder;path=..\..\..\..\..\src\Exceptionless.Web\storage