Recently I needed modify a query string that was somewhere in a URI string. It seems to be a trivial problem, but it took me some hours to get this right.

The problem

In my scenario I could have URI that looked like this:

  • /help
  • /help?search=cats
  • /pages/help?search=cats#anchor
  • http://server.com/help?search=cats#anchor
  • http://user:password@server.com/help?search=cats#anchor

I wanted a simple function to modify the query string no matter how the URI looked. If I understand it correctly, the first three samples are not technically a URI. A schema, like http or ftp, is required.

If you try to make a Uri object from any of these first three samples you will get an exception, unless you specify that it could be a relative path. But if it is a relative path, only a few properties work.

The solution

The first thing I do in my function is the check if the path is relative or not, and it is temporary convert it to an absolute path.

In the second step I created an UriBuilder from the current URI, and from that is was easy to create a NameValueCollection that then could be used to modify the query. Once the query string is in a NameValueCollection it is easy to work with.

In the third step, the new query is created from the UriBuilder. And if it is a relative URI, the relative URI is extracted with the method MakeRelativeUri.

This is the function I created:

ModifyUri code
static string ModifyUri(string uristring, Action<NameValueCollection> queryStringChangerCallback)
{
    Uri uri = new Uri(uristring, UriKind.RelativeOrAbsolute);

    if (uri.IsAbsoluteUri)
    {
        UriBuilder uriBuilder = new UriBuilder(uri);
        NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(uri.Query);

        queryStringChangerCallback.Invoke(nameValueCollection);

        uriBuilder.Query = nameValueCollection.ToString();

        return uriBuilder.Uri.AbsoluteUri;
    }
    else
    {
        //Change to an absolute uri
        Uri relativeToUri = new Uri("http://f.oo");
        uri = new Uri("http://f.oo/" + uristring);

        UriBuilder uriBuilder = new UriBuilder(uri);
        NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(uri.Query);

        queryStringChangerCallback.Invoke(nameValueCollection);

        uriBuilder.Query = nameValueCollection.ToString();

        //Get the relative uri
        Uri relative = relativeToUri.MakeRelativeUri(uriBuilder.Uri);

        return relative.OriginalString;
    }
}

The function accepts a callback that then is used to modify the NameValueCollection. It could be used like this:

Usage
string output = ModifyUri("/path/search?q=dogs&age=puppy#fragment", qs =>
{
    // Replace value of q.
    qs["q"] = "cats";
    // New URI: /path/search?q=cats&age=puppy#fragment

    // Remove age.
    qs.Remove("age");
    // New URI: /path/search?q=cats#fragment

    // Add color.
    qs["color"] = "black";
    // New URI: /path/search?q=cats&color=black#fragment

    // Add second color.
    qs.Add("color", "white");
    // New URI: /path/search?q=cats&color=black&color=white#fragment

    // Add value without key
    qs.Add(null, "cute");
    // New URI: /path/search?q=cats&color=black&color=white&cute#fragment
});

// output now has the value:
// /path/search?q=cats&color=black&color=white&cute#fragment

Testing

Just to make sure the function works properly I created a large number of tests:

Usage
public class UnitTestModifyUri
{
    static string ModifyUri(string uristring, Action<NameValueCollection> queryStringChangerCallback)
    {
        Uri uri = new Uri(uristring, UriKind.RelativeOrAbsolute);

        if (uri.IsAbsoluteUri)
        {
            UriBuilder uriBuilder = new UriBuilder(uri);
            NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(uri.Query);

            queryStringChangerCallback.Invoke(nameValueCollection);

            uriBuilder.Query = nameValueCollection.ToString();

            return uriBuilder.Uri.AbsoluteUri;
        }
        else
        {
            //Change to an absolute uri
            Uri relativeToUri = new Uri("http://f.oo");
            uri = new Uri("http://f.oo/" + uristring);

            UriBuilder uriBuilder = new UriBuilder(uri);
            NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(uri.Query);

            queryStringChangerCallback.Invoke(nameValueCollection);

            uriBuilder.Query = nameValueCollection.ToString();

            //Get the relative uri
            Uri relative = relativeToUri.MakeRelativeUri(uriBuilder.Uri);

            return relative.OriginalString;
        }
    }

    [Theory]
    [MemberData(nameof(GetData))]
    public void TestClear(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "?alpha=1&beta=2");
        string expectedOutput = string.Format(urlFormat,
                        "");

        string output = ModifyUri(input, qs =>
        {
            qs.Clear();
        });

