Replacing Uri with a more convenient class

Posted on

Problem

I needed some data structure for holding Uris so I thought I try the Uri class. It quickly turned out that it’s very impractical because:

  • it’s missing an implicit cast from/to string
  • it doesn’t automatically recognize uris as absolute or relative without specifying the UriKind
  • it doesn’t parse the query-string so it cannot compare uris reliably when key-value pairs are in different order
  • it’s not easy to combine an absolute uri with a relative one

Working with it is just too inconvenient. As a solution I created my own SimpleUri that should solve these issues. It doesn’t support everything yet because I don’t need it right now but adding a couple of more regexes later should not be a problem.

public class SimpleUri : IEquatable<SimpleUri>, IEquatable<string>
{
    // https://regex101.com/r/sd288W/1
    // using 'new[]' for _nicer_ syntax
    private static readonly string UriPattern = string.Join(string.Empty, new[]
    {
        /* language=regexp */ @"^(?:(?<scheme>w+):)?",
        /* language=regexp */ @"(?://(?<authority>w+))?",
        /* language=regexp */ @"(?<path>[a-z0-9/:]+)",
        /* language=regexp */ @"(?:?(?<query>[a-z0-9=&]+))?",
        /* language=regexp */ @"(?:#(?<fragment>[a-z0-9]+))?"
    });

    public static readonly IEqualityComparer<SimpleUri> Comparer = EqualityComparerFactory<SimpleUri>.Create
    (
        equals: (x, y) => StringComparer.OrdinalIgnoreCase.Equals(x, y),
        getHashCode: (obj) => StringComparer.OrdinalIgnoreCase.GetHashCode(obj)
    );

    public SimpleUri([NotNull] string uri)
    {
        if (uri == null) throw new ArgumentNullException(nameof(uri));

        var uriMatch = Regex.Match
        (
            uri,
            UriPattern,
            RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture
        );

        if (!uriMatch.Success)
        {
            throw new ArgumentException(paramName: nameof(uri), message: $"'{uri}' is not a valid Uri.");
        }

        Scheme = uriMatch.Groups["scheme"];
        Authority = uriMatch.Groups["authority"];
        Path = uriMatch.Groups["path"];
        Query =
            uriMatch.Groups["query"].Success
                ? Regex
                    .Matches
                    (
                        uriMatch.Groups["query"].Value,
                        @"(?:^|&)(?<key>[a-z0-9]+)(?:=(?<value>[a-z0-9]+))?",
                        RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture
                    )
                    .Cast<Match>()
                    .ToImmutableDictionary
                    (
                        m => (ImplicitString)m.Groups["key"],
                        m => (ImplicitString)m.Groups["value"]
                    )
                : ImmutableDictionary<ImplicitString, ImplicitString>.Empty;
        Fragment = uriMatch.Groups["fragment"];
    }

    public SimpleUri(SimpleUri absoluteUri, SimpleUri relativeUri)
    {
        if (absoluteUri.IsRelative) throw new ArgumentException($"{nameof(absoluteUri)} must be an absolute Uri.");
        if (!relativeUri.IsRelative) throw new ArgumentException($"{nameof(relativeUri)} must be a relative Uri.");

        Scheme = absoluteUri.Scheme;
        Authority = absoluteUri.Authority;
        Path = absoluteUri.Path.Value.TrimEnd('/') + "/" + relativeUri.Path.Value.TrimStart('/');
        Query = absoluteUri.Query;
        Fragment = absoluteUri.Fragment;
    }

    public ImplicitString Scheme { get; }

    public ImplicitString Authority { get; }

    public ImplicitString Path { get; }

    public IImmutableDictionary<ImplicitString, ImplicitString> Query { get; }

    public ImplicitString Fragment { get; }

    public bool IsRelative => !Scheme;

    public override string ToString() => string.Join(string.Empty, GetComponents());

    private IEnumerable<string> GetComponents()
    {
        if (Scheme)
        {
            yield return $"{Scheme}:";
        }

        if (Authority)
        {
            yield return $"//{Authority}";
        }

        yield return Path;

        if (Query.Any())
        {
            var queryPairs =
                Query
                    .OrderBy(x => (string)x.Key, StringComparer.OrdinalIgnoreCase)
                    .Select(x => $"{x.Key}{(x.Value ? "=" : string.Empty)}{x.Value}");
            yield return $"?{string.Join("&", queryPairs)}";
        }

        if (Fragment)
        {
            yield return $"#{Fragment}";
        }
    }

    #region IEquatable

    public bool Equals(SimpleUri other) => Comparer.Equals(this, other);

    public bool Equals(string other) => Comparer.Equals(this, other);

    public override bool Equals(object obj) => obj is SimpleUri uri && Equals(uri);

    public override int GetHashCode() => Comparer.GetHashCode(this);

    #endregion

    #region operators

    public static implicit operator SimpleUri(string uri) => new SimpleUri(uri);

    public static implicit operator string(SimpleUri uri) => uri.ToString();

    public static SimpleUri operator +(SimpleUri absoluteUri, SimpleUri relativeUri) => new SimpleUri(absoluteUri, relativeUri);

    #endregion
}

In this experiment I use a new helper for the first time which is the ImplicitString. Its purpose is to be able to use a string as a conditional without having to write any of the string.IsX all over the place.

public class ImplicitString : IEquatable<ImplicitString>
{
    public ImplicitString(string value) => Value = value;

    [AutoEqualityProperty]
    public string Value { get; }

    public override string ToString() => Value;

    public static implicit operator ImplicitString(string value) => new ImplicitString(value);

    public static implicit operator ImplicitString(Group group) => group.Value;

    public static implicit operator string(ImplicitString value) => value.ToString();

    public static implicit operator bool(ImplicitString value) => !string.IsNullOrWhiteSpace(value);

    #region IEquatable

    public bool Equals(ImplicitString other) => AutoEquality<ImplicitString>.Comparer.Equals(this, other);

    public override bool Equals(object obj) => obj is ImplicitString str && Equals(str);

    public override int GetHashCode() => AutoEquality<ImplicitString>.Comparer.GetHashCode(this);

    #endregion
}

Example

I tested it with use-cases I currently need it for and it works fine:

new SimpleUri("scheme://authority/p/a/t/h?query#fragment").Dump();
new SimpleUri("scheme:p/a/t/h?q1=v1&q2=v2&q3#fragment").Dump();
new SimpleUri("p/a/t/h?q1=v1&q2=v2&q3#fragment").Dump();
new SimpleUri("p/a/t/h").Dump();
new SimpleUri("file:c:/p/a/t/h").Dump();

What do you say? Is this an optimal implementation or can it be improved?

Solution

Instead of this:

$"{x.Key}{(x.Value ? "=" : string.Empty)}{x.Value}"

I would find it easier to understand this way:

$"{x.Key}{(x.Value ? $"={x.Value}" : string.Empty)}"

Instead of testing-by-printing, why not have proper unit tests for this?
(So I don’t have to read the output after every change and re-convince myself that it’s still good.)


I’m a bit surprised that the class doesn’t handle URI with domain names containing a dot, for example https://stackoverflow.com/somepath.


It might be an interesting feature to add,
when !uriMatch.Success, to check if the URI could actually be parsed by the standard Uri class.
That is, give a more clear signal to users of the class,
whether the URI is invalid, or it’s just using some pattern not-yet-supported by SimpleUri.

Leave a Reply

Your email address will not be published. Required fields are marked *