diff --git a/docs/features/configuration.rst b/docs/features/configuration.rst index 4447dac3d..5a29a6b48 100644 --- a/docs/features/configuration.rst +++ b/docs/features/configuration.rst @@ -216,62 +216,14 @@ For example: Examining the code within the `ConfigurationBuilderExtensions class `_ would be helpful for gaining a better understanding of the signatures of the overloaded methods [#f2]_. -Store Configuration in Consul ------------------------------ +Store Configuration in `Consul`_ +-------------------------------- -The first thing you need to do is install the `NuGet package `_ that provides `Consul `_ support in Ocelot. +As a developer, if you have enabled :doc:`../features/servicediscovery` with `Consul`_ support in Ocelot, you may choose to manage your configuration saving to the *Consul* `KV store`_. -.. code-block:: powershell +Beyond the traditional methods of storing configuration in a file vs folder (:ref:`config-merging-files`), or in-memory (:ref:`config-merging-tomemory`), you also have the alternative to utilize the `Consul`_ server's storage capabilities. - Install-Package Ocelot.Provider.Consul - -Then you add the following when you register your services Ocelot will attempt to store and retrieve its configuration in Consul KV store. -In order to register Consul services we must call the ``AddConsul()`` and ``AddConfigStoredInConsul()`` extensions using the ``OcelotBuilder`` being returned by ``AddOcelot()`` [#f3]_ like below: - -.. code-block:: csharp - - services.AddOcelot() - .AddConsul() - .AddConfigStoredInConsul(); - -You also need to add the following to your `ocelot.json`_. This is how Ocelot finds your Consul agent and interacts to load and store the configuration from Consul. - -.. code-block:: json - - "GlobalConfiguration": { - "ServiceDiscoveryProvider": { - "Host": "localhost", - "Port": 9500 - } - } - -The team decided to create this feature after working on the Raft consensus algorithm and finding out its super hard. -Why not take advantage of the fact Consul already gives you this! -We guess it means if you want to use Ocelot to its fullest, you take on Consul as a dependency for now. - -This feature has a `3 seconds `_ TTL cache before making a new request to your local Consul agent. - -.. _config-consul-key: - -Consul Configuration Key [#f4]_ -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -If you are using Consul for configuration (or other providers in the future), you might want to key your configurations: so you can have multiple configurations. - -In order to specify the key you need to set the **ConfigurationKey** property in the **ServiceDiscoveryProvider** options of the configuration JSON file e.g. - -.. code-block:: json - - "GlobalConfiguration": { - "ServiceDiscoveryProvider": { - "Host": "localhost", - "Port": 9500, - "ConfigurationKey": "Ocelot_A" - } - } - -In this example Ocelot will use ``Ocelot_A`` as the key for your configuration when looking it up in Consul. -If you do not set the **ConfigurationKey**, Ocelot will use the string ``InternalConfiguration`` as the key. +For further details on managing Ocelot configurations via a Consul instance, please consult the ":ref:`sd-consul-configuration-in-kv`" section. Follow Redirects aka HttpHandlerOptions --------------------------------------- @@ -417,7 +369,7 @@ Ocelot allows you to choose the HTTP version it will use to make the proxy reque .. _config-version-policy: -DownstreamHttpVersionPolicy [#f5]_ +DownstreamHttpVersionPolicy [#f3]_ ---------------------------------- This routing property enables the configuration of the ``VersionPolicy`` property within ``HttpRequestMessage`` objects for downstream HTTP requests. @@ -557,12 +509,12 @@ Now, the route metadata can be accessed through the `DownstreamRoute` object: .. [#f1] ":ref:`config-merging-files`" feature was requested in `issue 296 `_, since then we extended it in `issue 1216 `_ (PR `1227 `_) as ":ref:`config-merging-tomemory`" subfeature which was released as a part of version `23.2`_. .. [#f2] ":ref:`config-merging-tomemory`" subfeature is based on the ``MergeOcelotJson`` enumeration type with values: ``ToFile`` and ``ToMemory``. The 1st one is implicit by default, and the second one is exactly what you need when merging to memory. See more details on implementations in the `ConfigurationBuilderExtensions`_ class. -.. [#f3] :ref:`di-the-addocelot-method` adds default ASP.NET services to DI container. You could call another extended :ref:`di-addocelotusingbuilder-method` while configuring services to develop your own :ref:`di-custom-builder`. See more instructions in the ":ref:`di-addocelotusingbuilder-method`" section of :doc:`../features/dependencyinjection` feature. -.. [#f4] ":ref:`config-consul-key`" feature was requested in `issue 346 `_ as a part of version `7.0.0 `_. -.. [#f5] ":ref:`config-version-policy`" feature was requested in `issue 1672 `_ as a part of version `24.0`_. +.. [#f3] ":ref:`config-version-policy`" feature was requested in `issue 1672 `_ as a part of version `23.3`_. .. _20.0: https://github.com/ThreeMammals/Ocelot/releases/tag/20.0.0 .. _23.2: https://github.com/ThreeMammals/Ocelot/releases/tag/23.2.0 -.. _24.0: https://github.com/ThreeMammals/Ocelot/releases/tag/24.0.0 +.. _23.3: https://github.com/ThreeMammals/Ocelot/releases/tag/23.3.0 .. _ocelot.json: https://github.com/ThreeMammals/Ocelot/blob/main/test/Ocelot.ManualTest/ocelot.json .. _ConfigurationBuilderExtensions: https://github.com/ThreeMammals/Ocelot/blob/develop/src/Ocelot/DependencyInjection/ConfigurationBuilderExtensions.cs +.. _Consul: https://www.consul.io/ +.. _KV Store: https://developer.hashicorp.com/consul/docs/dynamic-app-config/kv diff --git a/docs/features/servicediscovery.rst b/docs/features/servicediscovery.rst index 6d9d8d4ef..a4e4f96c5 100644 --- a/docs/features/servicediscovery.rst +++ b/docs/features/servicediscovery.rst @@ -9,28 +9,85 @@ At the moment this is only supported in the **GlobalConfiguration** section, whi Consul ------ - | **Namespace**: `Ocelot.Provider.Consul `_ + | **Namespace**: ``Ocelot.Provider.Consul`` -The first thing you need to do is install the `Ocelot.Provider.Consul `__ package that provides `Consul `_ support in Ocelot: +The first thing you need to do is install the `Ocelot.Provider.Consul `_ package that provides `Consul`_ support in Ocelot: .. code-block:: powershell Install-Package Ocelot.Provider.Consul -Then add the following to your ``ConfigureServices`` method: +To register *Consul* services, you must invoke the ``AddConsul()`` extension using the ``OcelotBuilder`` returned by ``AddOcelot()`` [#f1]_. +Therefore, include the following in your ``ConfigureServices`` method: .. code-block:: csharp services.AddOcelot() - .AddConsul(); + .AddConsul(); // or .AddConsul() -Currently there are 2 types of Consul *service discovery* providers: ``Consul`` and ``PollConsul``. -The default provider is ``Consul``, which means that if ``ConsulProviderFactory`` cannot read, understand, or parse the **Type** property of the ``ServiceProviderConfiguration`` object, then a ``Consul`` provider instance is created by the factory. +Currently there are 2 types of *Consul* service discovery providers: ``Consul`` and ``PollConsul``. +The default provider is ``Consul``, which means that if ``ConsulProviderFactory`` cannot read, understand, or parse the **Type** property of the ``ServiceProviderConfiguration`` object, +then a :ref:`sd-consul-provider` instance is created by the factory. -Explore these types of providers and understand the differences in the subsections below. +Explore these types of providers and understand the differences in the subsections: :ref:`sd-consul-provider` and :ref:`sd-pollconsul-provider`. -Consul Provider Type -^^^^^^^^^^^^^^^^^^^^ +.. _sd-consul-configuration-in-kv: + +Configuration in `KV Store`_ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Add the following when you register your services Ocelot will attempt to store and retrieve its :doc:`../features/configuration` in *Consul* `KV Store`_: + +.. code-block:: csharp + + services.AddOcelot() + .AddConsul() + .AddConfigStoredInConsul(); // ! + +You also need to add the following to your `ocelot.json`_. +This is how Ocelot finds your *Consul* agent and interacts to load and store the configuration from *Consul*. + +.. code-block:: json + + "GlobalConfiguration": { + "ServiceDiscoveryProvider": { + "Host": "localhost", + "Port": 9500 + } + } + +The team decided to create this feature after working on the Raft consensus algorithm and finding out its super hard. +Why not take advantage of the fact Consul already gives you this! +We guess it means if you want to use Ocelot to its fullest, you take on Consul as a dependency for now. + + **Note!** This feature has a `3 seconds TTL`_ cache before making a new request to your local *Consul* agent. + +.. _sd-consul-configuration-key: + +Consul Configuration Key [#f2]_ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you are using *Consul* for :doc:`../features/configuration` (or other providers in the future), you might want to key your configurations: so you can have multiple configurations. + +In order to specify the key you need to set the **ConfigurationKey** property in the **ServiceDiscoveryProvider** options of the configuration JSON file e.g. + +.. code-block:: json + + "GlobalConfiguration": { + "ServiceDiscoveryProvider": { + "Host": "localhost", + "Port": 9500, + "ConfigurationKey": "Ocelot_A" // ! + } + } + +In this example Ocelot will use ``Ocelot_A`` as the key for your configuration when looking it up in *Consul*. +If you do not set the **ConfigurationKey**, Ocelot will use the string ``InternalConfiguration`` as the key. + +.. _sd-consul-provider: + +``Consul`` Provider +^^^^^^^^^^^^^^^^^^^ | **Class**: `Ocelot.Provider.Consul.Consul `_ @@ -67,8 +124,10 @@ If no load balancer is specified, Ocelot will not load balance requests. When this is set up Ocelot will lookup the downstream host and port from the *service discovery* provider and load balance requests across any available services. -PollConsul Provider Type -^^^^^^^^^^^^^^^^^^^^^^^^ +.. _sd-pollconsul-provider: + +``PollConsul`` Provider +^^^^^^^^^^^^^^^^^^^^^^^ | **Class**: `Ocelot.Provider.Consul.PollConsul `_ @@ -98,7 +157,7 @@ Service Definition Your services need to be added to Consul something like below (C# style but hopefully this make sense)... The only important thing to note is not to add ``http`` or ``https`` to the ``Address`` field. We have been contacted before about not accepting scheme in ``Address``. -After reading `this `_ we do not think the scheme should be in there. +After reading `Agents Overview `_ and `Define services `_ docs we do not think the **scheme** should be in there. In C# @@ -140,6 +199,68 @@ In order so this to work you must add the additional property below: Ocelot will add this token to the Consul client that it uses to make requests and that is then used for every request. +.. _sd-consul-service-builder: + +Consul Service Builder [#f3]_ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + | **Interface**: ``IConsulServiceBuilder`` + | **Implementation**: ``DefaultConsulServiceBuilder`` + +The Ocelot community has consistently reported, both in the past and presently, issues with *Consul* services (such as connectivity) due to a variety of *Consul* agent definitions. +Some DevOps engineers prefer to group services as *Consul* `catalog nodes`_ by customizing the assignment of host names to node names, +while others focus on defining agent services with pure IP addresses as hosts, which relates to the `954`_ bug dilemma. + +Since version `13.5.2`_, the building of service downstream host/port in PR `909`_ has been altered to favor the node name as the host over the agent service address IP. + +Version `23.3`_ saw the introduction of a customization feature that allows control over the service building process through the ``DefaultConsulServiceBuilder`` class. +This class has virtual methods that can be overridden to meet the needs of developers and DevOps. + +The present logic in the ``DefaultConsulServiceBuilder`` class is as follows: + +.. code-block:: csharp + + protected virtual string GetDownstreamHost(ServiceEntry entry, Node node) + => node != null ? node.Name : entry.Service.Address; + +Some DevOps engineers choose to ignore node names, opting instead for abstract identifiers rather than actual hostnames. +Our team, however, advocates for the assignment of real hostnames or IP addresses to node names, upholding this as a best practice. +If this approach does not align with your needs, or if you prefer not to spend time detailing your nodes for downstream services, you might consider defining agent services without node names. +In such cases within a *Consul* setup, you would need to override the behavior of the ``DefaultConsulServiceBuilder`` class. +For further details, refer to the subsequent section below. + +.. _sd-addconsul-generic-method: + +``AddConsul`` method +""""""""""""""""""""""" + + | **Signature**: ``IOcelotBuilder AddConsul(this IOcelotBuilder builder)`` + +Overriding the ``DefaultConsulServiceBuilder`` behavior involves two steps: defining a new class that inherits from the ``IConsulServiceBuilder`` interface, +and then injecting this new behavior into DI using the ``AddConsul`` helper. +However, the quickest and most streamlined approach is to inherit directly from the ``DefaultConsulServiceBuilder`` class, which offers greater flexibility. + +**First**, we need to define a new service building class: + +.. code-block:: csharp + + public class MyConsulServiceBuilder : DefaultConsulServiceBuilder + { + public MyConsulServiceBuilder(Func configurationFactory, IConsulClientFactory clientFactory, IOcelotLoggerFactory loggerFactory) + : base(configurationFactory, clientFactory, loggerFactory) { } + // I want to use the agent service IP address as the downstream hostname + protected override string GetDownstreamHost(ServiceEntry entry, Node node) => entry.Service.Address; + } + +**Second**, we must inject the new behavior into DI, as demonstrated in the Ocelot versus Consul setup: + +.. code-block:: csharp + + services.AddOcelot() + .AddConsul(); + +You can refer to `the acceptance test`_ in the repository for an example. + Eureka ------ @@ -410,3 +531,22 @@ After this, you need to add the ``IServiceDiscoveryProviderFactory`` interface t Note that in this case the Ocelot pipeline will not use ``ServiceDiscoveryProviderFactory`` by default. Additionally, you do not need to specify ``"Type": "MyServiceDiscoveryProvider"`` in the **ServiceDiscoveryProvider** properties of the **GlobalConfiguration** settings. But you can leave this ``Type`` option for compatibility between both designs. + +"""" + +.. [#f1] :ref:`di-the-addocelot-method` adds default ASP.NET services to DI container. You could call another extended :ref:`di-addocelotusingbuilder-method` while configuring services to develop your own :ref:`di-custom-builder`. See more instructions in the ":ref:`di-addocelotusingbuilder-method`" section of :doc:`../features/dependencyinjection` feature. +.. [#f2] *"Consul Configuration Key"* feature was requested in issue `346`_ as a part of version `7.0.0`_. +.. [#f3] Customization of *"Consul Service Builder"* was implemented as a part of bug `954`_ fixing and the feature was delivered in version `23.3`_. + +.. _ocelot.json: https://github.com/ThreeMammals/Ocelot/blob/main/test/Ocelot.ManualTest/ocelot.json +.. _Consul: https://www.consul.io/ +.. _KV Store: https://developer.hashicorp.com/consul/docs/dynamic-app-config/kv +.. _3 seconds TTL: https://github.com/search?q=repo%3AThreeMammals%2FOcelot+TimeSpan.FromSeconds%283%29&type=code +.. _catalog nodes: https://developer.hashicorp.com/consul/api-docs/catalog#list-nodes +.. _the acceptance test: https://github.com/search?q=repo%3AThreeMammals%2FOcelot+Should_return_service_address_by_overridden_service_builder_when_there_is_a_node&type=code +.. _346: https://github.com/ThreeMammals/Ocelot/issues/346 +.. _909: https://github.com/ThreeMammals/Ocelot/pull/909 +.. _954: https://github.com/ThreeMammals/Ocelot/issues/954 +.. _7.0.0: https://github.com/ThreeMammals/Ocelot/releases/tag/7.0.0 +.. _13.5.2: https://github.com/ThreeMammals/Ocelot/releases/tag/13.5.2 +.. _23.3: https://github.com/ThreeMammals/Ocelot/releases/tag/23.3.0 diff --git a/src/Ocelot.Provider.Consul/Consul.cs b/src/Ocelot.Provider.Consul/Consul.cs index 273fca2ab..27b5b4422 100644 --- a/src/Ocelot.Provider.Consul/Consul.cs +++ b/src/Ocelot.Provider.Consul/Consul.cs @@ -1,5 +1,5 @@ -using Ocelot.Infrastructure.Extensions; -using Ocelot.Logging; +using Ocelot.Logging; +using Ocelot.Provider.Consul.Interfaces; using Ocelot.ServiceDiscovery.Providers; using Ocelot.Values; @@ -7,70 +7,49 @@ namespace Ocelot.Provider.Consul; public class Consul : IServiceDiscoveryProvider { - private const string VersionPrefix = "version-"; - private readonly ConsulRegistryConfiguration _config; + private readonly ConsulRegistryConfiguration _configuration; private readonly IConsulClient _consul; private readonly IOcelotLogger _logger; + private readonly IConsulServiceBuilder _serviceBuilder; - public Consul(ConsulRegistryConfiguration config, IOcelotLoggerFactory factory, IConsulClientFactory clientFactory) + public Consul( + ConsulRegistryConfiguration config, + IOcelotLoggerFactory factory, + IConsulClientFactory clientFactory, + IConsulServiceBuilder serviceBuilder) { - _config = config; - _consul = clientFactory.Get(_config); + _configuration = config; + _consul = clientFactory.Get(_configuration); _logger = factory.CreateLogger(); + _serviceBuilder = serviceBuilder; } - public async Task> GetAsync() + public virtual async Task> GetAsync() { - var queryResult = await _consul.Health.Service(_config.KeyOfServiceInConsul, string.Empty, true); + var entriesTask = _consul.Health.Service(_configuration.KeyOfServiceInConsul, string.Empty, true); + var nodesTask = _consul.Catalog.Nodes(); + await Task.WhenAll(entriesTask, nodesTask); + + var entries = entriesTask.Result.Response ?? Array.Empty(); + var nodes = nodesTask.Result.Response ?? Array.Empty(); var services = new List(); - foreach (var serviceEntry in queryResult.Response) + if (entries.Length != 0) { - var service = serviceEntry.Service; - if (IsValid(service)) - { - var nodes = await _consul.Catalog.Nodes(); - if (nodes.Response == null) - { - services.Add(BuildService(serviceEntry, null)); - } - else - { - var serviceNode = nodes.Response.FirstOrDefault(n => n.Address == service.Address); - services.Add(BuildService(serviceEntry, serviceNode)); - } - } - else - { - _logger.LogWarning( - () => $"Unable to use service address: '{service.Address}' and port: {service.Port} as it is invalid for the service: '{service.Service}'. Address must contain host only e.g. 'localhost', and port must be greater than 0."); - } + _logger.LogDebug(() => $"{nameof(Consul)} Provider: Found total {entries.Length} service entries for '{_configuration.KeyOfServiceInConsul}' service."); + _logger.LogDebug(() => $"{nameof(Consul)} Provider: Found total {nodes.Length} catalog nodes."); + var collection = BuildServices(entries, nodes); + services.AddRange(collection); + } + else + { + _logger.LogWarning(() => $"{nameof(Consul)} Provider: No service entries found for '{_configuration.KeyOfServiceInConsul}' service!"); } - return services.ToList(); - } - - private static Service BuildService(ServiceEntry serviceEntry, Node serviceNode) - { - var service = serviceEntry.Service; - return new Service( - service.Service, - new ServiceHostAndPort( - serviceNode == null ? service.Address : serviceNode.Name, - service.Port), - service.ID, - GetVersionFromStrings(service.Tags), - service.Tags ?? Enumerable.Empty()); + return services; } - private static bool IsValid(AgentService service) - => !string.IsNullOrEmpty(service.Address) - && !service.Address.Contains($"{Uri.UriSchemeHttp}://") - && !service.Address.Contains($"{Uri.UriSchemeHttps}://") - && service.Port > 0; - - private static string GetVersionFromStrings(IEnumerable strings) - => strings?.FirstOrDefault(x => x.StartsWith(VersionPrefix, StringComparison.Ordinal)) - .TrimStart(VersionPrefix); + protected virtual IEnumerable BuildServices(ServiceEntry[] entries, Node[] nodes) + => _serviceBuilder.BuildServices(entries, nodes); } diff --git a/src/Ocelot.Provider.Consul/ConsulClientFactory.cs b/src/Ocelot.Provider.Consul/ConsulClientFactory.cs index 4a7478c59..f7c5c0c0c 100644 --- a/src/Ocelot.Provider.Consul/ConsulClientFactory.cs +++ b/src/Ocelot.Provider.Consul/ConsulClientFactory.cs @@ -1,4 +1,6 @@ -namespace Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; + +namespace Ocelot.Provider.Consul; public class ConsulClientFactory : IConsulClientFactory { diff --git a/src/Ocelot.Provider.Consul/ConsulFileConfigurationRepository.cs b/src/Ocelot.Provider.Consul/ConsulFileConfigurationRepository.cs index 2f9569362..c95146f46 100644 --- a/src/Ocelot.Provider.Consul/ConsulFileConfigurationRepository.cs +++ b/src/Ocelot.Provider.Consul/ConsulFileConfigurationRepository.cs @@ -5,6 +5,7 @@ using Ocelot.Configuration.File; using Ocelot.Configuration.Repository; using Ocelot.Logging; +using Ocelot.Provider.Consul.Interfaces; using Ocelot.Responses; using System.Text; diff --git a/src/Ocelot.Provider.Consul/ConsulProviderFactory.cs b/src/Ocelot.Provider.Consul/ConsulProviderFactory.cs index c769f49c0..00c2715ee 100644 --- a/src/Ocelot.Provider.Consul/ConsulProviderFactory.cs +++ b/src/Ocelot.Provider.Consul/ConsulProviderFactory.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Ocelot.Configuration; using Ocelot.Logging; +using Ocelot.Provider.Consul.Interfaces; using Ocelot.ServiceDiscovery.Providers; namespace Ocelot.Provider.Consul; @@ -17,16 +18,20 @@ public static class ConsulProviderFactory public static ServiceDiscoveryFinderDelegate Get { get; } = CreateProvider; + private static ConsulRegistryConfiguration configuration; + private static ConsulRegistryConfiguration ConfigurationGetter() => configuration; + public static Func GetConfiguration { get; } = ConfigurationGetter; + private static IServiceDiscoveryProvider CreateProvider(IServiceProvider provider, ServiceProviderConfiguration config, DownstreamRoute route) { var factory = provider.GetService(); var consulFactory = provider.GetService(); - var consulRegistryConfiguration = new ConsulRegistryConfiguration( - config.Scheme, config.Host, config.Port, route.ServiceName, config.Token); + configuration = new ConsulRegistryConfiguration(config.Scheme, config.Host, config.Port, route.ServiceName, config.Token); + var serviceBuilder = provider.GetService(); - var consulProvider = new Consul(consulRegistryConfiguration, factory, consulFactory); + var consulProvider = new Consul(configuration, factory, consulFactory, serviceBuilder); if (PollConsul.Equals(config.Type, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Ocelot.Provider.Consul/DefaultConsulServiceBuilder.cs b/src/Ocelot.Provider.Consul/DefaultConsulServiceBuilder.cs new file mode 100644 index 000000000..7526bea65 --- /dev/null +++ b/src/Ocelot.Provider.Consul/DefaultConsulServiceBuilder.cs @@ -0,0 +1,103 @@ +using Ocelot.Infrastructure.Extensions; +using Ocelot.Logging; +using Ocelot.Provider.Consul.Interfaces; +using Ocelot.Values; + +namespace Ocelot.Provider.Consul; + +public class DefaultConsulServiceBuilder : IConsulServiceBuilder +{ + private readonly ConsulRegistryConfiguration _configuration; + private readonly IConsulClient _client; + private readonly IOcelotLogger _logger; + + public DefaultConsulServiceBuilder( + Func configurationFactory, + IConsulClientFactory clientFactory, + IOcelotLoggerFactory loggerFactory) + { + _configuration = configurationFactory.Invoke(); + _client = clientFactory.Get(_configuration); + _logger = loggerFactory.CreateLogger(); + } + + public ConsulRegistryConfiguration Configuration => _configuration; + protected IConsulClient Client => _client; + protected IOcelotLogger Logger => _logger; + + public virtual bool IsValid(ServiceEntry entry) + { + var service = entry.Service; + var address = service.Address; + bool valid = !string.IsNullOrEmpty(address) + && !address.StartsWith(Uri.UriSchemeHttp + "://", StringComparison.OrdinalIgnoreCase) + && !address.StartsWith(Uri.UriSchemeHttps + "://", StringComparison.OrdinalIgnoreCase) + && service.Port > 0; + + if (!valid) + { + _logger.LogWarning( + () => $"Unable to use service address: '{service.Address}' and port: {service.Port} as it is invalid for the service: '{service.Service}'. Address must contain host only e.g. 'localhost', and port must be greater than 0."); + } + + return valid; + } + + public virtual IEnumerable BuildServices(ServiceEntry[] entries, Node[] nodes) + { + ArgumentNullException.ThrowIfNull(entries); + var services = new List(entries.Length); + + foreach (var serviceEntry in entries) + { + if (IsValid(serviceEntry)) + { + var serviceNode = GetNode(serviceEntry, nodes); + var item = CreateService(serviceEntry, serviceNode); + if (item != null) + { + services.Add(item); + } + } + } + + return services; + } + + protected virtual Node GetNode(ServiceEntry entry, Node[] nodes) + => entry?.Node ?? nodes?.FirstOrDefault(n => n.Address == entry?.Service?.Address); + + public virtual Service CreateService(ServiceEntry entry, Node node) + => new( + GetServiceName(entry, node), + GetServiceHostAndPort(entry, node), + GetServiceId(entry, node), + GetServiceVersion(entry, node), + GetServiceTags(entry, node) + ); + + protected virtual string GetServiceName(ServiceEntry entry, Node node) + => entry.Service.Service; + + protected virtual ServiceHostAndPort GetServiceHostAndPort(ServiceEntry entry, Node node) + => new( + GetDownstreamHost(entry, node), + entry.Service.Port); + + protected virtual string GetDownstreamHost(ServiceEntry entry, Node node) + => node != null ? node.Name : entry.Service.Address; + + protected virtual string GetServiceId(ServiceEntry entry, Node node) + => entry.Service.ID; + + protected virtual string GetServiceVersion(ServiceEntry entry, Node node) + => entry.Service.Tags + ?.FirstOrDefault(tag => tag.StartsWith(VersionPrefix, StringComparison.Ordinal)) + ?.TrimStart(VersionPrefix) + ?? string.Empty; + + protected virtual IEnumerable GetServiceTags(ServiceEntry entry, Node node) + => entry.Service.Tags ?? Enumerable.Empty(); + + private const string VersionPrefix = "version-"; +} diff --git a/src/Ocelot.Provider.Consul/IConsulClientFactory.cs b/src/Ocelot.Provider.Consul/Interfaces/IConsulClientFactory.cs similarity index 68% rename from src/Ocelot.Provider.Consul/IConsulClientFactory.cs rename to src/Ocelot.Provider.Consul/Interfaces/IConsulClientFactory.cs index 3ee3a2b25..0fe12aa08 100644 --- a/src/Ocelot.Provider.Consul/IConsulClientFactory.cs +++ b/src/Ocelot.Provider.Consul/Interfaces/IConsulClientFactory.cs @@ -1,4 +1,4 @@ -namespace Ocelot.Provider.Consul; +namespace Ocelot.Provider.Consul.Interfaces; public interface IConsulClientFactory { diff --git a/src/Ocelot.Provider.Consul/Interfaces/IConsulServiceBuilder.cs b/src/Ocelot.Provider.Consul/Interfaces/IConsulServiceBuilder.cs new file mode 100644 index 000000000..0555b0144 --- /dev/null +++ b/src/Ocelot.Provider.Consul/Interfaces/IConsulServiceBuilder.cs @@ -0,0 +1,11 @@ +using Ocelot.Values; + +namespace Ocelot.Provider.Consul.Interfaces; + +public interface IConsulServiceBuilder +{ + ConsulRegistryConfiguration Configuration { get; } + bool IsValid(ServiceEntry entry); + IEnumerable BuildServices(ServiceEntry[] entries, Node[] nodes); + Service CreateService(ServiceEntry serviceEntry, Node serviceNode); +} diff --git a/src/Ocelot.Provider.Consul/OcelotBuilderExtensions.cs b/src/Ocelot.Provider.Consul/OcelotBuilderExtensions.cs index dac7aecff..0c064f780 100644 --- a/src/Ocelot.Provider.Consul/OcelotBuilderExtensions.cs +++ b/src/Ocelot.Provider.Consul/OcelotBuilderExtensions.cs @@ -2,21 +2,57 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Ocelot.Configuration.Repository; using Ocelot.DependencyInjection; +using Ocelot.Provider.Consul.Interfaces; namespace Ocelot.Provider.Consul; public static class OcelotBuilderExtensions { + /// + /// Integrates Consul service discovery into the DI, atop the existing Ocelot services. + /// + /// + /// Default services: + /// + /// The service is an instance of . + /// The service is an instance of . + /// + /// + /// The Ocelot Builder instance, default. + /// The reference to the same extended object. public static IOcelotBuilder AddConsul(this IOcelotBuilder builder) { builder.Services .AddSingleton(ConsulProviderFactory.Get) + .AddSingleton(ConsulProviderFactory.GetConfiguration) .AddSingleton() + .AddSingleton() .RemoveAll(typeof(IFileConfigurationPollerOptions)) .AddSingleton(); return builder; } + /// + /// Integrates Consul service discovery into the DI, atop the existing Ocelot services, with service builder overriding. + /// + /// + /// Services to override: + /// + /// The service has been substituted with a instance. + /// + /// + /// The service builder type. + /// The Ocelot Builder instance, default. + /// The reference to the same extended object. + public static IOcelotBuilder AddConsul(this IOcelotBuilder builder) + where TServiceBuilder : class, IConsulServiceBuilder + { + AddConsul(builder).Services + .RemoveAll() + .AddSingleton(typeof(IConsulServiceBuilder), typeof(TServiceBuilder)); + return builder; + } + public static IOcelotBuilder AddConfigStoredInConsul(this IOcelotBuilder builder) { builder.Services diff --git a/test/Ocelot.AcceptanceTests/ConsulConfigurationInConsulTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs similarity index 97% rename from test/Ocelot.AcceptanceTests/ConsulConfigurationInConsulTests.cs rename to test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs index 978626c14..d97d98c09 100644 --- a/test/Ocelot.AcceptanceTests/ConsulConfigurationInConsulTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs @@ -6,474 +6,474 @@ using Ocelot.Cache; using Ocelot.Configuration.File; using System.Text; - -namespace Ocelot.AcceptanceTests -{ - public class ConsulConfigurationInConsulTests : IDisposable - { - private IWebHost _builder; - private readonly Steps _steps; - private IWebHost _fakeConsulBuilder; - private FileConfiguration _config; - private readonly List _consulServices; - - public ConsulConfigurationInConsulTests() - { - _consulServices = new List(); - _steps = new Steps(); - } - - [Fact] - public void should_return_response_200_with_simple_url() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = servicePort, - }, - }, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - - this.Given(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, string.Empty)) - .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{servicePort}", string.Empty, 200, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_load_configuration_out_of_consul() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - - var consulConfig = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/status", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = servicePort, - }, - }, - UpstreamPathTemplate = "/cs/status", - UpstreamHttpMethod = new List {"Get"}, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => GivenTheConsulConfigurationIs(consulConfig)) - .And(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, string.Empty)) - .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{servicePort}", "/status", 200, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/cs/status")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_load_configuration_out_of_consul_if_it_is_changed() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - - var consulConfig = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/status", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = servicePort, - }, - }, - UpstreamPathTemplate = "/cs/status", - UpstreamHttpMethod = new List {"Get"}, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - var secondConsulConfig = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/status", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = servicePort, - }, - }, - UpstreamPathTemplate = "/cs/status/awesome", - UpstreamHttpMethod = new List {"Get"}, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => GivenTheConsulConfigurationIs(consulConfig)) - .And(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, string.Empty)) - .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{servicePort}", "/status", 200, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/cs/status")) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .When(x => GivenTheConsulConfigurationIs(secondConsulConfig)) - .Then(x => ThenTheConfigIsUpdatedInOcelot()) - .BDDfy(); - } - - [Fact] - public void should_handle_request_to_consul_for_downstream_service_and_make_request_no_re_routes_and_rate_limit() - { - var consulPort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = downstreamServicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var consulConfig = new FileConfiguration - { - DynamicRoutes = new List - { - new() - { - ServiceName = serviceName, - RateLimitRule = new FileRateLimitRule - { - EnableRateLimiting = true, - ClientWhitelist = new List(), - Limit = 3, - Period = "1s", - PeriodTimespan = 1000, - }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - RateLimitOptions = new FileRateLimitOptions - { - ClientIdHeader = "ClientId", - DisableRateLimitHeaders = false, - QuotaExceededMessage = string.Empty, - RateLimitCounterPrefix = string.Empty, - HttpStatusCode = 428, - }, - DownstreamScheme = "http", - }, - }; - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/something", 200, "Hello from Laura")) - .And(x => GivenTheConsulConfigurationIs(consulConfig)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/web/something", 1)) - .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/web/something", 2)) - .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/web/something", 1)) - .Then(x => _steps.ThenTheStatusCodeShouldBe(428)) - .BDDfy(); - } - - private void ThenTheConfigIsUpdatedInOcelot() - { - var result = Wait.WaitFor(20000).Until(() => - { - try - { - _steps.WhenIGetUrlOnTheApiGateway("/cs/status/awesome"); - _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK); - _steps.ThenTheResponseBodyShouldBe("Hello from Laura"); - return true; - } - catch (Exception) - { - return false; - } - }); - result.ShouldBeTrue(); - } - - private void GivenTheConsulConfigurationIs(FileConfiguration config) - { - _config = config; - } - - private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) - { - foreach (var serviceEntry in serviceEntries) - { - _consulServices.Add(serviceEntry); - } - } - - private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url, string serviceName) - { - _fakeConsulBuilder = new WebHostBuilder() - .UseUrls(url) - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .UseUrls(url) - .Configure(app => - { - app.Run(async context => - { - if (context.Request.Method.ToLower() == "get" && context.Request.Path.Value == "/v1/kv/InternalConfiguration") - { - var json = JsonConvert.SerializeObject(_config); - - var bytes = Encoding.UTF8.GetBytes(json); - - var base64 = Convert.ToBase64String(bytes); - - var kvp = new FakeConsulGetResponse(base64); - json = JsonConvert.SerializeObject(new[] { kvp }); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - else if (context.Request.Method.ToLower() == "put" && context.Request.Path.Value == "/v1/kv/InternalConfiguration") - { - try - { - var reader = new StreamReader(context.Request.Body); - - // Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead. - // var json = reader.ReadToEnd(); - var json = await reader.ReadToEndAsync(); - - _config = JsonConvert.DeserializeObject(json); - - var response = JsonConvert.SerializeObject(true); - - await context.Response.WriteAsync(response); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - else if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") - { - var json = JsonConvert.SerializeObject(_consulServices); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - }) - .Build(); - - _fakeConsulBuilder.Start(); - } - - public class FakeConsulGetResponse - { - public FakeConsulGetResponse(string value) - { - Value = value; - } - - public int CreateIndex => 100; - public int ModifyIndex => 200; - public int LockIndex => 200; - public string Key => "InternalConfiguration"; - public int Flags => 0; - public string Value { get; } - public string Session => "adf4238a-882b-9ddc-4a9d-5b6758e4159e"; - } - - private void GivenThereIsAServiceRunningOn(string url, string basePath, int statusCode, string responseBody) - { - _builder = new WebHostBuilder() - .UseUrls(url) - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .UseUrls(url) - .Configure(app => - { - app.UsePathBase(basePath); - - app.Run(async context => - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - }); - }) - .Build(); - - _builder.Start(); - } - - public void Dispose() - { - _builder?.Dispose(); - _steps.Dispose(); - } - - private class FakeCache : IOcelotCache - { - public void Add(string key, FileConfiguration value, TimeSpan ttl, string region) - { - throw new NotImplementedException(); - } - - public FileConfiguration Get(string key, string region) - { - throw new NotImplementedException(); - } - - public void ClearRegion(string region) - { - throw new NotImplementedException(); - } - - public void AddAndDelete(string key, FileConfiguration value, TimeSpan ttl, string region) - { - throw new NotImplementedException(); - } - } - } -} + +namespace Ocelot.AcceptanceTests.ServiceDiscovery +{ + public class ConsulConfigurationInConsulTests : IDisposable + { + private IWebHost _builder; + private readonly Steps _steps; + private IWebHost _fakeConsulBuilder; + private FileConfiguration _config; + private readonly List _consulServices; + + public ConsulConfigurationInConsulTests() + { + _consulServices = new List(); + _steps = new Steps(); + } + + [Fact] + public void should_return_response_200_with_simple_url() + { + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = servicePort, + }, + }, + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + + this.Given(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, string.Empty)) + .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{servicePort}", string.Empty, 200, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void should_load_configuration_out_of_consul() + { + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + + var consulConfig = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/status", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = servicePort, + }, + }, + UpstreamPathTemplate = "/cs/status", + UpstreamHttpMethod = new List {"Get"}, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + this.Given(x => GivenTheConsulConfigurationIs(consulConfig)) + .And(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, string.Empty)) + .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{servicePort}", "/status", 200, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/cs/status")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void should_load_configuration_out_of_consul_if_it_is_changed() + { + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + + var consulConfig = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/status", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = servicePort, + }, + }, + UpstreamPathTemplate = "/cs/status", + UpstreamHttpMethod = new List {"Get"}, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + var secondConsulConfig = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/status", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = servicePort, + }, + }, + UpstreamPathTemplate = "/cs/status/awesome", + UpstreamHttpMethod = new List {"Get"}, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + this.Given(x => GivenTheConsulConfigurationIs(consulConfig)) + .And(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, string.Empty)) + .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{servicePort}", "/status", 200, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/cs/status")) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .When(x => GivenTheConsulConfigurationIs(secondConsulConfig)) + .Then(x => ThenTheConfigIsUpdatedInOcelot()) + .BDDfy(); + } + + [Fact] + public void should_handle_request_to_consul_for_downstream_service_and_make_request_no_re_routes_and_rate_limit() + { + var consulPort = PortFinder.GetRandomPort(); + const string serviceName = "web"; + var downstreamServicePort = PortFinder.GetRandomPort(); + var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = "localhost", + Port = downstreamServicePort, + ID = "web_90_0_2_224_8080", + Tags = new[] { "version-v1" }, + }, + }; + + var consulConfig = new FileConfiguration + { + DynamicRoutes = new List + { + new() + { + ServiceName = serviceName, + RateLimitRule = new FileRateLimitRule + { + EnableRateLimiting = true, + ClientWhitelist = new List(), + Limit = 3, + Period = "1s", + PeriodTimespan = 1000, + }, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + RateLimitOptions = new FileRateLimitOptions + { + ClientIdHeader = "ClientId", + DisableRateLimitHeaders = false, + QuotaExceededMessage = string.Empty, + RateLimitCounterPrefix = string.Empty, + HttpStatusCode = 428, + }, + DownstreamScheme = "http", + }, + }; + + var configuration = new FileConfiguration + { + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/something", 200, "Hello from Laura")) + .And(x => GivenTheConsulConfigurationIs(consulConfig)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningUsingConsulToStoreConfig()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/web/something", 1)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/web/something", 2)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/web/something", 1)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(428)) + .BDDfy(); + } + + private void ThenTheConfigIsUpdatedInOcelot() + { + var result = Wait.WaitFor(20000).Until(() => + { + try + { + _steps.WhenIGetUrlOnTheApiGateway("/cs/status/awesome"); + _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK); + _steps.ThenTheResponseBodyShouldBe("Hello from Laura"); + return true; + } + catch (Exception) + { + return false; + } + }); + result.ShouldBeTrue(); + } + + private void GivenTheConsulConfigurationIs(FileConfiguration config) + { + _config = config; + } + + private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) + { + foreach (var serviceEntry in serviceEntries) + { + _consulServices.Add(serviceEntry); + } + } + + private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url, string serviceName) + { + _fakeConsulBuilder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.Run(async context => + { + if (context.Request.Method.ToLower() == "get" && context.Request.Path.Value == "/v1/kv/InternalConfiguration") + { + var json = JsonConvert.SerializeObject(_config); + + var bytes = Encoding.UTF8.GetBytes(json); + + var base64 = Convert.ToBase64String(bytes); + + var kvp = new FakeConsulGetResponse(base64); + json = JsonConvert.SerializeObject(new[] { kvp }); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + else if (context.Request.Method.ToLower() == "put" && context.Request.Path.Value == "/v1/kv/InternalConfiguration") + { + try + { + var reader = new StreamReader(context.Request.Body); + + // Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead. + // var json = reader.ReadToEnd(); + var json = await reader.ReadToEndAsync(); + + _config = JsonConvert.DeserializeObject(json); + + var response = JsonConvert.SerializeObject(true); + + await context.Response.WriteAsync(response); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + else if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") + { + var json = JsonConvert.SerializeObject(_consulServices); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + }) + .Build(); + + _fakeConsulBuilder.Start(); + } + + public class FakeConsulGetResponse + { + public FakeConsulGetResponse(string value) + { + Value = value; + } + + public int CreateIndex => 100; + public int ModifyIndex => 200; + public int LockIndex => 200; + public string Key => "InternalConfiguration"; + public int Flags => 0; + public string Value { get; } + public string Session => "adf4238a-882b-9ddc-4a9d-5b6758e4159e"; + } + + private void GivenThereIsAServiceRunningOn(string url, string basePath, int statusCode, string responseBody) + { + _builder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.UsePathBase(basePath); + + app.Run(async context => + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + }); + }) + .Build(); + + _builder.Start(); + } + + public void Dispose() + { + _builder?.Dispose(); + _steps.Dispose(); + } + + private class FakeCache : IOcelotCache + { + public void Add(string key, FileConfiguration value, TimeSpan ttl, string region) + { + throw new NotImplementedException(); + } + + public FileConfiguration Get(string key, string region) + { + throw new NotImplementedException(); + } + + public void ClearRegion(string region) + { + throw new NotImplementedException(); + } + + public void AddAndDelete(string key, FileConfiguration value, TimeSpan ttl, string region) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs index d25c2075b..b98f93c0d 100644 --- a/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulServiceDiscoveryTests.cs @@ -1,744 +1,527 @@ using Consul; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Logging; +using Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; -namespace Ocelot.AcceptanceTests.ServiceDiscovery +namespace Ocelot.AcceptanceTests.ServiceDiscovery; + +public sealed class ConsulServiceDiscoveryTests : Steps, IDisposable { - public class ConsulServiceDiscoveryTests : IDisposable + private readonly List _consulServices; + private readonly List _consulNodes; + private int _counterOne; + private int _counterTwo; + private int _counterConsul; + private int _counterNodes; + private static readonly object SyncLock = new(); + private string _downstreamPath; + private string _receivedToken; + private readonly ServiceHandler _serviceHandler; + private readonly ServiceHandler _serviceHandler2; + private readonly ServiceHandler _consulHandler; + + public ConsulServiceDiscoveryTests() { - private readonly Steps _steps; - private readonly List _consulServices; - private int _counterOne; - private int _counterTwo; - private int _counterConsul; - private static readonly object SyncLock = new(); - private string _downstreamPath; - private string _receivedToken; - private readonly ServiceHandler _serviceHandler; - private readonly ServiceHandler _serviceHandler2; - private readonly ServiceHandler _consulHandler; - - public ConsulServiceDiscoveryTests() - { - _serviceHandler = new ServiceHandler(); - _serviceHandler2 = new ServiceHandler(); - _consulHandler = new ServiceHandler(); - _steps = new Steps(); - _consulServices = new List(); - } + _serviceHandler = new ServiceHandler(); + _serviceHandler2 = new ServiceHandler(); + _consulHandler = new ServiceHandler(); + _consulServices = new(); + _consulNodes = new(); + } - [Fact] - public void should_use_consul_service_discovery_and_load_balance_request() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort1 = PortFinder.GetRandomPort(); - var servicePort2 = PortFinder.GetRandomPort(); - var serviceName = "product"; - var downstreamServiceOneUrl = $"http://localhost:{servicePort1}"; - var downstreamServiceTwoUrl = $"http://localhost:{servicePort2}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort1, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort2, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) - .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 50)) - .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) - .BDDfy(); - } + public override void Dispose() + { + _serviceHandler?.Dispose(); + _serviceHandler2?.Dispose(); + _consulHandler?.Dispose(); + } - [Fact] - public void should_handle_request_to_consul_for_downstream_service_and_make_request() - { - var consulPort = PortFinder.GetRandomPort(); - var servicePort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServiceOneUrl = $"http://localhost:{servicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/home", - DownstreamScheme = "http", - UpstreamPathTemplate = "/home", - UpstreamHttpMethod = new List { "Get", "Options" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/home")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + [Fact] + public void Should_use_consul_service_discovery_and_load_balance_request() + { + const string serviceName = "product"; + var consulPort = PortFinder.GetRandomPort(); + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var serviceEntryOne = GivenServiceEntry(port1, serviceName: serviceName); + var serviceEntryTwo = GivenServiceEntry(port2, serviceName: serviceName); + var route = GivenRoute(serviceName: serviceName); + var configuration = GivenServiceDiscovery(consulPort, route); + this.Given(x => x.GivenProductServiceOneIsRunning(DownstreamUrl(port1), 200)) + .And(x => x.GivenProductServiceTwoIsRunning(DownstreamUrl(port2), 200)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 50)) + .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) .BDDfy(); - } + } - [Fact] - public void should_handle_request_to_consul_for_downstream_service_and_make_request_no_re_routes() - { - var consulPort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = downstreamServicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - DownstreamScheme = "http", - HttpHandlerOptions = new FileHttpHandlerOptions - { - AllowAutoRedirect = true, - UseCookieContainer = true, - UseTracing = false, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/something", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + [Fact] + public void Should_handle_request_to_consul_for_downstream_service_and_make_request() + { + const string serviceName = "web"; + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + var serviceEntryOne = GivenServiceEntry(servicePort, "localhost", "web_90_0_2_224_8080", new[] { "version-v1" }, serviceName); + var route = GivenRoute("/api/home", "/home", serviceName, httpMethods: new[] { "Get", "Options" }); + var configuration = GivenServiceDiscovery(consulPort, route); + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(servicePort), "/api/home", HttpStatusCode.OK, "Hello from Laura")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/web/something")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) + .When(x => WhenIGetUrlOnTheApiGateway("/home")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); - } + } - [Fact] - public void should_use_consul_service_discovery_and_load_balance_request_no_re_routes() + [Fact] + public void Should_handle_request_to_consul_for_downstream_service_and_make_request_no_re_routes() + { + const string serviceName = "web"; + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + var serviceEntry = GivenServiceEntry(servicePort, "localhost", "web_90_0_2_224_8080", new[] { "version-v1" }, serviceName); + + var configuration = GivenServiceDiscovery(consulPort); + configuration.GlobalConfiguration.DownstreamScheme = "http"; + configuration.GlobalConfiguration.HttpHandlerOptions = new() { - var consulPort = PortFinder.GetRandomPort(); - var serviceName = "product"; - var serviceOnePort = PortFinder.GetRandomPort(); - var serviceTwoPort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{serviceOnePort}"; - var downstreamServiceTwoUrl = $"http://localhost:{serviceTwoPort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = serviceOnePort, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = serviceTwoPort, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - DownstreamScheme = "http", - }, - }; - - this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) - .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes($"/{serviceName}/", 50)) - .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) - .BDDfy(); - } + AllowAutoRedirect = true, + UseCookieContainer = true, + UseTracing = false, + }; - [Fact] - public void should_use_token_to_make_request_to_consul() - { - var token = "abctoken"; - var consulPort = PortFinder.GetRandomPort(); - var serviceName = "web"; - var servicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{servicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort, - ID = "web_90_0_2_224_8080", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/home", - DownstreamScheme = "http", - UpstreamPathTemplate = "/home", - UpstreamHttpMethod = new List { "Get", "Options" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - Token = token, - }, - }, - }; - - this.Given(_ => GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(_ => _steps.GivenThereIsAConfiguration(configuration)) - .And(_ => _steps.GivenOcelotIsRunningWithConsul()) - .When(_ => _steps.WhenIGetUrlOnTheApiGateway("/home")) - .Then(_ => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(_ => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(_ => ThenTheTokenIs(token)) - .BDDfy(); - } + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(servicePort), "/something", HttpStatusCode.OK, "Hello from Laura")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntry)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) + .When(x => WhenIGetUrlOnTheApiGateway("/web/something")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } - [Fact] - public void should_send_request_to_service_after_it_becomes_available_in_consul() - { - var consulPort = PortFinder.GetRandomPort(); - var serviceName = "product"; - var servicePort1 = PortFinder.GetRandomPort(); - var servicePort2 = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{servicePort1}"; - var downstreamServiceTwoUrl = $"http://localhost:{servicePort2}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort1, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = servicePort2, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) - .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .And(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) - .And(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) - .And(x => WhenIRemoveAService(serviceEntryTwo)) - .And(x => GivenIResetCounters()) - .And(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) - .And(x => ThenOnlyOneServiceHasBeenCalled()) - .And(x => WhenIAddAServiceBackIn(serviceEntryTwo)) - .And(x => GivenIResetCounters()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) - .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) - .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) - .BDDfy(); - } + [Fact] + public void Should_use_consul_service_discovery_and_load_balance_request_no_re_routes() + { + const string serviceName = "product"; + var consulPort = PortFinder.GetRandomPort(); + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var serviceEntry1 = GivenServiceEntry(port1, serviceName: serviceName); + var serviceEntry2 = GivenServiceEntry(port2, serviceName: serviceName); + + var configuration = GivenServiceDiscovery(consulPort); + configuration.GlobalConfiguration.LoadBalancerOptions = new() { Type = nameof(LeastConnection) }; + configuration.GlobalConfiguration.DownstreamScheme = "http"; + + this.Given(x => x.GivenProductServiceOneIsRunning(DownstreamUrl(port1), 200)) + .And(x => x.GivenProductServiceTwoIsRunning(DownstreamUrl(port2), 200)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntry1, serviceEntry2)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimes($"/{serviceName}/", 50)) + .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(50)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(24, 26)) + .BDDfy(); + } - [Fact] - public void should_handle_request_to_poll_consul_for_downstream_service_and_make_request() - { - var consulPort = PortFinder.GetRandomPort(); - const string serviceName = "web"; - var downstreamServicePort = PortFinder.GetRandomPort(); - var downstreamServiceOneUrl = $"http://localhost:{downstreamServicePort}"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = "localhost", - Port = downstreamServicePort, - ID = $"web_90_0_2_224_{downstreamServicePort}", - Tags = new[] { "version-v1" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/home", - DownstreamScheme = "http", - UpstreamPathTemplate = "/home", - UpstreamHttpMethod = new List { "Get", "Options" }, - ServiceName = serviceName, - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "LeastConnection" }, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - Type = "PollConsul", - PollingInterval = 0, - Namespace = string.Empty, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul()) - .When(x => _steps.WhenIGetUrlOnTheApiGatewayWaitingForTheResponseToBeOk("/home")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + [Fact] + public void Should_use_token_to_make_request_to_consul() + { + const string serviceName = "web"; + const string token = "abctoken"; + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + var serviceEntry = GivenServiceEntry(servicePort, "localhost", "web_90_0_2_224_8080", new[] { "version-v1" }, serviceName); + var route = GivenRoute("/api/home", "/home", serviceName, httpMethods: new[] { "Get", "Options" }); + + var configuration = GivenServiceDiscovery(consulPort, route); + configuration.GlobalConfiguration.ServiceDiscoveryProvider.Token = token; + + this.Given(_ => GivenThereIsAServiceRunningOn(DownstreamUrl(servicePort), "/api/home", HttpStatusCode.OK, "Hello from Laura")) + .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntry)) + .And(_ => GivenThereIsAConfiguration(configuration)) + .And(_ => GivenOcelotIsRunningWithConsul()) + .When(_ => WhenIGetUrlOnTheApiGateway("/home")) + .Then(_ => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(_ => ThenTheTokenIs(token)) .BDDfy(); - } + } - [Theory] - [Trait("PR", "1944")] - [Trait("Issues", "849 1496")] - [InlineData("LeastConnection")] - [InlineData("RoundRobin")] - [InlineData("NoLoadBalancer")] - [InlineData("CookieStickySessions")] - public void Should_use_consul_service_discovery_based_on_upstream_host(string loadBalancerType) - { - // Simulate two DIFFERENT downstream services (e.g. product services for US and EU markets) - // with different ServiceNames (e.g. product-us and product-eu), - // UpstreamHost is used to determine which ServiceName to use when making a request to Consul (e.g. Host: us-shop goes to product-us) - var consulPort = PortFinder.GetRandomPort(); - var servicePortUS = PortFinder.GetRandomPort(); - var servicePortEU = PortFinder.GetRandomPort(); - var serviceNameUS = "product-us"; - var serviceNameEU = "product-eu"; - var downstreamServiceUrlUS = $"http://localhost:{servicePortUS}"; - var downstreamServiceUrlEU = $"http://localhost:{servicePortEU}"; - var upstreamHostUS = "us-shop"; - var upstreamHostEU = "eu-shop"; - var publicUrlUS = $"http://{upstreamHostUS}"; - var publicUrlEU = $"http://{upstreamHostEU}"; - var responseBodyUS = "Phone chargers with US plug"; - var responseBodyEU = "Phone chargers with EU plug"; - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryUS = new ServiceEntry - { - Service = new AgentService - { - Service = serviceNameUS, - Address = "localhost", - Port = servicePortUS, - ID = Guid.NewGuid().ToString(), - Tags = new string[] { "US" }, - }, - }; - var serviceEntryEU = new ServiceEntry - { - Service = new AgentService - { - Service = serviceNameEU, - Address = "localhost", - Port = servicePortEU, - ID = Guid.NewGuid().ToString(), - Tags = new string[] { "EU" }, - }, - }; - - var configuration = new FileConfiguration - { - Routes = new() - { - new() - { - DownstreamPathTemplate = "/products", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new() { "Get" }, - UpstreamHost = upstreamHostUS, - ServiceName = serviceNameUS, - LoadBalancerOptions = new() { Type = loadBalancerType }, - }, - new() - { - DownstreamPathTemplate = "/products", - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new() {"Get" }, - UpstreamHost = upstreamHostEU, - ServiceName = serviceNameEU, - LoadBalancerOptions = new() { Type = loadBalancerType }, - }, - }, - GlobalConfiguration = new() - { - ServiceDiscoveryProvider = new() - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - }, - }, - }; - - // Ocelot request for http://us-shop/ should find 'product-us' in Consul, call /products and return "Phone chargers with US plug" - // Ocelot request for http://eu-shop/ should find 'product-eu' in Consul, call /products and return "Phone chargers with EU plug" - this.Given(x => x._serviceHandler.GivenThereIsAServiceRunningOn(downstreamServiceUrlUS, "/products", MapGet("/products", responseBodyUS))) - .And(x => x._serviceHandler2.GivenThereIsAServiceRunningOn(downstreamServiceUrlEU, "/products", MapGet("/products", responseBodyEU))) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) - .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryUS, serviceEntryEU)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithConsul(publicUrlUS, publicUrlEU)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop for the first time") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(1)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop for the first time") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(2)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop again") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(3)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop again") - .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(4)) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) - .BDDfy(); - } + [Fact] + public void Should_send_request_to_service_after_it_becomes_available_in_consul() + { + const string serviceName = "product"; + var consulPort = PortFinder.GetRandomPort(); + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var serviceEntry1 = GivenServiceEntry(port1, serviceName: serviceName); + var serviceEntry2 = GivenServiceEntry(port2, serviceName: serviceName); + var route = GivenRoute(serviceName: serviceName); + var configuration = GivenServiceDiscovery(consulPort, route); + this.Given(x => x.GivenProductServiceOneIsRunning(DownstreamUrl(port1), 200)) + .And(x => x.GivenProductServiceTwoIsRunning(DownstreamUrl(port2), 200)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntry1, serviceEntry2)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) + .And(x => WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) + .And(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) + .And(x => WhenIRemoveAService(serviceEntry2)) + .And(x => GivenIResetCounters()) + .And(x => WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) + .And(x => ThenOnlyOneServiceHasBeenCalled()) + .And(x => WhenIAddAServiceBackIn(serviceEntry2)) + .And(x => GivenIResetCounters()) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 10)) + .Then(x => x.ThenTheTwoServicesShouldHaveBeenCalledTimes(10)) + .And(x => x.ThenBothServicesCalledRealisticAmountOfTimes(4, 6)) + .BDDfy(); + } - private void ThenTheTokenIs(string token) - { - _receivedToken.ShouldBe(token); - } + [Fact] + public void Should_handle_request_to_poll_consul_for_downstream_service_and_make_request() + { + const string serviceName = "web"; + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); + var serviceEntry = GivenServiceEntry(servicePort, "localhost", $"web_90_0_2_224_{servicePort}", new[] { "version-v1" }, serviceName); + var route = GivenRoute("/api/home", "/home", serviceName, httpMethods: new[] { "Get", "Options" }); + var configuration = GivenServiceDiscovery(consulPort, route); + + var sd = configuration.GlobalConfiguration.ServiceDiscoveryProvider; + sd.Type = nameof(PollConsul); + sd.PollingInterval = 0; + sd.Namespace = string.Empty; + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(servicePort), "/api/home", HttpStatusCode.OK, "Hello from Laura")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntry)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) + .When(x => WhenIGetUrlOnTheApiGatewayWaitingForTheResponseToBeOk("/home")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } - private void WhenIAddAServiceBackIn(ServiceEntry serviceEntryTwo) - { - _consulServices.Add(serviceEntryTwo); - } + [Theory] + [Trait("PR", "1944")] + [Trait("Bugs", "849 1496")] + [InlineData(nameof(LeastConnection))] + [InlineData(nameof(RoundRobin))] + [InlineData(nameof(NoLoadBalancer))] + [InlineData(nameof(CookieStickySessions))] + public void Should_use_consul_service_discovery_based_on_upstream_host(string loadBalancerType) + { + // Simulate two DIFFERENT downstream services (e.g. product services for US and EU markets) + // with different ServiceNames (e.g. product-us and product-eu), + // UpstreamHost is used to determine which ServiceName to use when making a request to Consul (e.g. Host: us-shop goes to product-us) + const string serviceNameUS = "product-us"; + const string serviceNameEU = "product-eu"; + var consulPort = PortFinder.GetRandomPort(); + var servicePortUS = PortFinder.GetRandomPort(); + var servicePortEU = PortFinder.GetRandomPort(); + const string upstreamHostUS = "us-shop"; + const string upstreamHostEU = "eu-shop"; + var publicUrlUS = $"http://{upstreamHostUS}"; + var publicUrlEU = $"http://{upstreamHostEU}"; + const string responseBodyUS = "Phone chargers with US plug"; + const string responseBodyEU = "Phone chargers with EU plug"; + var serviceEntryUS = GivenServiceEntry(servicePortUS, serviceName: serviceNameUS, tags: new[] { "US" }); + var serviceEntryEU = GivenServiceEntry(servicePortEU, serviceName: serviceNameEU, tags: new[] { "EU" }); + var routeUS = GivenRoute("/products", "/", serviceNameUS, loadBalancerType, upstreamHostUS); + var routeEU = GivenRoute("/products", "/", serviceNameEU, loadBalancerType, upstreamHostEU); + var configuration = GivenServiceDiscovery(consulPort, routeUS, routeEU); + + // Ocelot request for http://us-shop/ should find 'product-us' in Consul, call /products and return "Phone chargers with US plug" + // Ocelot request for http://eu-shop/ should find 'product-eu' in Consul, call /products and return "Phone chargers with EU plug" + this.Given(x => x._serviceHandler.GivenThereIsAServiceRunningOn(DownstreamUrl(servicePortUS), "/products", MapGet("/products", responseBodyUS))) + .And(x => x._serviceHandler2.GivenThereIsAServiceRunningOn(DownstreamUrl(servicePortEU), "/products", MapGet("/products", responseBodyEU))) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryUS, serviceEntryEU)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul(publicUrlUS, publicUrlEU)) + .When(x => WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop for the first time") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(1)) + .And(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(responseBodyUS)) + .When(x => WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop for the first time") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(2)) + .And(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(responseBodyEU)) + .When(x => WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop again") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(3)) + .And(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(responseBodyUS)) + .When(x => WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop again") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(4)) + .And(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(responseBodyEU)) + .BDDfy(); + } - private void ThenOnlyOneServiceHasBeenCalled() - { - _counterOne.ShouldBe(10); - _counterTwo.ShouldBe(0); - } + [Fact] + [Trait("Bug", "954")] + public void Should_return_service_address_by_overridden_service_builder_when_there_is_a_node() + { + const string serviceName = "OpenTestService"; + var consulPort = PortFinder.GetRandomPort(); + var servicePort = PortFinder.GetRandomPort(); // 9999 + var serviceEntry = GivenServiceEntry(servicePort, + id: "OPEN_TEST_01", + serviceName: serviceName, + tags: new[] { serviceName }); + var serviceNode = new Node() { Name = "n1" }; // cornerstone of the bug + serviceEntry.Node = serviceNode; + var route = GivenRoute("/api/{url}", "/open/{url}", serviceName, httpMethods: new[] { "POST", "GET" }); + var configuration = GivenServiceDiscovery(consulPort, route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(servicePort), "/api/home", HttpStatusCode.OK, "Hello from Raman")) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(DownstreamUrl(consulPort))) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntry)) + .And(x => x.GivenTheServiceNodesAreRegisteredWithConsul(serviceNode)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithConsul()) // default services registration results with the bug: "n1" host issue + .When(x => WhenIGetUrlOnTheApiGateway("/open/home")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.BadGateway)) + .And(x => ThenTheResponseBodyShouldBe("")) + .And(x => ThenConsulShouldHaveBeenCalledTimes(1)) + .And(x => ThenConsulNodesShouldHaveBeenCalledTimes(1)) + + // Override default service builder + .Given(x => GivenOcelotIsRunningWithServices(WithOverriddenConsulServiceBuilder)) + .When(x => WhenIGetUrlOnTheApiGateway("/open/home")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Raman")) + .And(x => ThenConsulShouldHaveBeenCalledTimes(2)) + .And(x => ThenConsulNodesShouldHaveBeenCalledTimes(2)) + .BDDfy(); + } - private void WhenIRemoveAService(ServiceEntry serviceEntryTwo) - { - _consulServices.Remove(serviceEntryTwo); - } + private static void WithOverriddenConsulServiceBuilder(IServiceCollection services) + => services.AddOcelot().AddConsul(); - private void GivenIResetCounters() - { - _counterOne = 0; - _counterTwo = 0; - _counterConsul = 0; - } + public class MyConsulServiceBuilder : DefaultConsulServiceBuilder + { + public MyConsulServiceBuilder(Func configurationFactory, IConsulClientFactory clientFactory, IOcelotLoggerFactory loggerFactory) + : base(configurationFactory, clientFactory, loggerFactory) { } - private void ThenBothServicesCalledRealisticAmountOfTimes(int bottom, int top) - { - _counterOne.ShouldBeInRange(bottom, top); - _counterOne.ShouldBeInRange(bottom, top); - } + protected override string GetDownstreamHost(ServiceEntry entry, Node node) => entry.Service.Address; + } - private void ThenTheTwoServicesShouldHaveBeenCalledTimes(int expected) + private static ServiceEntry GivenServiceEntry(int port, string address = null, string id = null, string[] tags = null, [CallerMemberName] string serviceName = null) => new() + { + Service = new AgentService { - var total = _counterOne + _counterTwo; - total.ShouldBe(expected); - } + Service = serviceName, + Address = address ?? "localhost", + Port = port, + ID = id ?? Guid.NewGuid().ToString(), + Tags = tags ?? Array.Empty(), + }, + }; + + private static FileRoute GivenRoute(string downstream = null, string upstream = null, [CallerMemberName] string serviceName = null, string loadBalancerType = null, string upstreamHost = null, string[] httpMethods = null) => new() + { + DownstreamPathTemplate = downstream ?? "/", + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamPathTemplate = upstream ?? "/", + UpstreamHttpMethod = httpMethods != null ? new(httpMethods) : new() { HttpMethods.Get }, + UpstreamHost = upstreamHost, + ServiceName = serviceName, + LoadBalancerOptions = new() { Type = loadBalancerType ?? nameof(LeastConnection) }, + }; + + private static FileConfiguration GivenServiceDiscovery(int consulPort, params FileRoute[] routes) + { + var config = GivenConfiguration(routes); + config.GlobalConfiguration.ServiceDiscoveryProvider = new() + { + Scheme = Uri.UriSchemeHttp, + Host = "localhost", + Port = consulPort, + Type = nameof(Provider.Consul.Consul), + }; + return config; + } + + private void ThenTheTokenIs(string token) + { + _receivedToken.ShouldBe(token); + } - private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) + private void WhenIAddAServiceBackIn(ServiceEntry serviceEntry) + { + _consulServices.Add(serviceEntry); + } + + private void ThenOnlyOneServiceHasBeenCalled() + { + _counterOne.ShouldBe(10); + _counterTwo.ShouldBe(0); + } + + private void WhenIRemoveAService(ServiceEntry serviceEntry) + { + _consulServices.Remove(serviceEntry); + } + + private void GivenIResetCounters() + { + _counterOne = 0; + _counterTwo = 0; + _counterConsul = 0; + } + + private void ThenBothServicesCalledRealisticAmountOfTimes(int bottom, int top) + { + _counterOne.ShouldBeInRange(bottom, top); + _counterOne.ShouldBeInRange(bottom, top); + } + + private void ThenTheTwoServicesShouldHaveBeenCalledTimes(int expected) + { + var total = _counterOne + _counterTwo; + total.ShouldBe(expected); + } + + private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) => _consulServices.AddRange(serviceEntries); + private void GivenTheServiceNodesAreRegisteredWithConsul(params Node[] nodes) => _consulNodes.AddRange(nodes); + + private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url) + { + _consulHandler.GivenThereIsAServiceRunningOn(url, async context => { - foreach (var serviceEntry in serviceEntries) + if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) { - _consulServices.Add(serviceEntry); + _receivedToken = values.First(); } - } - private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url) - { - _consulHandler.GivenThereIsAServiceRunningOn(url, async context => + // Parse the request path to get the service name + var pathMatch = Regex.Match(context.Request.Path.Value, "/v1/health/service/(?[^/]+)"); + if (pathMatch.Success) { - if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) - { - _receivedToken = values.First(); - } + _counterConsul++; + + // Use the parsed service name to filter the registered Consul services + var serviceName = pathMatch.Groups["serviceName"].Value; + var services = _consulServices.Where(x => x.Service.Service == serviceName).ToList(); + var json = JsonConvert.SerializeObject(services); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + return; + } - // Parse the request path to get the service name - var pathMatch = Regex.Match(context.Request.Path.Value, "/v1/health/service/(?[^/]+)"); - if (pathMatch.Success) - { - _counterConsul++; - - // Use the parsed service name to filter the registered Consul services - var serviceName = pathMatch.Groups["serviceName"].Value; - var services = _consulServices.Where(x => x.Service.Service == serviceName).ToList(); - var json = JsonConvert.SerializeObject(services); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - } + if (context.Request.Path.Value == "/v1/catalog/nodes") + { + _counterNodes++; + var json = JsonConvert.SerializeObject(_consulNodes); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + } - private void ThenConsulShouldHaveBeenCalledTimes(int expected) - { - _counterConsul.ShouldBe(expected); - } + private void ThenConsulShouldHaveBeenCalledTimes(int expected) => _counterConsul.ShouldBe(expected); + private void ThenConsulNodesShouldHaveBeenCalledTimes(int expected) => _counterNodes.ShouldBe(expected); - private void GivenProductServiceOneIsRunning(string url, int statusCode) + private void GivenProductServiceOneIsRunning(string url, int statusCode) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + try { - try - { - string response; - lock (SyncLock) - { - _counterOne++; - response = _counterOne.ToString(); - } - - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(response); - } - catch (Exception exception) + string response; + lock (SyncLock) { - await context.Response.WriteAsync(exception.StackTrace); + _counterOne++; + response = _counterOne.ToString(); } - }); - } - private void GivenProductServiceTwoIsRunning(string url, int statusCode) - { - _serviceHandler2.GivenThereIsAServiceRunningOn(url, async context => + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(response); + } + catch (Exception exception) { - try - { - string response; - lock (SyncLock) - { - _counterTwo++; - response = _counterTwo.ToString(); - } - - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(response); - } - catch (Exception exception) - { - await context.Response.WriteAsync(exception.StackTrace); - } - }); - } + await context.Response.WriteAsync(exception.StackTrace); + } + }); + } - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) + private void GivenProductServiceTwoIsRunning(string url, int statusCode) + { + _serviceHandler2.GivenThereIsAServiceRunningOn(url, async context => { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + try { - _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; - - if (_downstreamPath != basePath) - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync("downstream path didnt match base path"); - } - else + string response; + lock (SyncLock) { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); + _counterTwo++; + response = _counterTwo.ToString(); } - }); - } - private RequestDelegate MapGet(string path, string responseBody) => async context => + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(response); + } + catch (Exception exception) + { + await context.Response.WriteAsync(exception.StackTrace); + } + }); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, HttpStatusCode statusCode, string responseBody) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => { - var downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; - if (downstreamPath == path) + _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + + if (_downstreamPath != basePath) { - context.Response.StatusCode = 200; - await context.Response.WriteAsync(responseBody); + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + await context.Response.WriteAsync("Downstream path doesn't match base path"); } else { - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Not Found"); + context.Response.StatusCode = (int)statusCode; + await context.Response.WriteAsync(responseBody); } - }; + }); + } - public void Dispose() + private static RequestDelegate MapGet(string path, string responseBody) => async context => + { + var downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + if (downstreamPath == path) { - _serviceHandler?.Dispose(); - _serviceHandler2?.Dispose(); - _consulHandler?.Dispose(); - _steps.Dispose(); + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsync(responseBody); } - } + else + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + await context.Response.WriteAsync("Not Found"); + } + }; } diff --git a/test/Ocelot.AcceptanceTests/ConsulWebSocketTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulWebSocketTests.cs similarity index 97% rename from test/Ocelot.AcceptanceTests/ConsulWebSocketTests.cs rename to test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulWebSocketTests.cs index dceafc0d4..9afa1b154 100644 --- a/test/Ocelot.AcceptanceTests/ConsulWebSocketTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulWebSocketTests.cs @@ -5,343 +5,343 @@ using Ocelot.WebSockets; using System.Net.WebSockets; using System.Text; - -namespace Ocelot.AcceptanceTests -{ - public class ConsulWebSocketTests : IDisposable - { - private readonly List _secondRecieved; - private readonly List _firstRecieved; - private readonly List _serviceEntries; - private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - - public ConsulWebSocketTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - _firstRecieved = new List(); - _secondRecieved = new List(); - _serviceEntries = new List(); - } - - [Fact] - public void ShouldProxyWebsocketInputToDownstreamServiceAndUseServiceDiscoveryAndLoadBalancer() - { - var downstreamPort = PortFinder.GetRandomPort(); - var downstreamHost = "localhost"; - - var secondDownstreamPort = PortFinder.GetRandomPort(); - var secondDownstreamHost = "localhost"; - - var serviceName = "websockets"; - var consulPort = PortFinder.GetRandomPort(); - var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = downstreamHost, - Port = downstreamPort, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - var serviceEntryTwo = new ServiceEntry - { - Service = new AgentService - { - Service = serviceName, - Address = secondDownstreamHost, - Port = secondDownstreamPort, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - var config = new FileConfiguration - { - Routes = new List - { - new() - { - UpstreamPathTemplate = "/", - DownstreamPathTemplate = "/ws", - DownstreamScheme = "ws", - LoadBalancerOptions = new FileLoadBalancerOptions { Type = "RoundRobin" }, - ServiceName = serviceName, - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "http", - Host = "localhost", - Port = consulPort, - Type = "consul", - }, - }, - }; - - this.Given(_ => _steps.GivenThereIsAConfiguration(config)) - .And(_ => _steps.StartFakeOcelotWithWebSocketsWithConsul()) - .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) - .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .And(_ => StartFakeDownstreamService($"http://{downstreamHost}:{downstreamPort}", "/ws")) - .And(_ => StartSecondFakeDownstreamService($"http://{secondDownstreamHost}:{secondDownstreamPort}", "/ws")) - .When(_ => WhenIStartTheClients()) - .Then(_ => ThenBothDownstreamServicesAreCalled()) - .BDDfy(); - } - - private void ThenBothDownstreamServicesAreCalled() - { - _firstRecieved.Count.ShouldBe(10); - _firstRecieved.ForEach(x => - { - x.ShouldBe("test"); - }); - - _secondRecieved.Count.ShouldBe(10); - _secondRecieved.ForEach(x => - { - x.ShouldBe("chocolate"); - }); - } - - private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) - { - foreach (var serviceEntry in serviceEntries) - { - _serviceEntries.Add(serviceEntry); - } - } - - private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url, string serviceName) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") - { - var json = JsonConvert.SerializeObject(_serviceEntries); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - } - - private async Task WhenIStartTheClients() - { - var firstClient = StartClient("ws://localhost:5000/"); - - var secondClient = StartSecondClient("ws://localhost:5000/"); - - await Task.WhenAll(firstClient, secondClient); - } - - private async Task StartClient(string url) - { - IClientWebSocket client = new ClientWebSocketProxy(); - - await client.ConnectAsync(new Uri(url), CancellationToken.None); - - var sending = Task.Run(async () => - { - var line = "test"; - for (var i = 0; i < 10; i++) - { - var bytes = Encoding.UTF8.GetBytes(line); - - await client.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, - CancellationToken.None); - await Task.Delay(10); - } - - await client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); - }); - - var receiving = Task.Run(async () => - { - var buffer = new byte[1024 * 4]; - - while (true) - { - var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - - if (result.MessageType == WebSocketMessageType.Text) - { - _firstRecieved.Add(Encoding.UTF8.GetString(buffer, 0, result.Count)); - } - else if (result.MessageType == WebSocketMessageType.Close) - { - if (client.State != WebSocketState.Closed) - { - // Last version, the client state is CloseReceived - // Valid states are: Open, CloseReceived, CloseSent - await client.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); - } - - break; - } - } - }); - - await Task.WhenAll(sending, receiving); - } - - private async Task StartSecondClient(string url) - { - await Task.Delay(500); - - IClientWebSocket client = new ClientWebSocketProxy(); - - await client.ConnectAsync(new Uri(url), CancellationToken.None); - - var sending = Task.Run(async () => - { - var line = "test"; - for (var i = 0; i < 10; i++) - { - var bytes = Encoding.UTF8.GetBytes(line); - - await client.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, - CancellationToken.None); - await Task.Delay(10); - } - - await client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); - }); - - var receiving = Task.Run(async () => - { - var buffer = new byte[1024 * 4]; - - while (true) - { - var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - - if (result.MessageType == WebSocketMessageType.Text) - { - _secondRecieved.Add(Encoding.UTF8.GetString(buffer, 0, result.Count)); - } - else if (result.MessageType == WebSocketMessageType.Close) - { - if (client.State != WebSocketState.Closed) - { - // Last version, the client state is CloseReceived - // Valid states are: Open, CloseReceived, CloseSent - await client.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); - } - - break; - } - } - }); - - await Task.WhenAll(sending, receiving); - } - - private async Task StartFakeDownstreamService(string url, string path) - { - await _serviceHandler.StartFakeDownstreamService(url, async (context, next) => - { - if (context.Request.Path == path) - { - if (context.WebSockets.IsWebSocketRequest) - { - var webSocket = await context.WebSockets.AcceptWebSocketAsync(); - await Echo(webSocket); - } - else - { - context.Response.StatusCode = 400; - } - } - else - { - await next(); - } - }); - } - - private async Task StartSecondFakeDownstreamService(string url, string path) - { - await _serviceHandler.StartFakeDownstreamService(url, async (context, next) => - { - if (context.Request.Path == path) - { - if (context.WebSockets.IsWebSocketRequest) - { - var webSocket = await context.WebSockets.AcceptWebSocketAsync(); - await Message(webSocket); - } - else - { - context.Response.StatusCode = 400; - } - } - else - { - await next(); - } - }); - } - - private static async Task Echo(WebSocket webSocket) - { - try - { - var buffer = new byte[1024 * 4]; - - var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - - while (!result.CloseStatus.HasValue) - { - await webSocket.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None); - - result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - } - - await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - private static async Task Message(WebSocket webSocket) - { - try - { - var buffer = new byte[1024 * 4]; - - var bytes = Encoding.UTF8.GetBytes("chocolate"); - - var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - - while (!result.CloseStatus.HasValue) - { - await webSocket.SendAsync(new ArraySegment(bytes), result.MessageType, result.EndOfMessage, CancellationToken.None); - - result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - } - - await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - public void Dispose() - { - _serviceHandler?.Dispose(); + +namespace Ocelot.AcceptanceTests.ServiceDiscovery +{ + public class ConsulWebSocketTests : IDisposable + { + private readonly List _secondRecieved; + private readonly List _firstRecieved; + private readonly List _serviceEntries; + private readonly Steps _steps; + private readonly ServiceHandler _serviceHandler; + + public ConsulWebSocketTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + _firstRecieved = new List(); + _secondRecieved = new List(); + _serviceEntries = new List(); + } + + [Fact] + public void ShouldProxyWebsocketInputToDownstreamServiceAndUseServiceDiscoveryAndLoadBalancer() + { + var downstreamPort = PortFinder.GetRandomPort(); + var downstreamHost = "localhost"; + + var secondDownstreamPort = PortFinder.GetRandomPort(); + var secondDownstreamHost = "localhost"; + + var serviceName = "websockets"; + var consulPort = PortFinder.GetRandomPort(); + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryOne = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = downstreamHost, + Port = downstreamPort, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + var serviceEntryTwo = new ServiceEntry + { + Service = new AgentService + { + Service = serviceName, + Address = secondDownstreamHost, + Port = secondDownstreamPort, + ID = Guid.NewGuid().ToString(), + Tags = Array.Empty(), + }, + }; + + var config = new FileConfiguration + { + Routes = new List + { + new() + { + UpstreamPathTemplate = "/", + DownstreamPathTemplate = "/ws", + DownstreamScheme = "ws", + LoadBalancerOptions = new FileLoadBalancerOptions { Type = "RoundRobin" }, + ServiceName = serviceName, + }, + }, + GlobalConfiguration = new FileGlobalConfiguration + { + ServiceDiscoveryProvider = new FileServiceDiscoveryProvider + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + Type = "consul", + }, + }, + }; + + this.Given(_ => _steps.GivenThereIsAConfiguration(config)) + .And(_ => _steps.StartFakeOcelotWithWebSocketsWithConsul()) + .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) + .And(_ => StartFakeDownstreamService($"http://{downstreamHost}:{downstreamPort}", "/ws")) + .And(_ => StartSecondFakeDownstreamService($"http://{secondDownstreamHost}:{secondDownstreamPort}", "/ws")) + .When(_ => WhenIStartTheClients()) + .Then(_ => ThenBothDownstreamServicesAreCalled()) + .BDDfy(); + } + + private void ThenBothDownstreamServicesAreCalled() + { + _firstRecieved.Count.ShouldBe(10); + _firstRecieved.ForEach(x => + { + x.ShouldBe("test"); + }); + + _secondRecieved.Count.ShouldBe(10); + _secondRecieved.ForEach(x => + { + x.ShouldBe("chocolate"); + }); + } + + private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) + { + foreach (var serviceEntry in serviceEntries) + { + _serviceEntries.Add(serviceEntry); + } + } + + private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url, string serviceName) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") + { + var json = JsonConvert.SerializeObject(_serviceEntries); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + } + + private async Task WhenIStartTheClients() + { + var firstClient = StartClient("ws://localhost:5000/"); + + var secondClient = StartSecondClient("ws://localhost:5000/"); + + await Task.WhenAll(firstClient, secondClient); + } + + private async Task StartClient(string url) + { + IClientWebSocket client = new ClientWebSocketProxy(); + + await client.ConnectAsync(new Uri(url), CancellationToken.None); + + var sending = Task.Run(async () => + { + var line = "test"; + for (var i = 0; i < 10; i++) + { + var bytes = Encoding.UTF8.GetBytes(line); + + await client.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, + CancellationToken.None); + await Task.Delay(10); + } + + await client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + }); + + var receiving = Task.Run(async () => + { + var buffer = new byte[1024 * 4]; + + while (true) + { + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Text) + { + _firstRecieved.Add(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + else if (result.MessageType == WebSocketMessageType.Close) + { + if (client.State != WebSocketState.Closed) + { + // Last version, the client state is CloseReceived + // Valid states are: Open, CloseReceived, CloseSent + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + } + + break; + } + } + }); + + await Task.WhenAll(sending, receiving); + } + + private async Task StartSecondClient(string url) + { + await Task.Delay(500); + + IClientWebSocket client = new ClientWebSocketProxy(); + + await client.ConnectAsync(new Uri(url), CancellationToken.None); + + var sending = Task.Run(async () => + { + var line = "test"; + for (var i = 0; i < 10; i++) + { + var bytes = Encoding.UTF8.GetBytes(line); + + await client.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, + CancellationToken.None); + await Task.Delay(10); + } + + await client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + }); + + var receiving = Task.Run(async () => + { + var buffer = new byte[1024 * 4]; + + while (true) + { + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Text) + { + _secondRecieved.Add(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + else if (result.MessageType == WebSocketMessageType.Close) + { + if (client.State != WebSocketState.Closed) + { + // Last version, the client state is CloseReceived + // Valid states are: Open, CloseReceived, CloseSent + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + } + + break; + } + } + }); + + await Task.WhenAll(sending, receiving); + } + + private async Task StartFakeDownstreamService(string url, string path) + { + await _serviceHandler.StartFakeDownstreamService(url, async (context, next) => + { + if (context.Request.Path == path) + { + if (context.WebSockets.IsWebSocketRequest) + { + var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await Echo(webSocket); + } + else + { + context.Response.StatusCode = 400; + } + } + else + { + await next(); + } + }); + } + + private async Task StartSecondFakeDownstreamService(string url, string path) + { + await _serviceHandler.StartFakeDownstreamService(url, async (context, next) => + { + if (context.Request.Path == path) + { + if (context.WebSockets.IsWebSocketRequest) + { + var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await Message(webSocket); + } + else + { + context.Response.StatusCode = 400; + } + } + else + { + await next(); + } + }); + } + + private static async Task Echo(WebSocket webSocket) + { + try + { + var buffer = new byte[1024 * 4]; + + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + while (!result.CloseStatus.HasValue) + { + await webSocket.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None); + + result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + } + + await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + + private static async Task Message(WebSocket webSocket) + { + try + { + var buffer = new byte[1024 * 4]; + + var bytes = Encoding.UTF8.GetBytes("chocolate"); + + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + while (!result.CloseStatus.HasValue) + { + await webSocket.SendAsync(new ArraySegment(bytes), result.MessageType, result.EndOfMessage, CancellationToken.None); + + result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + } + + await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + + public void Dispose() + { + _serviceHandler?.Dispose(); _steps.Dispose(); - GC.SuppressFinalize(this); - } - } -} + GC.SuppressFinalize(this); + } + } +} diff --git a/test/Ocelot.UnitTests/Consul/ConsulFileConfigurationRepositoryTests.cs b/test/Ocelot.UnitTests/Consul/ConsulFileConfigurationRepositoryTests.cs index 2b2ee6557..0f8eaf192 100644 --- a/test/Ocelot.UnitTests/Consul/ConsulFileConfigurationRepositoryTests.cs +++ b/test/Ocelot.UnitTests/Consul/ConsulFileConfigurationRepositoryTests.cs @@ -5,6 +5,7 @@ using Ocelot.Configuration.File; using Ocelot.Logging; using Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; using Ocelot.Responses; using System.Text; diff --git a/test/Ocelot.UnitTests/Consul/ConsulServiceDiscoveryProviderTests.cs b/test/Ocelot.UnitTests/Consul/ConsulServiceDiscoveryProviderTests.cs deleted file mode 100644 index 7df0a2c60..000000000 --- a/test/Ocelot.UnitTests/Consul/ConsulServiceDiscoveryProviderTests.cs +++ /dev/null @@ -1,223 +0,0 @@ -using Consul; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Newtonsoft.Json; -using Ocelot.Logging; -using Ocelot.Provider.Consul; -using Ocelot.Values; -using ConsulProvider = Ocelot.Provider.Consul.Consul; - -namespace Ocelot.UnitTests.Consul -{ - public class ConsulServiceDiscoveryProviderTests : UnitTest, IDisposable - { - private IWebHost _fakeConsulBuilder; - private readonly List _serviceEntries; - private ConsulProvider _provider; - private readonly string _serviceName; - private readonly int _port; - private readonly string _consulHost; - private readonly string _consulScheme; - private readonly string _fakeConsulServiceDiscoveryUrl; - private List _services; - private readonly Mock _factory; - private readonly Mock _logger; - private string _receivedToken; - private readonly IConsulClientFactory _clientFactory; - - public ConsulServiceDiscoveryProviderTests() - { - _serviceName = "test"; - _port = 8500; - _consulHost = "localhost"; - _consulScheme = "http"; - _fakeConsulServiceDiscoveryUrl = $"{_consulScheme}://{_consulHost}:{_port}"; - _serviceEntries = new List(); - _factory = new Mock(); - _clientFactory = new ConsulClientFactory(); - _logger = new Mock(); - _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); - _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); - var config = new ConsulRegistryConfiguration(_consulScheme, _consulHost, _port, _serviceName, null); - _provider = new ConsulProvider(config, _factory.Object, _clientFactory); - } - - [Fact] - public void should_return_service_from_consul() - { - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = _serviceName, - Address = "localhost", - Port = 50881, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - this.Given(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(_fakeConsulServiceDiscoveryUrl, _serviceName)) - .And(x => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .When(x => WhenIGetTheServices()) - .Then(x => ThenTheCountIs(1)) - .BDDfy(); - } - - [Fact] - public void should_use_token() - { - var token = "test token"; - var config = new ConsulRegistryConfiguration(_consulScheme, _consulHost, _port, _serviceName, token); - _provider = new ConsulProvider(config, _factory.Object, _clientFactory); - - var serviceEntryOne = new ServiceEntry - { - Service = new AgentService - { - Service = _serviceName, - Address = "localhost", - Port = 50881, - ID = Guid.NewGuid().ToString(), - Tags = Array.Empty(), - }, - }; - - this.Given(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(_fakeConsulServiceDiscoveryUrl, _serviceName)) - .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) - .When(_ => WhenIGetTheServices()) - .Then(_ => ThenTheCountIs(1)) - .And(_ => ThenTheTokenIs(token)) - .BDDfy(); - } - - [Fact] - public void should_not_return_services_with_invalid_address() - { - var serviceEntryOne = GivenService(address: "http://localhost", port: 50881) - .ToServiceEntry(); - var serviceEntryTwo = GivenService(address: "http://localhost", port: 50888) - .ToServiceEntry(); - - this.Given(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(_fakeConsulServiceDiscoveryUrl, _serviceName)) - .And(x => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .When(x => WhenIGetTheServices()) - .Then(x => ThenTheCountIs(0)) - .And(x => ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(serviceEntryOne, serviceEntryTwo)) - .BDDfy(); - } - - [Fact] - public void should_not_return_services_with_empty_address() - { - var serviceEntryOne = GivenService(port: 50881) - .WithAddress(string.Empty) - .ToServiceEntry(); - var serviceEntryTwo = GivenService(port: 50888) - .WithAddress(null) - .ToServiceEntry(); - - this.Given(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(_fakeConsulServiceDiscoveryUrl, _serviceName)) - .And(x => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .When(x => WhenIGetTheServices()) - .Then(x => ThenTheCountIs(0)) - .And(x => ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(serviceEntryOne, serviceEntryTwo)) - .BDDfy(); - } - - [Fact] - public void should_not_return_services_with_invalid_port() - { - var serviceEntryOne = GivenService(port: -1) - .ToServiceEntry(); - var serviceEntryTwo = GivenService(port: 0) - .ToServiceEntry(); - - this.Given(x => GivenThereIsAFakeConsulServiceDiscoveryProvider(_fakeConsulServiceDiscoveryUrl, _serviceName)) - .And(x => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) - .When(x => WhenIGetTheServices()) - .Then(x => ThenTheCountIs(0)) - .And(x => ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(serviceEntryOne, serviceEntryTwo)) - .BDDfy(); - } - - private AgentService GivenService(string address = null, int? port = null, string id = null, string[] tags = null) - => new() - { - Service = _serviceName, - Address = address ?? "localhost", - Port = port ?? 123, - ID = id ?? Guid.NewGuid().ToString(), - Tags = tags ?? Array.Empty(), - }; - - private void ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(params ServiceEntry[] serviceEntries) - { - foreach (var entry in serviceEntries) - { - var service = entry.Service; - var expected = $"Unable to use service address: '{service.Address}' and port: {service.Port} as it is invalid for the service: '{service.Service}'. Address must contain host only e.g. 'localhost', and port must be greater than 0."; - _logger.Verify(x => x.LogWarning(It.Is>(y => y.Invoke() == expected)), Times.Once); - } - } - - private void ThenTheCountIs(int count) - { - _services.Count.ShouldBe(count); - } - - private void WhenIGetTheServices() - { - _services = _provider.GetAsync().GetAwaiter().GetResult(); - } - - private void ThenTheTokenIs(string token) - { - _receivedToken.ShouldBe(token); - } - - private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] serviceEntries) - { - foreach (var serviceEntry in serviceEntries) - { - _serviceEntries.Add(serviceEntry); - } - } - - private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url, string serviceName) - { - _fakeConsulBuilder = new WebHostBuilder() - .UseUrls(url) - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .UseUrls(url) - .Configure(app => - { - app.Run(async context => - { - if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") - { - if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) - { - _receivedToken = values.First(); - } - - var json = JsonConvert.SerializeObject(_serviceEntries); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - }) - .Build(); - - _fakeConsulBuilder.Start(); - } - - public void Dispose() - { - _fakeConsulBuilder?.Dispose(); - } - } -} diff --git a/test/Ocelot.UnitTests/Consul/ConsulTests.cs b/test/Ocelot.UnitTests/Consul/ConsulTests.cs new file mode 100644 index 000000000..b9009d488 --- /dev/null +++ b/test/Ocelot.UnitTests/Consul/ConsulTests.cs @@ -0,0 +1,209 @@ +using Consul; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Ocelot.Logging; +using Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; +using System.Runtime.CompilerServices; +using ConsulProvider = Ocelot.Provider.Consul.Consul; + +namespace Ocelot.UnitTests.Consul; + +public sealed class ConsulTests : UnitTest, IDisposable +{ + private readonly int _port; + private readonly string _consulHost; + private readonly string _consulScheme; + private readonly string _fakeConsulServiceDiscoveryUrl; + private readonly List _consulServiceEntries; + private readonly Mock _factory; + private readonly Mock _logger; + private IConsulClientFactory _clientFactory; + private IConsulServiceBuilder _serviceBuilder; + private ConsulRegistryConfiguration _config; + private IWebHost _fakeConsulBuilder; + private ConsulProvider _provider; + private string _receivedToken; + + public ConsulTests() + { + _port = 8500; + _consulHost = "localhost"; + _consulScheme = "http"; + _fakeConsulServiceDiscoveryUrl = $"{_consulScheme}://{_consulHost}:{_port}"; + _consulServiceEntries = new List(); + _factory = new Mock(); + _logger = new Mock(); + _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); + _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); + _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); + } + + public void Dispose() + { + _fakeConsulBuilder?.Dispose(); + } + + private void Arrange([CallerMemberName] string serviceName = null) + { + _config = new ConsulRegistryConfiguration(_consulScheme, _consulHost, _port, serviceName, null); + _clientFactory = new ConsulClientFactory(); + _serviceBuilder = new DefaultConsulServiceBuilder(() => _config, _clientFactory, _factory.Object); + _provider = new ConsulProvider(_config, _factory.Object, _clientFactory, _serviceBuilder); + } + + [Fact] + public async Task Should_return_service_from_consul() + { + Arrange(); + var service1 = GivenService(50881); + _consulServiceEntries.Add(service1.ToServiceEntry()); + GivenThereIsAFakeConsulServiceDiscoveryProvider(); + + // Act + var actual = await _provider.GetAsync(); + + // Assert + actual.ShouldNotBeNull().Count.ShouldBe(1); + } + + [Fact] + public async Task Should_use_token() + { + Arrange(); + const string token = "test token"; + var service1 = GivenService(50881); + _consulServiceEntries.Add(service1.ToServiceEntry()); + GivenThereIsAFakeConsulServiceDiscoveryProvider(); + var config = new ConsulRegistryConfiguration(_consulScheme, _consulHost, _port, nameof(Should_use_token), token); + _provider = new ConsulProvider(config, _factory.Object, _clientFactory, _serviceBuilder); + + // Act + var actual = await _provider.GetAsync(); + + // Assert + actual.ShouldNotBeNull().Count.ShouldBe(1); + _receivedToken.ShouldBe(token); + } + + [Fact] + public async Task Should_not_return_services_with_invalid_address() + { + Arrange(); + var service1 = GivenService(50881, "http://localhost"); + var service2 = GivenService(50888, "http://localhost"); + _consulServiceEntries.Add(service1.ToServiceEntry()); + _consulServiceEntries.Add(service2.ToServiceEntry()); + GivenThereIsAFakeConsulServiceDiscoveryProvider(); + + // Act + var actual = await _provider.GetAsync(); + + // Assert + actual.ShouldNotBeNull().Count.ShouldBe(0); + ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(); + } + + [Fact] + public async Task Should_not_return_services_with_empty_address() + { + Arrange(); + var service1 = GivenService(50881).WithAddress(string.Empty); + var service2 = GivenService(50888).WithAddress(null); + _consulServiceEntries.Add(service1.ToServiceEntry()); + _consulServiceEntries.Add(service2.ToServiceEntry()); + GivenThereIsAFakeConsulServiceDiscoveryProvider(); + + // Act + var actual = await _provider.GetAsync(); + + // Assert + actual.ShouldNotBeNull().Count.ShouldBe(0); + ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(); + } + + [Fact] + public async Task Should_not_return_services_with_invalid_port() + { + Arrange(); + var service1 = GivenService(-1); + var service2 = GivenService(0); + _consulServiceEntries.Add(service1.ToServiceEntry()); + _consulServiceEntries.Add(service2.ToServiceEntry()); + GivenThereIsAFakeConsulServiceDiscoveryProvider(); + + // Act + var actual = await _provider.GetAsync(); + + // Assert + actual.ShouldNotBeNull().Count.ShouldBe(0); + ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning(); + } + + [Fact] + public async Task GetAsync_NoEntries_ShouldLogWarning() + { + Arrange(); + _consulServiceEntries.Clear(); // NoEntries + _logger.Setup(x => x.LogWarning(It.IsAny>())).Verifiable(); + GivenThereIsAFakeConsulServiceDiscoveryProvider(); + + // Act + var actual = await _provider.GetAsync(); + + // Assert + actual.ShouldNotBeNull().ShouldBeEmpty(); + var expected = $"Consul Provider: No service entries found for '{nameof(GetAsync_NoEntries_ShouldLogWarning)}' service!"; + _logger.Verify(x => x.LogWarning(It.Is>(y => y.Invoke() == expected)), Times.Once); + } + + private static AgentService GivenService(int port, string address = null, string id = null, string[] tags = null, [CallerMemberName] string serviceName = null) => new() + { + Service = serviceName, + Address = address ?? "localhost", + Port = port, + ID = id ?? Guid.NewGuid().ToString(), + Tags = tags ?? Array.Empty(), + }; + + private void ThenTheLoggerHasBeenCalledCorrectlyWithValidationWarning() + { + foreach (var entry in _consulServiceEntries) + { + var service = entry.Service; + var expected = $"Unable to use service address: '{service.Address}' and port: {service.Port} as it is invalid for the service: '{service.Service}'. Address must contain host only e.g. 'localhost', and port must be greater than 0."; + _logger.Verify(x => x.LogWarning(It.Is>(y => y.Invoke() == expected)), Times.Once); + } + } + + private void GivenThereIsAFakeConsulServiceDiscoveryProvider([CallerMemberName] string serviceName = "test") + { + _fakeConsulBuilder = new WebHostBuilder() + .UseUrls(_fakeConsulServiceDiscoveryUrl) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(_fakeConsulServiceDiscoveryUrl) + .Configure(app => + { + app.Run(async context => + { + if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") + { + if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) + { + _receivedToken = values.First(); + } + + var json = JsonConvert.SerializeObject(_consulServiceEntries); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + }) + .Build(); + _fakeConsulBuilder.Start(); + } +} diff --git a/test/Ocelot.UnitTests/Consul/DefaultConsulServiceBuilderTests.cs b/test/Ocelot.UnitTests/Consul/DefaultConsulServiceBuilderTests.cs new file mode 100644 index 000000000..25dc8d950 --- /dev/null +++ b/test/Ocelot.UnitTests/Consul/DefaultConsulServiceBuilderTests.cs @@ -0,0 +1,200 @@ +using Castle.Components.DictionaryAdapter.Xml; +using Consul; +using Ocelot.Logging; +using Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +namespace Ocelot.UnitTests.Consul; + +public sealed class DefaultConsulServiceBuilderTests +{ + private DefaultConsulServiceBuilder sut; + private readonly Func configurationFactory; + private readonly Mock clientFactory; + private readonly Mock loggerFactory; + private readonly Mock logger; + private ConsulRegistryConfiguration _configuration; + + private ConsulRegistryConfiguration GetConfiguration() => _configuration; + + public DefaultConsulServiceBuilderTests() + { + configurationFactory = GetConfiguration; + clientFactory = new(); + clientFactory.Setup(x => x.Get(It.IsAny())) + .Returns(new ConsulClient()); + logger = new(); + loggerFactory = new(); + loggerFactory.Setup(x => x.CreateLogger()) + .Returns(logger.Object); + } + + private void Arrange([CallerMemberName] string testName = null) + { + _configuration = new(null, null, 0, testName, null); + sut = new DefaultConsulServiceBuilder(configurationFactory, clientFactory.Object, loggerFactory.Object); + } + + [Fact] + public void Ctor_PrivateMembers_PropertiesAreInitialized() + { + Arrange(); + var methodClient = sut.GetType().GetProperty("Client", BindingFlags.NonPublic | BindingFlags.Instance); + var methodLogger = sut.GetType().GetProperty("Logger", BindingFlags.NonPublic | BindingFlags.Instance); + + // Act + var actualConfiguration = sut.Configuration; + var actualClient = methodClient.GetValue(sut); + var actualLogger = methodLogger.GetValue(sut); + + // Assert + actualConfiguration.ShouldNotBeNull().ShouldBe(_configuration); + actualClient.ShouldNotBeNull(); + actualLogger.ShouldNotBeNull(); + } + + private static Type Me { get; } = typeof(DefaultConsulServiceBuilder); + private static MethodInfo GetNode { get; } = Me.GetMethod("GetNode", BindingFlags.NonPublic | BindingFlags.Instance); + + [Fact] + public void GetNode_EntryBranch_ReturnsEntryNode() + { + Arrange(); + Node node = new() { Name = nameof(GetNode_EntryBranch_ReturnsEntryNode) }; + ServiceEntry entry = new() { Node = node }; + + // Act + var actual = GetNode.Invoke(sut, new object[] { entry, null }) as Node; + + // Assert + actual.ShouldNotBeNull().ShouldBe(node); + actual.Name.ShouldBe(node.Name); + } + + [Fact] + public void GetNode_NodesBranch_ReturnsNodeFromCollection() + { + Arrange(); + ServiceEntry entry = new() + { + Node = null, + Service = new() { Address = nameof(GetNode_NodesBranch_ReturnsNodeFromCollection) }, + }; + Node[] nodes = null; + + // Act, Assert: nodes is null + var actual = GetNode.Invoke(sut, new object[] { entry, nodes }) as Node; + actual.ShouldBeNull(); + + // Arrange, Act, Assert: nodes has items, happy path + var node = new Node { Address = nameof(GetNode_NodesBranch_ReturnsNodeFromCollection) }; + nodes = new[] { node }; + actual = GetNode.Invoke(sut, new object[] { entry, nodes }) as Node; + actual.ShouldNotBeNull().ShouldBe(node); + actual.Address.ShouldBe(entry.Service.Address); + + // Arrange, Act, Assert: nodes has items, some nulls in entry + entry.Service.Address = null; + actual = GetNode.Invoke(sut, new object[] { entry, nodes }) as Node; + actual.ShouldBeNull(); + + entry.Service = null; + actual = GetNode.Invoke(sut, new object[] { entry, nodes }) as Node; + actual.ShouldBeNull(); + + entry = null; + actual = GetNode.Invoke(sut, new object[] { entry, nodes }) as Node; + actual.ShouldBeNull(); + } + + private static MethodInfo GetDownstreamHost { get; } = Me.GetMethod("GetDownstreamHost", BindingFlags.NonPublic | BindingFlags.Instance); + + [Fact] + public void GetDownstreamHost_BothBranches_NameOrAddress() + { + Arrange(); + + // Arrange, Act, Assert: node branch + ServiceEntry entry = new() + { + Service = new() { Address = nameof(GetDownstreamHost_BothBranches_NameOrAddress) }, + }; + var node = new Node { Name = "test1" }; + var actual = GetDownstreamHost.Invoke(sut, new object[] { entry, node }) as string; + actual.ShouldNotBeNull().ShouldBe("test1"); + + // Arrange, Act, Assert: entry branch + node = null; + actual = GetDownstreamHost.Invoke(sut, new object[] { entry, node }) as string; + actual.ShouldNotBeNull().ShouldBe(nameof(GetDownstreamHost_BothBranches_NameOrAddress)); + } + + private static MethodInfo GetServiceVersion { get; } = Me.GetMethod("GetServiceVersion", BindingFlags.NonPublic | BindingFlags.Instance); + + [Fact] + public void GetServiceVersion_TagsIsNull_EmptyString() + { + Arrange(); + + // Arrange, Act, Assert: collection is null + ServiceEntry entry = new() + { + Service = new() { Tags = null }, + }; + Node node = null; + var actual = GetServiceVersion.Invoke(sut, new object[] { entry, node }) as string; + actual.ShouldBe(string.Empty); + + // Arrange, Act, Assert: collection has no version tag + entry.Service.Tags = new[] { "test" }; + actual = GetServiceVersion.Invoke(sut, new object[] { entry, node }) as string; + actual.ShouldBe(string.Empty); + } + + [Fact] + public void GetServiceVersion_HasTags_HappyPath() + { + Arrange(); + + // Arrange + var tags = new string[] { "test", "version-v2" }; + ServiceEntry entry = new() + { + Service = new() { Tags = tags }, + }; + Node node = null; + + // Act + var actual = GetServiceVersion.Invoke(sut, new object[] { entry, node }) as string; + + // Assert + actual.ShouldBe("v2"); + } + + private static MethodInfo GetServiceTags { get; } = Me.GetMethod("GetServiceTags", BindingFlags.NonPublic | BindingFlags.Instance); + + [Fact] + public void GetServiceTags_BothBranches() + { + Arrange(); + + // Arrange, Act, Assert: collection is null + ServiceEntry entry = new() + { + Service = new() { Tags = null }, + }; + Node node = null; + var actual = GetServiceTags.Invoke(sut, new object[] { entry, node }) as IEnumerable; + actual.ShouldNotBeNull().ShouldBeEmpty(); + + // Arrange, Act, Assert: happy path + entry.Service.Tags = new string[] { "1", "2", "3" }; + actual = GetServiceTags.Invoke(sut, new object[] { entry, node }) as IEnumerable; + actual.ShouldNotBeNull().ShouldNotBeEmpty(); + actual.Count().ShouldBe(3); + actual.ShouldContain("3"); + } +} diff --git a/test/Ocelot.UnitTests/Consul/OcelotBuilderExtensionsTests.cs b/test/Ocelot.UnitTests/Consul/OcelotBuilderExtensionsTests.cs index b9c7532c0..c1a2ed096 100644 --- a/test/Ocelot.UnitTests/Consul/OcelotBuilderExtensionsTests.cs +++ b/test/Ocelot.UnitTests/Consul/OcelotBuilderExtensionsTests.cs @@ -1,73 +1,105 @@ -using Microsoft.AspNetCore.Hosting; +using Consul; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Ocelot.DependencyInjection; using Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; +using Ocelot.Values; using System.Reflection; -namespace Ocelot.UnitTests.Consul +namespace Ocelot.UnitTests.Consul; + +public class OcelotBuilderExtensionsTests : UnitTest { - public class OcelotBuilderExtensionsTests : UnitTest + private readonly IServiceCollection _services; + private readonly IConfiguration _configRoot; + + public OcelotBuilderExtensionsTests() + { + _configRoot = new ConfigurationRoot(new List()); + _services = new ServiceCollection(); + _services.AddSingleton(GetHostingEnvironment()); + _services.AddSingleton(_configRoot); + } + + private static IWebHostEnvironment GetHostingEnvironment() { - private readonly IServiceCollection _services; - private readonly IConfiguration _configRoot; - private IOcelotBuilder _ocelotBuilder; - private Exception _ex; + var environment = new Mock(); + environment.Setup(e => e.ApplicationName) + .Returns(typeof(OcelotBuilderExtensionsTests).GetTypeInfo().Assembly.GetName().Name); + return environment.Object; + } - public OcelotBuilderExtensionsTests() + [Fact] + public void AddConsul_ShouldSetUpConsul() + { + // Arrange + Exception ex = null; + try { - _configRoot = new ConfigurationRoot(new List()); - _services = new ServiceCollection(); - _services.AddSingleton(GetHostingEnvironment()); - _services.AddSingleton(_configRoot); + // Act + var builder = _services.AddOcelot(_configRoot); + builder.AddConsul(); } - - private static IWebHostEnvironment GetHostingEnvironment() + catch (Exception e) { - var environment = new Mock(); - environment - .Setup(e => e.ApplicationName) - .Returns(typeof(OcelotBuilderExtensionsTests).GetTypeInfo().Assembly.GetName().Name); - - return environment.Object; + ex = e; } - [Fact] - public void should_set_up_consul() + // Assert + ex.ShouldBeNull(); + } + + [Fact] + public void AddConfigStoredInConsul_ShouldSetUpConsul() + { + // Arrange + Exception ex = null; + try { - this.Given(x => WhenISetUpOcelotServices()) - .When(x => WhenISetUpConsul()) - .Then(x => ThenAnExceptionIsntThrown()) - .BDDfy(); + // Act + var builder = _services.AddOcelot(_configRoot); + builder.AddConsul().AddConfigStoredInConsul(); } - - private void WhenISetUpOcelotServices() + catch (Exception e) { - try - { - _ocelotBuilder = _services.AddOcelot(_configRoot); - } - catch (Exception e) - { - _ex = e; - } + ex = e; } - private void WhenISetUpConsul() + // Assert + ex.ShouldBeNull(); + } + + [Fact] + public void AddConsulGeneric_TServiceBuilder_ShouldSetUpConsul() + { + // Arrange + Exception ex = null; + IOcelotBuilder builder = null; + try { - try - { - _ocelotBuilder.AddConsul().AddConfigStoredInConsul(); - } - catch (Exception e) - { - _ex = e; - } + // Act + builder = _services + .AddOcelot(_configRoot) + .AddConsul(); } - - private void ThenAnExceptionIsntThrown() + catch (Exception e) { - _ex.ShouldBeNull(); + ex = e; } + + // Assert + ex.ShouldBeNull(); + builder.ShouldNotBeNull(); + builder.Services.SingleOrDefault(s => s.ServiceType == typeof(IConsulServiceBuilder)).ShouldNotBeNull(); } } + +internal class FakeConsulServiceBuilder : IConsulServiceBuilder +{ + public ConsulRegistryConfiguration Configuration => throw new NotImplementedException(); + public IEnumerable BuildServices(ServiceEntry[] entries, Node[] nodes) => throw new NotImplementedException(); + public Service CreateService(ServiceEntry serviceEntry, Node serviceNode) => throw new NotImplementedException(); + public bool IsValid(ServiceEntry entry) => throw new NotImplementedException(); +} diff --git a/test/Ocelot.UnitTests/Consul/ProviderFactoryTests.cs b/test/Ocelot.UnitTests/Consul/ProviderFactoryTests.cs index f71b4ffe5..d7b676a23 100644 --- a/test/Ocelot.UnitTests/Consul/ProviderFactoryTests.cs +++ b/test/Ocelot.UnitTests/Consul/ProviderFactoryTests.cs @@ -3,6 +3,7 @@ using Ocelot.Configuration.Builder; using Ocelot.Logging; using Ocelot.Provider.Consul; +using Ocelot.Provider.Consul.Interfaces; using Ocelot.ServiceDiscovery.Providers; namespace Ocelot.UnitTests.Consul;