From 9ba57f8ba654f66d5515fb96b7500ec67bf846c7 Mon Sep 17 00:00:00 2001 From: Tom Pallister Date: Sun, 19 Nov 2017 21:01:54 +0000 Subject: [PATCH] Make Ocelot not add forward slash to downstream url (#158) * removed code where we add a trailing slash as this means if we request /1.txt/ instead of /1.txt then some servers will not return the resource at /1.txt. After reading up it seems if you dont have a trailing slash then its a file, if you do then its a resource * test for 145 * removed unused code * fix broken build..my bad --- .../Creator/FileOcelotConfigurationCreator.cs | 1 - .../Creator/UpstreamTemplatePatternCreator.cs | 10 ++-- .../Finder/DownstreamRouteFinder.cs | 3 -- .../DownstreamRouteFinderMiddleware.cs | 1 - .../UrlPathPlaceholderNameAndValueFinder.cs | 2 +- src/Ocelot/Utilities/StringExtensions.cs | 17 ------- test/Ocelot.AcceptanceTests/RoutingTests.cs | 44 ++++++++++++++++-- .../UpstreamTemplatePatternCreatorTests.cs | 27 +++++++++-- .../DownstreamRouteFinderTests.cs | 4 +- ...TemplateVariableNameAndValueFinderTests.cs | 10 ++++ test/Ocelot.UnitTests/Ocelot.UnitTests.csproj | 6 +++ test/Ocelot.UnitTests/idsrv3test.pfx | Bin 0 -> 3395 bytes 12 files changed, 86 insertions(+), 39 deletions(-) delete mode 100644 src/Ocelot/Utilities/StringExtensions.cs create mode 100644 test/Ocelot.UnitTests/idsrv3test.pfx diff --git a/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs b/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs index bffb8392..3aba7c20 100644 --- a/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs +++ b/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs @@ -14,7 +14,6 @@ using Ocelot.LoadBalancer.LoadBalancers; using Ocelot.Logging; using Ocelot.Requester.QoS; using Ocelot.Responses; -using Ocelot.Utilities; namespace Ocelot.Configuration.Creator { diff --git a/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs b/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs index 4e02a158..92559bfb 100644 --- a/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs +++ b/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs @@ -1,12 +1,11 @@ using System.Collections.Generic; using Ocelot.Configuration.File; -using Ocelot.Utilities; namespace Ocelot.Configuration.Creator { public class UpstreamTemplatePatternCreator : IUpstreamTemplatePatternCreator { - private const string RegExMatchEverything = ".*"; + private const string RegExMatchEverything = "[0-9a-zA-Z].*"; private const string RegExMatchEndString = "$"; private const string RegExIgnoreCase = "(?i)"; private const string RegExForwardSlashOnly = "^/$"; @@ -15,8 +14,6 @@ namespace Ocelot.Configuration.Creator { var upstreamTemplate = reRoute.UpstreamPathTemplate; - upstreamTemplate = upstreamTemplate.SetLastCharacterAs('/'); - var placeholders = new List(); for (var i = 0; i < upstreamTemplate.Length; i++) @@ -40,6 +37,11 @@ namespace Ocelot.Configuration.Creator return RegExForwardSlashOnly; } + if(upstreamTemplate.EndsWith("/")) + { + upstreamTemplate = upstreamTemplate.Remove(upstreamTemplate.Length -1, 1) + "(/|)"; + } + var route = reRoute.ReRouteIsCaseSensitive ? $"^{upstreamTemplate}{RegExMatchEndString}" : $"^{RegExIgnoreCase}{upstreamTemplate}{RegExMatchEndString}"; diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs index e39fffc6..2c3077cc 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs @@ -7,7 +7,6 @@ using Ocelot.Configuration.Provider; using Ocelot.DownstreamRouteFinder.UrlMatcher; using Ocelot.Errors; using Ocelot.Responses; -using Ocelot.Utilities; namespace Ocelot.DownstreamRouteFinder.Finder { @@ -24,8 +23,6 @@ namespace Ocelot.DownstreamRouteFinder.Finder public Response FindDownstreamRoute(string upstreamUrlPath, string upstreamHttpMethod, IOcelotConfiguration configuration) { - upstreamUrlPath = upstreamUrlPath.SetLastCharacterAs('/'); - var applicableReRoutes = configuration.ReRoutes.Where(r => r.UpstreamHttpMethod.Count == 0 || r.UpstreamHttpMethod.Select(x => x.Method.ToLower()).Contains(upstreamHttpMethod.ToLower())); foreach (var reRoute in applicableReRoutes) diff --git a/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs b/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs index b5351e2a..b5e72218 100644 --- a/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs +++ b/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs @@ -8,7 +8,6 @@ using Ocelot.Infrastructure.Extensions; using Ocelot.Infrastructure.RequestData; using Ocelot.Logging; using Ocelot.Middleware; -using Ocelot.Utilities; namespace Ocelot.DownstreamRouteFinder.Middleware { diff --git a/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs b/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs index 8e5a2416..946a365c 100644 --- a/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs +++ b/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs @@ -13,7 +13,7 @@ namespace Ocelot.DownstreamRouteFinder.UrlMatcher for (int counterForTemplate = 0; counterForTemplate < upstreamUrlPathTemplate.Length; counterForTemplate++) { - if (CharactersDontMatch(upstreamUrlPathTemplate[counterForTemplate], upstreamUrlPath[counterForUrl]) && ContinueScanningUrl(counterForUrl,upstreamUrlPath.Length)) + if ((upstreamUrlPath.Length > counterForUrl) && CharactersDontMatch(upstreamUrlPathTemplate[counterForTemplate], upstreamUrlPath[counterForUrl]) && ContinueScanningUrl(counterForUrl,upstreamUrlPath.Length)) { if (IsPlaceholder(upstreamUrlPathTemplate[counterForTemplate])) { diff --git a/src/Ocelot/Utilities/StringExtensions.cs b/src/Ocelot/Utilities/StringExtensions.cs deleted file mode 100644 index 4655a98a..00000000 --- a/src/Ocelot/Utilities/StringExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Ocelot.Utilities -{ - public static class StringExtensions - { - public static string SetLastCharacterAs(this string valueToSetLastChar, - char expectedLastChar) - { - var last = valueToSetLastChar[valueToSetLastChar.Length - 1]; - - if (last != expectedLastChar) - { - valueToSetLastChar = $"{valueToSetLastChar}{expectedLastChar}"; - } - return valueToSetLastChar; - } - } -} diff --git a/test/Ocelot.AcceptanceTests/RoutingTests.cs b/test/Ocelot.AcceptanceTests/RoutingTests.cs index 4994aff7..926cb849 100644 --- a/test/Ocelot.AcceptanceTests/RoutingTests.cs +++ b/test/Ocelot.AcceptanceTests/RoutingTests.cs @@ -159,7 +159,7 @@ namespace Ocelot.AcceptanceTests } [Fact] - public void should_not_care_about_no_trailing() + public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_template_does_not() { var configuration = new FileConfiguration { @@ -187,7 +187,7 @@ namespace Ocelot.AcceptanceTests } [Fact] - public void should_not_care_about_trailing() + public void should_return_not_found_when_upstream_url_ends_with_forward_slash_but_template_does_not() { var configuration = new FileConfiguration { @@ -209,8 +209,7 @@ namespace Ocelot.AcceptanceTests .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) .BDDfy(); } @@ -483,6 +482,41 @@ namespace Ocelot.AcceptanceTests .BDDfy(); } + + [Fact] + public void should_fix_145() + { + var configuration = new FileConfiguration + { + ReRoutes = new List + { + new FileReRoute + { + DownstreamPathTemplate = "/api/{url}", + DownstreamScheme = "http", + DownstreamHost = "localhost", + DownstreamPort = 51899, + UpstreamPathTemplate = "/platform/{url}", + UpstreamHttpMethod = new List { "Get" }, + QoSOptions = new FileQoSOptions { + ExceptionsAllowedBeforeBreaking = 3, + DurationOfBreak = 10, + TimeoutValue = 5000 + } + } + } + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn("http://localhost:51899", "", 200, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/platform/swagger/lib/backbone-min.js")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(x => ThenTheDownstreamUrlPathShouldBe("/api/swagger/lib/backbone-min.js")) + .BDDfy(); + } + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) { _builder = new WebHostBuilder() @@ -495,7 +529,7 @@ namespace Ocelot.AcceptanceTests app.UsePathBase(basePath); app.Run(async context => { - _downstreamPath = context.Request.PathBase.Value; + _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; context.Response.StatusCode = statusCode; await context.Response.WriteAsync(responseBody); }); diff --git a/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs index 21c5be38..fba285e2 100644 --- a/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs @@ -28,7 +28,23 @@ namespace Ocelot.UnitTests.Configuration this.Given(x => x.GivenTheFollowingFileReRoute(fileReRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^(?i)/PRODUCTS/.*/$")) + .Then(x => x.ThenTheFollowingIsReturned("^(?i)/PRODUCTS/[0-9a-zA-Z].*$")) + .BDDfy(); + } + + + [Fact] + public void should_match_forward_slash_or_no_forward_slash_if_template_end_with_forward_slash() + { + var fileReRoute = new FileReRoute + { + UpstreamPathTemplate = "/PRODUCTS/", + ReRouteIsCaseSensitive = false + }; + + this.Given(x => x.GivenTheFollowingFileReRoute(fileReRoute)) + .When(x => x.WhenICreateTheTemplatePattern()) + .Then(x => x.ThenTheFollowingIsReturned("^(?i)/PRODUCTS(/|)$")) .BDDfy(); } @@ -42,7 +58,7 @@ namespace Ocelot.UnitTests.Configuration }; this.Given(x => x.GivenTheFollowingFileReRoute(fileReRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/PRODUCTS/.*/$")) + .Then(x => x.ThenTheFollowingIsReturned("^/PRODUCTS/[0-9a-zA-Z].*$")) .BDDfy(); } @@ -57,7 +73,7 @@ namespace Ocelot.UnitTests.Configuration this.Given(x => x.GivenTheFollowingFileReRoute(fileReRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/api/products/.*/$")) + .Then(x => x.ThenTheFollowingIsReturned("^/api/products/[0-9a-zA-Z].*$")) .BDDfy(); } @@ -72,9 +88,10 @@ namespace Ocelot.UnitTests.Configuration this.Given(x => x.GivenTheFollowingFileReRoute(fileReRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/api/products/.*/variants/.*/$")) + .Then(x => x.ThenTheFollowingIsReturned("^/api/products/[0-9a-zA-Z].*/variants/[0-9a-zA-Z].*$")) .BDDfy(); } + [Fact] public void should_create_template_pattern_that_matches_more_than_one_placeholder_with_trailing_slash() { @@ -86,7 +103,7 @@ namespace Ocelot.UnitTests.Configuration this.Given(x => x.GivenTheFollowingFileReRoute(fileReRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/api/products/.*/variants/.*/$")) + .Then(x => x.ThenTheFollowingIsReturned("^/api/products/[0-9a-zA-Z].*/variants/[0-9a-zA-Z].*(/|)$")) .BDDfy(); } diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs index 2e586c23..a1839bea 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs @@ -68,7 +68,7 @@ namespace Ocelot.UnitTests.DownstreamRouteFinder [Fact] - public void should_append_slash_to_upstream_url_path() + public void should_not_append_slash_to_upstream_url_path() { var serviceProviderConfig = new ServiceProviderConfigurationBuilder().Build(); @@ -97,7 +97,7 @@ namespace Ocelot.UnitTests.DownstreamRouteFinder .WithUpstreamHttpMethod(new List { "Get" }) .Build() ))) - .And(x => x.ThenTheUrlMatcherIsCalledCorrectly("matchInUrlMatcher/")) + .And(x => x.ThenTheUrlMatcherIsCalledCorrectly("matchInUrlMatcher")) .BDDfy(); } diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/TemplateVariableNameAndValueFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/TemplateVariableNameAndValueFinderTests.cs index 7c73483a..fded9ecf 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/TemplateVariableNameAndValueFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/TemplateVariableNameAndValueFinderTests.cs @@ -30,6 +30,16 @@ namespace Ocelot.UnitTests.DownstreamRouteFinder.UrlMatcher .BDDfy(); } + [Fact] + public void should_not_find_anything() + { + this.Given(x => x.GivenIHaveAUpstreamPath("/products")) + .And(x => x.GivenIHaveAnUpstreamUrlTemplate("/products/")) + .When(x => x.WhenIFindTheUrlVariableNamesAndValues()) + .And(x => x.ThenTheTemplatesVariablesAre(new List())) + .BDDfy(); + } + [Fact] public void can_match_down_stream_url_with_no_slash() { diff --git a/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj b/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj index 415fb07b..f0ad0d8d 100644 --- a/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj +++ b/test/Ocelot.UnitTests/Ocelot.UnitTests.csproj @@ -31,6 +31,12 @@ + + + PreserveNewest + + + diff --git a/test/Ocelot.UnitTests/idsrv3test.pfx b/test/Ocelot.UnitTests/idsrv3test.pfx new file mode 100644 index 0000000000000000000000000000000000000000..0247dea03f0cc23694291f21310f3ae88880e2bb GIT binary patch literal 3395 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 V%$88{h~I%@Xjg7x^oQR@_8&Ry9S;Bi literal 0 HcmV?d00001