        Assert.Equal(expectedOutput, output);
    }

    [Theory]
    [MemberData(nameof(GetData))]
    public void TestAddFromEmpty(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "");
        string expectedOutput = string.Format(urlFormat,
                        "?alpha=1");

        string output = ModifyUri(input, qs =>
        {
            qs.Add("alpha", "1");
        });

        Assert.Equal(expectedOutput, output);
    }

    [Theory]
    [MemberData(nameof(GetData))]
    public void TestAddViaAssignFromEmpty(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "");
        string expectedOutput = string.Format(urlFormat,
                        "?alpha=1");

        string output = ModifyUri(input, qs =>
        {
            qs["alpha"] = "1";
        });

        Assert.Equal(expectedOutput, output);
    }

    [Theory]
    [MemberData(nameof(GetData))]
    public void TestAddFromExisting(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "?alpha=1");
        string expectedOutput = string.Format(urlFormat,
                        "?alpha=1&beta=2");

        string output = ModifyUri(input, qs =>
        {
            qs.Add("beta", "2");
        });

        Assert.Equal(expectedOutput, output);
    }

    [Theory]
    [MemberData(nameof(GetData))]
    public void TestModify(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "?alpha=1&beta");
        string expectedOutput = string.Format(urlFormat,
                        "?alpha=2&beta");

        string output = ModifyUri(input, qs =>
        {
            qs["alpha"] = "2";
        });

        Assert.Equal(expectedOutput, output);
    }
    
    [Theory]
    [MemberData(nameof(GetData))]
    public void TestAddSecondValue(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "?alpha=1&beta");
        string expectedOutput = string.Format(urlFormat,
                        "?alpha=1&alpha=2&beta");

        string output = ModifyUri(input, qs =>
        {
            qs.Add("alpha", "2");
        });

        Assert.Equal(expectedOutput, output);
    }
            
    [Theory]
    [MemberData(nameof(GetData))]
    public void TestRemoveMultipleValues(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "?alpha=1&alpha=2&beta");
        string expectedOutput = string.Format(urlFormat,
                        "?beta");

        string output = ModifyUri(input, qs =>
        {
            qs.Remove("alpha");
            qs.Remove("doesnotexists");
        });

        Assert.Equal(expectedOutput, output);
    }
    
    [Theory]
    [MemberData(nameof(GetData))]
    public void TestAddEmptyValue(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "?alpha=1&beta");
        string expectedOutput = string.Format(urlFormat,
                        "?alpha=1&beta&gamma");

        string output = ModifyUri(input, qs =>
        {
            qs.Add(null, "gamma");
        });

        Assert.Equal(expectedOutput, output);
    }

    [Theory]
    [MemberData(nameof(GetData))]
    public void TestEncoding(string urlFormat)
    {
        string input = string.Format(urlFormat,
                        "");
        string expectedOutput = string.Format(urlFormat,
                        "?%C3%85%C3%84%C3%96=%C3%A5%C3%A4%C3%B6&foodquery=%F0%9F%8D%95+%26+%F0%9F%8D%B0+%3d+%F0%9F%A4%AE%3f");

        string output = ModifyUri(input, qs =>
        {
            qs.Add("ร…ร„ร–", "รฅรครถ");
            qs.Add("foodquery", "๐Ÿ• & ๐Ÿฐ = ๐Ÿคฎ?");
        });

        Assert.Equal(expectedOutput, output);
    }

    public static IEnumerable<object[]> GetData() =>
        new List<object[]>
        {
            new object[] {"{0}"},
            new object[] {"hello{0}"},
            new object[] {"/hello{0}"},
            new object[] {"http://a.bc/hello{0}"},
            new object[] {"http://a.bc:1234/hello{0}"},
            new object[] {"http://user:pass@a.bc:1234/hello{0}"},
            new object[] {"{0}#fragment"},
            new object[] {"hello{0}#fragment"},
            new object[] {"/hello{0}#fragment"},
            new object[] {"http://a.bc/hello{0}#fragment"},
            new object[] {"http://a.bc:1234/hello{0}#fragment"},
            new object[] {"http://user:pass@a.bc:1234/hello{0}#fragment"}
        };
    }

Summary

I do not know why, but it took me a lot of time to get everything right. Creating URI is nothing new to me, but I usually do it by concatenating strings. The thing that took longest time to crack was to support relative path.

The code uses HttpUtility.ParseQueryString to parse the query string. In .NET core you could use QueryHelpers.ParseNullableQuery instead, and then create the query string with QueryString.Create.

I think the new API are slightly better, they make it a bit easier to work with keys that have multiple values. But they require the assemblies Microsoft.AspNetCore.WebUtilities respective Microsoft.AspNetCore.Http.Abstractions, and I do not think it is worth it. I stick to the old API; it is good enough.