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
.