Skip to content

Commit

Permalink
Added initial port of URL targeting, still need clarity around this
Browse files Browse the repository at this point in the history
  • Loading branch information
Norhaven committed Dec 23, 2024
1 parent 0abda2d commit fe16465
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using Xunit;

namespace GrowthBook.Tests.StandardTests.GrowthBookTests;

public class UrlRedirectTests : UnitTest
{
public sealed class TestResult
{
public bool InExperiment { get; set; }
public string UrlRedirect { get; set; }
public string UrlWithParams { get; set; }
}

[StandardCaseTestCategory("urlRedirect")]
public class UrlRedirectTestCase
{
Expand All @@ -18,13 +26,26 @@ public class UrlRedirectTestCase
[TestPropertyIndex(1)]
public Context Context { get; set; }
[TestPropertyIndex(2)]
public JToken[] ExpectedResults { get; set; }
public TestResult[] ExpectedResults { get; set; }
}

[Theory]
[MemberData(nameof(GetMappedTestsInCategory), typeof(UrlRedirectTestCase))]
public void Run(UrlRedirectTestCase testCase)
{
var gb = new GrowthBook(testCase.Context);

#warning Is this only applicable for auto experiments? Need more clarity around usage/logic as well.

Check warning on line 38 in GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs

View workflow job for this annotation

GitHub Actions / build (3.1.x)

#warning: 'Is this only applicable for auto experiments? Need more clarity around usage/logic as well.'

Check warning on line 38 in GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs

View workflow job for this annotation

GitHub Actions / build (6.0.x)

#warning: 'Is this only applicable for auto experiments? Need more clarity around usage/logic as well.'

Check warning on line 38 in GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs

View workflow job for this annotation

GitHub Actions / build (7.0.x)

#warning: 'Is this only applicable for auto experiments? Need more clarity around usage/logic as well.'

//for(var i = 0; i < gb.Experiments.Count; i++)
//{
// var experiment = gb.Experiments[i];
// var expectedResult = testCase.ExpectedResults[i];

// var result = gb.Run(experiment);

// result.InExperiment.Should().Be(expectedResult.InExperiment);
// result.Value["urlRedirect"]?.ToString().Should().Be(expectedResult.UrlRedirect);
//}
}
}
5 changes: 5 additions & 0 deletions GrowthBook/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public class Context
/// </summary>
public IDictionary<string, Feature> Features { get; set; } = new Dictionary<string, Feature>();

/// <summary>
/// Experiment definitions.
/// </summary>
public IList<Experiment> Experiments { get; set; }

/// <summary>
/// Service for using sticky buckets.
/// </summary>
Expand Down
23 changes: 23 additions & 0 deletions GrowthBook/Extensions/UriExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace GrowthBook.Extensions
{
public static class UriExtensions
{
public static bool ContainsHashInPath(this Uri uri) => uri.AbsolutePath.Contains("#");

public static string GetHashContents(this Uri uri)
{
if (!uri.ContainsHashInPath())
{
return default;
}

var hashIndex = uri.AbsolutePath.IndexOf("#");

return uri.AbsolutePath.Substring(hashIndex);
}
}
}
14 changes: 14 additions & 0 deletions GrowthBook/GrowthBook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public GrowthBook(Context context)
Attributes = context.Attributes;
Url = context.Url;
Features = context.Features?.ToDictionary(k => k.Key, v => v.Value) ?? new Dictionary<string, Feature>();
Experiments = context.Experiments ?? new List<Experiment>();
ForcedVariations = context.ForcedVariations;

_qaMode = context.QaMode;
Expand Down Expand Up @@ -104,6 +105,11 @@ public GrowthBook(Context context)
/// </summary>
public IDictionary<string, Feature> Features { get; set; }

/// <summary>
/// The currently loaded experiments (separate from features).
/// </summary>
public IList<Experiment> Experiments { get; set; }

/// <summary>
/// Listing of specific experiments to always assign a specific variation (used for QA).
/// </summary>
Expand Down Expand Up @@ -445,6 +451,14 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId)
return GetExperimentResult(experiment, featureId: featureId);
}

// 2.6 Use improved URL targeting if specified.

if (experiment.UrlPatterns?.Count > 0 && !ExperimentUtilities.IsUrlTargeted(Url ?? string.Empty, experiment.UrlPatterns))
{
_logger.LogDebug("Skipping due to URL targeting");
return GetExperimentResult(experiment, featureId: featureId);
}

// 3. Use the override value from the query string if one is specified.

if (!Url.IsNullOrWhitespace())
Expand Down
57 changes: 50 additions & 7 deletions GrowthBook/Utilities/ExperimentUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,27 +220,70 @@ private static bool EvaluateSimpleUrlTarget(Uri actual, UrlPattern pattern)
// If a protocol is missing, but a host is specified, add `https://` to the front
// Use "_____" as the wildcard since `*` is not a valid hostname in some browsers

var expected = Regex.Replace(pattern.Pattern, "^([^:/?]*)\\.", "https://$1.");
expected = Regex.Replace(expected, "/*", "_____");
var currentPattern = pattern.Pattern;

var match = Regex.Match(currentPattern, "^([^:/?]*)\\.");

if (match.Success)
{
currentPattern = $"https://{currentPattern}";
}

var expected = currentPattern.Replace("*", "_____");
var expectedUri = new Uri($"https://{expected}");

// Compare each part of the URL separately

var comparisons = new[] { (actual.Host, expectedUri.Host, false), (actual.AbsolutePath, expectedUri.AbsolutePath, true) };
var comparisons = new List<(string Actual, string Expected, bool IsPath)>
{
(actual.Host, expectedUri.Host, false),
(actual.AbsolutePath, expectedUri.AbsolutePath, true)
};

// We only want to compare hashes if it's explicitly being targeted

#warning Check hash codes?
#warning Comparisons and finish implementation
if (expectedUri.ContainsHashInPath())
{
comparisons.Add((actual.GetHashContents(), expectedUri.GetHashContents(), false));
}

return false;
var actualQueryParameters = HttpUtility.ParseQueryString(actual.Query);
var expectedQueryParameters = HttpUtility.ParseQueryString(expectedUri.Query);

for(var i = 0; i < expectedQueryParameters.Count; i++)
{
comparisons.Add((actualQueryParameters[i] ?? string.Empty, expectedQueryParameters[i], false));
}

// Any failure means the whole thing fails.

return comparisons.Any(x => !EvaluateSimpleUrlPart(x.Actual, x.Expected, x.IsPath));
}

private static bool EvaluateSimpleUrlPart(string actual, string expected, bool isPath)
{
var escaped = Regex.Replace(expected, @"[*.+?^${}()|[\]\\]", @"\$&");
var escapedWithWildcards = escaped.Replace("_____", ".*");

if (isPath)
{
// When matching path name, make leading/trailing slashes optional

escapedWithWildcards = Regex.Replace(escapedWithWildcards, @"(^\/|\/$)", string.Empty);
escapedWithWildcards = $@"\/?{escapedWithWildcards}\/?";
}

var regex = new Regex($"^{escapedWithWildcards}$");

return regex.IsMatch(actual);
}

private static Regex GetUrlRegex(UrlPattern pattern)
{
try
{
var escaped = Regex.Replace(pattern.Pattern, "([^\\\\])\\/", "$1\\/");
var match = Regex.IsMatch(pattern.Pattern, @"([^\\])\/");
var escaped = Regex.Replace(pattern.Pattern, @"([^\\])\/", @"$1\/");

return new Regex(escaped);
}
Expand Down

0 comments on commit fe16465

Please sign in to comment.