4
\$\begingroup\$

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?

\$\endgroup\$
  • \$\begingroup\$ The EqualityComparerFactory can be found here. \$\endgroup\$ – t3chb0t Dec 16 '18 at 18:07
  • \$\begingroup\$ Downvoted... how so? \$\endgroup\$ – t3chb0t Dec 17 '18 at 12:49
  • \$\begingroup\$ FWIW I’m mildly surprised you didn’t extend URI to create SimpleUri. Surely it would be easier and more robust. \$\endgroup\$ – RubberDuck Dec 19 '18 at 22:53
  • \$\begingroup\$ @RubberDuck I can still do this ;-] and I actually already was there but the Uri is not as great as one would think. It throws unexplained exceptions all over the place. Values are spread over several strange and seemingly redundant properties. I might use it later for some edge cases that I'm to lazy to implement myself but generally it's terrible. On top of it it has a super-confusing system for registering new parsers which will break any dependency-injection. Uri is more trouble than help. \$\endgroup\$ – t3chb0t Dec 20 '18 at 8:24
3
\$\begingroup\$

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.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.