Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion Light.GuardClauses.SingleFile.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* ------------------------------
Light.GuardClauses 15.0.0
Light.GuardClauses 15.1.0
------------------------------

License information for Light.GuardClauses
Expand Down Expand Up @@ -5198,6 +5198,80 @@ public static ReadOnlyMemory<char> MustBeUpperCase(this ReadOnlyMemory<char> par
return parameter;
}

/// <summary>
/// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws an
/// <see cref = "InvalidUriException"/>.
/// </summary>
/// <param name = "parameter">The string to be checked.</param>
/// <param name = "uriKind">The kind of URI that the string must represent.</param>
/// <param name = "parameterName">The name of the parameter (optional).</param>
/// <param name = "message">The message that will be passed to the resulting exception (optional).</param>
/// <exception cref = "InvalidUriException">
/// Thrown when <paramref name = "parameter"/> is not a valid URI of the supplied kind.
/// </exception>
/// <exception cref = "ArgumentNullException">Thrown when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static string MustBeUri([NotNull][ValidatedNotNull] this string? parameter, UriKind uriKind = UriKind.RelativeOrAbsolute, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
{
parameter.MustNotBeNull(parameterName, message);
if (!Uri.TryCreate(parameter, uriKind, out _))
{
Throw.MustBeUri(parameter, uriKind, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom
/// exception.
/// </summary>
/// <param name = "parameter">The string to be checked.</param>
/// <param name = "uriKind">The kind of URI that the string must represent.</param>
/// <param name = "exceptionFactory">
/// The delegate that creates the exception to be thrown. <paramref name = "parameter"/> is passed to this delegate.
/// </param>
/// <exception cref = "Exception">
/// Your custom exception thrown when <paramref name = "parameter"/> is null or not a valid URI of the supplied kind.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static string MustBeUri([NotNull][ValidatedNotNull] this string? parameter, UriKind uriKind, Func<string?, Exception> exceptionFactory)
{
if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom
/// exception.
/// </summary>
/// <param name = "parameter">The string to be checked.</param>
/// <param name = "uriKind">The kind of URI that the string must represent.</param>
/// <param name = "exceptionFactory">
/// The delegate that creates the exception to be thrown. <paramref name = "parameter"/> and
/// <paramref name = "uriKind"/> are passed to this delegate.
/// </param>
/// <exception cref = "Exception">
/// Your custom exception thrown when <paramref name = "parameter"/> is null or not a valid URI of the supplied kind.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static string MustBeUri([NotNull][ValidatedNotNull] this string? parameter, UriKind uriKind, Func<string?, UriKind, Exception> exceptionFactory)
{
if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _))
{
Throw.CustomException(exceptionFactory, parameter, uriKind);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name = "parameter"/> uses <see cref = "DateTimeKind.Utc"/>, or otherwise throws an <see cref = "InvalidDateTimeException"/>.
/// </summary>
Expand Down Expand Up @@ -11050,6 +11124,27 @@ protected InvalidStateException(SerializationInfo info, StreamingContext context
}
}

/// <summary>
/// This exception indicates that a string is not a valid URI.
/// </summary>
[Serializable]
internal class InvalidUriException : UriException
{
/// <summary>
/// Creates a new instance of <see cref = "InvalidUriException"/>.
/// </summary>
/// <param name = "parameterName">The name of the parameter (optional).</param>
/// <param name = "message">The message of the exception (optional).</param>
public InvalidUriException(string? parameterName = null, string? message = null) : base(parameterName, message)
{
}

/// <inheritdoc/>
protected InvalidUriException(SerializationInfo info, StreamingContext context) : base(info, context)
{
}
}

/// <summary>
/// This exception indicates that an URI has an invalid scheme.
/// </summary>
Expand Down Expand Up @@ -12120,6 +12215,13 @@ public static void SameObjectReference<T>(T? parameter, [CallerArgumentExpressio
[DoesNotReturn]
public static void Substring(string parameter, string other, StringComparison comparisonType, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new SubstringException(parameterName, message ?? $"{parameterName ?? "The string"} must not be a substring of \"{other}\" ({comparisonType}), but it actually is {parameter.ToStringOrNull()}.");
/// <summary>
/// Throws the default <see cref = "InvalidUriException"/> indicating that a string is not a valid URI of the
/// supplied kind, using the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeUri(string parameter, UriKind uriKind, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new InvalidUriException(parameterName, message ?? $"{parameterName ?? "The string"} must be a valid URI ({uriKind}), but it actually is \"{parameter}\".");
/// <summary>
/// Throws the default <see cref = "RelativeUriException"/> indicating that a URI is relative instead of absolute,
/// using the optional parameter name and message.
/// </summary>
Expand Down
48 changes: 48 additions & 0 deletions ai-plans/0165-parsable-uri-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Parsable URI String Assertion

## Rationale

Parent issue #162 (point 2) identifies URI validation on strings as a recurring need: BrilliantMessaging validates a URI supplied as a string and accepts `UriKind.RelativeOrAbsolute`, but the existing URI assertions operate on `Uri` instances, forcing callers to write `Uri.TryCreate` logic themselves. Add `Check.MustBeUri` for strings with a configurable `UriKind`, so URI string validation reads explicitly and remains usable on `netstandard2.0`.

## Acceptance Criteria

- [x] `Check.MustBeUri(parameter, uriKind, parameterName, message)` returns the original string when `Uri.TryCreate` accepts it under the supplied `UriKind` (default `RelativeOrAbsolute`), throws the new `InvalidUriException` (deriving from `UriException`) when parsing fails, and throws `ArgumentNullException` when the parameter is null — identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10. The exception carries the parameter name and optional custom message like the existing URI exceptions.
- [x] Two custom-exception-factory overloads mirror the `MustHaveScheme` convention (`Func<string?, Exception>` and `Func<string?, UriKind, Exception>`); they throw the factory's exception only when validation fails (a null parameter counts as failure), never invoke the factory otherwise, and a null factory on failure throws `ArgumentNullException` via the existing `Throw.CustomException` convention.
- [x] Automated tests cover: parsable and unparsable strings for each `UriKind` (including relative-only and absolute-only acceptance), null parameter handling for all overloads, return-value identity with the input string, parameter-name and custom-message propagation, both factory overloads (including the passed parameter and `UriKind`), factories not invoked on success, and null-factory behavior.
- [x] The source-export whitelist catalog and committed settings contain `MustBeUri`, and focused source-export tests cover retention of the guard, its `Throw.MustBeUri` helper, and the `InvalidUriException` type as well as trimming of the exception-factory overloads when configured.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets.
- [x] The "URI assertions" table in `docs/assertion-overview.md` lists `MustBeUri`, and the 15.1.0 package release notes in `src/Directory.Build.props` mention the new guard.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Add the guard as a new `Check.MustBeUri.cs` modeled on `Check.MustBeEmailAddress.cs` (aggressive inlining, JetBrains contract annotations, XML documentation style). The exact public shape is:

```csharp
public static string MustBeUri(
this string? parameter,
UriKind uriKind = UriKind.RelativeOrAbsolute,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
);

public static string MustBeUri(
this string? parameter,
UriKind uriKind,
Func<string?, Exception> exceptionFactory
);

public static string MustBeUri(
this string? parameter,
UriKind uriKind,
Func<string?, UriKind, Exception> exceptionFactory
);
```

The guard delegates to `Uri.TryCreate(parameter, uriKind, out _)`, which exists on all library targets; BCL semantics apply verbatim (for example, the empty string parses as a valid relative reference and passes with `RelativeOrAbsolute`). The return-type question left open in #162 is resolved in favor of library convention: the assertion returns the original string for fluent argument validation, and callers that need a `Uri` can safely construct one after the guard. No span or memory overloads are added because `Uri.TryCreate` is string-based only, and no microbenchmarks are needed for this thin wrapper.

Add `InvalidUriException : UriException` in `Exceptions/InvalidUriException.cs`, modeled on `RelativeUriException` (`[Serializable]`, protected serialization constructor under `#if !NET8_0_OR_GREATER`). Deriving from `UriException` groups string parsing failures with the other URI assertion failures under a common `ArgumentException` base. Add a non-returning `Throw.MustBeUri` helper to the existing `ExceptionFactory/Throw.Uri.cs` (`[ContractAnnotation("=> halt")]`, `[DoesNotReturn]`) whose default message follows the established URI pattern, e.g. `{parameterName ?? "The string"} must be a valid URI ({uriKind}), but it actually is "{parameter}".`

The default overload validates null via `MustNotBeNull` (like `MustBeEmailAddress`); the factory overloads treat null as a validation failure and pass the null value to the factory, matching the existing string-assertion convention.

Add a `MustBeUri` entry to `AssertionWhitelist` and `settings.json` between `MustBeRelativeUri` and `MustContain` (preserving alphabetical position), add focused facts to `SourceFileMergerWhitelistTests` modeled on the `ObjectDisposed` facts for dependency retention and factory-overload trimming, add `MustBeUriTests` under `tests/Light.GuardClauses.Tests/UriAssertions/` following `MustBeAbsoluteUriTests` conventions (FluentAssertions, `Test.CustomMessage`/`Test.CustomException` helpers, `DefaultVariablesData` where useful), extend the per-assertion nullability flow-analysis facts in `tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs` (which exhaustively cover value-returning assertions such as `MustBeAbsoluteUri`), and regenerate `Light.GuardClauses.SingleFile.cs` via the source-export project's committed settings.
1 change: 1 addition & 0 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ The relation methods provide comparer overloads where applicable.

| Assertion | Behavior |
| --- | --- |
| `MustBeUri` | Require a string parsable as a relative, absolute, or either kind of URI |
| `MustBeAbsoluteUri`, `MustBeRelativeUri` | Require an absolute or relative URI |
| `MustHaveScheme`, `MustHaveOneSchemeOf` | Require one specific scheme or one of several schemes |
| `MustBeHttpUrl`, `MustBeHttpsUrl`, `MustBeHttpOrHttpsUrl` | Require an absolute HTTP/HTTPS URI with the named allowed scheme |
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Light.GuardClauses 15.1.0
--------------------------------

- new assertions: ObjectDisposed
- new assertions: MustBeUri, ObjectDisposed
</PackageReleaseNotes>
</PropertyGroup>

Expand Down
99 changes: 99 additions & 0 deletions src/Light.GuardClauses/Check.MustBeUri.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Light.GuardClauses.ExceptionFactory;
using Light.GuardClauses.Exceptions;
using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute;

namespace Light.GuardClauses;

public static partial class Check
{
/// <summary>
/// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws an
/// <see cref="InvalidUriException" />.
/// </summary>
/// <param name="parameter">The string to be checked.</param>
/// <param name="uriKind">The kind of URI that the string must represent.</param>
/// <param name="parameterName">The name of the parameter (optional).</param>
/// <param name="message">The message that will be passed to the resulting exception (optional).</param>
/// <exception cref="InvalidUriException">
/// Thrown when <paramref name="parameter" /> is not a valid URI of the supplied kind.
/// </exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="parameter" /> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static string MustBeUri(
[NotNull] [ValidatedNotNull] this string? parameter,
UriKind uriKind = UriKind.RelativeOrAbsolute,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
)
{
parameter.MustNotBeNull(parameterName, message);

if (!Uri.TryCreate(parameter, uriKind, out _))
{
Throw.MustBeUri(parameter, uriKind, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom
/// exception.
/// </summary>
/// <param name="parameter">The string to be checked.</param>
/// <param name="uriKind">The kind of URI that the string must represent.</param>
/// <param name="exceptionFactory">
/// The delegate that creates the exception to be thrown. <paramref name="parameter" /> is passed to this delegate.
/// </param>
/// <exception cref="Exception">
/// Your custom exception thrown when <paramref name="parameter" /> is null or not a valid URI of the supplied kind.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static string MustBeUri(
[NotNull] [ValidatedNotNull] this string? parameter,
UriKind uriKind,
Func<string?, Exception> exceptionFactory
)
{
if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom
/// exception.
/// </summary>
/// <param name="parameter">The string to be checked.</param>
/// <param name="uriKind">The kind of URI that the string must represent.</param>
/// <param name="exceptionFactory">
/// The delegate that creates the exception to be thrown. <paramref name="parameter" /> and
/// <paramref name="uriKind" /> are passed to this delegate.
/// </param>
/// <exception cref="Exception">
/// Your custom exception thrown when <paramref name="parameter" /> is null or not a valid URI of the supplied kind.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static string MustBeUri(
[NotNull] [ValidatedNotNull] this string? parameter,
UriKind uriKind,
Func<string?, UriKind, Exception> exceptionFactory
)
{
if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _))
{
Throw.CustomException(exceptionFactory, parameter, uriKind);
}

return parameter;
}
}
18 changes: 18 additions & 0 deletions src/Light.GuardClauses/ExceptionFactory/Throw.Uri.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ namespace Light.GuardClauses.ExceptionFactory;

public static partial class Throw
{
/// <summary>
/// Throws the default <see cref="InvalidUriException" /> indicating that a string is not a valid URI of the
/// supplied kind, using the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeUri(
string parameter,
UriKind uriKind,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
) =>
throw new InvalidUriException(
parameterName,
message ??
$"{parameterName ?? "The string"} must be a valid URI ({uriKind}), but it actually is \"{parameter}\"."
);

/// <summary>
/// Throws the default <see cref="RelativeUriException" /> indicating that a URI is relative instead of absolute,
/// using the optional parameter name and message.
Expand Down
23 changes: 23 additions & 0 deletions src/Light.GuardClauses/Exceptions/InvalidUriException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Runtime.Serialization;

namespace Light.GuardClauses.Exceptions;

/// <summary>
/// This exception indicates that a string is not a valid URI.
/// </summary>
[Serializable]
public class InvalidUriException : UriException
{
/// <summary>
/// Creates a new instance of <see cref="InvalidUriException" />.
/// </summary>
/// <param name="parameterName">The name of the parameter (optional).</param>
/// <param name="message">The message of the exception (optional).</param>
public InvalidUriException(string? parameterName = null, string? message = null) : base(parameterName, message) { }

#if !NET8_0_OR_GREATER
/// <inheritdoc />
protected InvalidUriException(SerializationInfo info, StreamingContext context) : base(info, context) { }
#endif
}
Loading
Loading