From a3f6d5b592ff7314f5c154a18d6ef5467b041a76 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 23 Jul 2026 20:03:46 +0200 Subject: [PATCH 1/2] docs: add AI plan for Parsable URI Strings Signed-off-by: Kenny Pflug --- ai-plans/0165-parsable-uri-strings.md | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ai-plans/0165-parsable-uri-strings.md diff --git a/ai-plans/0165-parsable-uri-strings.md b/ai-plans/0165-parsable-uri-strings.md new file mode 100644 index 0000000..0621d2b --- /dev/null +++ b/ai-plans/0165-parsable-uri-strings.md @@ -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 + +- [ ] `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. +- [ ] Two custom-exception-factory overloads mirror the `MustHaveScheme` convention (`Func` and `Func`); 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. +- [ ] 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. +- [ ] 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. +- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets. +- [ ] The "URI assertions" table in `docs/assertion-overview.md` lists `MustBeUri`, and the 15.0.0 package release notes in `src/Directory.Build.props` mention the new guard. +- [ ] 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 exceptionFactory +); + +public static string MustBeUri( + this string? parameter, + UriKind uriKind, + Func 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. From f22a5503f4ddda0ee5bcb4521553b66c018ba6ce Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 23 Jul 2026 20:17:27 +0200 Subject: [PATCH 2/2] feat: add MustBeUri Signed-off-by: Kenny Pflug --- Light.GuardClauses.SingleFile.cs | 104 ++++++++++- ai-plans/0165-parsable-uri-strings.md | 14 +- docs/assertion-overview.md | 1 + src/Directory.Build.props | 2 +- src/Light.GuardClauses/Check.MustBeUri.cs | 99 ++++++++++ .../ExceptionFactory/Throw.Uri.cs | 18 ++ .../Exceptions/InvalidUriException.cs | 23 +++ .../SourceFileMergerWhitelistTests.cs | 49 +++++ .../Issues/Issue72NotNullAttributeTests.cs | 29 ++- .../UriAssertions/MustBeUriTests.cs | 171 ++++++++++++++++++ .../AssertionWhitelist.cs | 2 + .../settings.json | 1 + 12 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 src/Light.GuardClauses/Check.MustBeUri.cs create mode 100644 src/Light.GuardClauses/Exceptions/InvalidUriException.cs create mode 100644 tests/Light.GuardClauses.Tests/UriAssertions/MustBeUriTests.cs diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index 031f93b..3976649 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -1,5 +1,5 @@ /* ------------------------------ - Light.GuardClauses 15.0.0 + Light.GuardClauses 15.1.0 ------------------------------ License information for Light.GuardClauses @@ -5198,6 +5198,80 @@ public static ReadOnlyMemory MustBeUpperCase(this ReadOnlyMemory par return parameter; } + /// + /// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws an + /// . + /// + /// The string to be checked. + /// The kind of URI that the string must represent. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// + /// Thrown when is not a valid URI of the supplied kind. + /// + /// Thrown when is null. + [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; + } + + /// + /// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom + /// exception. + /// + /// The string to be checked. + /// The kind of URI that the string must represent. + /// + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// + /// + /// Your custom exception thrown when is null or not a valid URI of the supplied kind. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static string MustBeUri([NotNull][ValidatedNotNull] this string? parameter, UriKind uriKind, Func exceptionFactory) + { + if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _)) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + + /// + /// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom + /// exception. + /// + /// The string to be checked. + /// The kind of URI that the string must represent. + /// + /// The delegate that creates the exception to be thrown. and + /// are passed to this delegate. + /// + /// + /// Your custom exception thrown when is null or not a valid URI of the supplied kind. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static string MustBeUri([NotNull][ValidatedNotNull] this string? parameter, UriKind uriKind, Func exceptionFactory) + { + if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _)) + { + Throw.CustomException(exceptionFactory, parameter, uriKind); + } + + return parameter; + } + /// /// Ensures that the specified uses , or otherwise throws an . /// @@ -11050,6 +11124,27 @@ protected InvalidStateException(SerializationInfo info, StreamingContext context } } + /// + /// This exception indicates that a string is not a valid URI. + /// + [Serializable] + internal class InvalidUriException : UriException + { + /// + /// Creates a new instance of . + /// + /// The name of the parameter (optional). + /// The message of the exception (optional). + public InvalidUriException(string? parameterName = null, string? message = null) : base(parameterName, message) + { + } + + /// + protected InvalidUriException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } + /// /// This exception indicates that an URI has an invalid scheme. /// @@ -12120,6 +12215,13 @@ public static void SameObjectReference(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()}."); /// + /// Throws the default indicating that a string is not a valid URI of the + /// supplied kind, using the optional parameter name and message. + /// + [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}\"."); + /// /// Throws the default indicating that a URI is relative instead of absolute, /// using the optional parameter name and message. /// diff --git a/ai-plans/0165-parsable-uri-strings.md b/ai-plans/0165-parsable-uri-strings.md index 0621d2b..163c2f8 100644 --- a/ai-plans/0165-parsable-uri-strings.md +++ b/ai-plans/0165-parsable-uri-strings.md @@ -6,13 +6,13 @@ Parent issue #162 (point 2) identifies URI validation on strings as a recurring ## Acceptance Criteria -- [ ] `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. -- [ ] Two custom-exception-factory overloads mirror the `MustHaveScheme` convention (`Func` and `Func`); 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. -- [ ] 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. -- [ ] 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. -- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets. -- [ ] The "URI assertions" table in `docs/assertion-overview.md` lists `MustBeUri`, and the 15.0.0 package release notes in `src/Directory.Build.props` mention the new guard. -- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. +- [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` and `Func`); 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 diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index f654545..4a66510 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -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 | diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c10db19..ac44bf7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,7 +20,7 @@ Light.GuardClauses 15.1.0 -------------------------------- - - new assertions: ObjectDisposed + - new assertions: MustBeUri, ObjectDisposed diff --git a/src/Light.GuardClauses/Check.MustBeUri.cs b/src/Light.GuardClauses/Check.MustBeUri.cs new file mode 100644 index 0000000..3d970f0 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBeUri.cs @@ -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 +{ + /// + /// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws an + /// . + /// + /// The string to be checked. + /// The kind of URI that the string must represent. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// + /// Thrown when is not a valid URI of the supplied kind. + /// + /// Thrown when is null. + [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; + } + + /// + /// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom + /// exception. + /// + /// The string to be checked. + /// The kind of URI that the string must represent. + /// + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// + /// + /// Your custom exception thrown when is null or not a valid URI of the supplied kind. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static string MustBeUri( + [NotNull] [ValidatedNotNull] this string? parameter, + UriKind uriKind, + Func exceptionFactory + ) + { + if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _)) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + + /// + /// Ensures that the specified string is a valid URI of the supplied kind, or otherwise throws your custom + /// exception. + /// + /// The string to be checked. + /// The kind of URI that the string must represent. + /// + /// The delegate that creates the exception to be thrown. and + /// are passed to this delegate. + /// + /// + /// Your custom exception thrown when is null or not a valid URI of the supplied kind. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static string MustBeUri( + [NotNull] [ValidatedNotNull] this string? parameter, + UriKind uriKind, + Func exceptionFactory + ) + { + if (parameter is null || !Uri.TryCreate(parameter, uriKind, out _)) + { + Throw.CustomException(exceptionFactory, parameter, uriKind); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.Uri.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.Uri.cs index 4f7a804..a33a10d 100644 --- a/src/Light.GuardClauses/ExceptionFactory/Throw.Uri.cs +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.Uri.cs @@ -11,6 +11,24 @@ namespace Light.GuardClauses.ExceptionFactory; public static partial class Throw { + /// + /// Throws the default indicating that a string is not a valid URI of the + /// supplied kind, using the optional parameter name and message. + /// + [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}\"." + ); + /// /// Throws the default indicating that a URI is relative instead of absolute, /// using the optional parameter name and message. diff --git a/src/Light.GuardClauses/Exceptions/InvalidUriException.cs b/src/Light.GuardClauses/Exceptions/InvalidUriException.cs new file mode 100644 index 0000000..6f5dbae --- /dev/null +++ b/src/Light.GuardClauses/Exceptions/InvalidUriException.cs @@ -0,0 +1,23 @@ +using System; +using System.Runtime.Serialization; + +namespace Light.GuardClauses.Exceptions; + +/// +/// This exception indicates that a string is not a valid URI. +/// +[Serializable] +public class InvalidUriException : UriException +{ + /// + /// Creates a new instance of . + /// + /// The name of the parameter (optional). + /// The message of the exception (optional). + public InvalidUriException(string? parameterName = null, string? message = null) : base(parameterName, message) { } + +#if !NET8_0_OR_GREATER + /// + protected InvalidUriException(SerializationInfo info, StreamingContext context) : base(info, context) { } +#endif +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index 8dd30cb..fac56fc 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -528,6 +528,55 @@ public static void ObjectDisposedWhitelistExportsGuardThrowHelperAndExceptionFac sourceCode.Should().NotContain("public static void InvalidOperation("); } + [Fact] + public static void MustBeUriWhitelistExportsGuardThrowHelperExceptionAndFactories() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeUri.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("MustBeUri", true)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("public static string MustBeUri("); + sourceCode.Should().Contain("public static void MustBeUri("); + sourceCode.Should().Contain("class InvalidUriException : UriException"); + sourceCode.Should().Contain("class UriException : ArgumentException"); + sourceCode.Should().Contain("Func exceptionFactory"); + sourceCode.Should().Contain("Func exceptionFactory"); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().NotContain("public static void MustBeAbsoluteUri("); + sourceCode.Should().NotContain("class RelativeUriException : UriException"); + } + + [Fact] + public static void MustBeUriWhitelistTrimsExceptionFactoryOverloads() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeUriWithoutFactories.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("MustBeUri", false)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("public static string MustBeUri("); + sourceCode.Should().Contain("public static void MustBeUri("); + sourceCode.Should().Contain("class InvalidUriException : UriException"); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("public static void CustomException("); + sourceCode.Should().NotContain("public static void CustomException("); + } + [Fact] public static void ObjectDisposedWhitelistTrimsExceptionFactoryOverloads() { diff --git a/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs b/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs index c6c0123..7d269b6 100644 --- a/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs +++ b/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs @@ -1102,6 +1102,33 @@ static Uri TestMustBeRelativeUriWithDelegate(Uri? input) } } + [Fact] + public static void CheckMustBeUri() + { + TestMustBeUri("https://example.com").Should().Be("https://example.com"); + TestMustBeUriWithDelegate("https://example.com").Should().Be("https://example.com"); + TestMustBeUriWithSecondDelegate("https://example.com").Should().Be("https://example.com"); + return; + + static string TestMustBeUri(string? input) + { + input.MustBeUri(); + return input; + } + + static string TestMustBeUriWithDelegate(string? input) + { + input.MustBeUri(UriKind.Absolute, _ => new Exception()); + return input; + } + + static string TestMustBeUriWithSecondDelegate(string? input) + { + input.MustBeUri(UriKind.Absolute, (_, _) => new Exception()); + return input; + } + } + [Fact] public static void CheckMustHaveScheme() { @@ -1517,4 +1544,4 @@ static int TestGetAllExceptionMessages(Exception? exception) return exception.Message.Length; } } -} \ No newline at end of file +} diff --git a/tests/Light.GuardClauses.Tests/UriAssertions/MustBeUriTests.cs b/tests/Light.GuardClauses.Tests/UriAssertions/MustBeUriTests.cs new file mode 100644 index 0000000..66ef57e --- /dev/null +++ b/tests/Light.GuardClauses.Tests/UriAssertions/MustBeUriTests.cs @@ -0,0 +1,171 @@ +#nullable enable + +using System; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Xunit; + +namespace Light.GuardClauses.Tests.UriAssertions; + +public static class MustBeUriTests +{ + [Theory(DisplayName = "MustBeUri must return the original string when it can be parsed with the supplied URI kind.")] + [MemberData(nameof(ParsableUriData))] + public static void ParsableUri(string value, UriKind uriKind) + { + var result = value.MustBeUri(uriKind); + + result.Should().BeSameAs(value); + } + + public static readonly TheoryData ParsableUriData = + new () + { + { "https://example.com/api/orders", UriKind.Absolute }, + { "api/orders/42", UriKind.Relative }, + { "https://example.com/api/orders", UriKind.RelativeOrAbsolute }, + { "api/orders/42", UriKind.RelativeOrAbsolute }, + }; + + [Fact(DisplayName = "MustBeUri must use RelativeOrAbsolute by default and accept an empty relative reference.")] + public static void DefaultUriKind() + { + var value = new string([]); + + value.MustBeUri().Should().BeSameAs(value); + } + + [Theory(DisplayName = "MustBeUri must throw InvalidUriException when parsing fails for the supplied URI kind.")] + [MemberData(nameof(UnparsableUriData))] + public static void UnparsableUri(string value, UriKind uriKind) + { + Action act = () => value.MustBeUri(uriKind); + + act.Should().Throw(); + } + + public static readonly TheoryData UnparsableUriData = + new () + { + { "api/orders/42", UriKind.Absolute }, + { "https://example.com/api/orders", UriKind.Relative }, + { "http://[::1", UriKind.RelativeOrAbsolute }, + }; + + [Fact] + public static void NullParameter() + { + string? value = null; + + Action act = () => value.MustBeUri(); + + act.Should().Throw() + .WithParameterName(nameof(value)); + } + + [Fact] + public static void ParameterName() + { + Action act = () => "api/orders".MustBeUri(UriKind.Absolute, "endpoint"); + + act.Should().Throw() + .WithParameterName("endpoint") + .WithMessage("*endpoint must be a valid URI (Absolute)*"); + } + + [Fact] + public static void CallerArgumentExpression() + { + var endpoint = "api/orders"; + + Action act = () => endpoint.MustBeUri(UriKind.Absolute); + + act.Should().Throw() + .WithParameterName(nameof(endpoint)); + } + + [Fact] + public static void CustomMessage() => + Test.CustomMessage( + message => "api/orders".MustBeUri(UriKind.Absolute, message: message) + ); + + [Fact] + public static void CustomMessageForNullParameter() => + Test.CustomMessage( + message => ((string?) null).MustBeUri(message: message) + ); + + [Theory] + [MemberData(nameof(UnparsableUriData))] + public static void CustomExceptionFactoryReceivesParameter(string value, UriKind uriKind) => + Test.CustomException( + value, + (invalidValue, exceptionFactory) => invalidValue.MustBeUri(uriKind, exceptionFactory) + ); + + [Theory] + [MemberData(nameof(UnparsableUriData))] + public static void CustomExceptionFactoryReceivesParameterAndUriKind(string value, UriKind uriKind) => + Test.CustomException( + value, + uriKind, + (invalidValue, kind, exceptionFactory) => invalidValue.MustBeUri(kind, exceptionFactory) + ); + + [Fact] + public static void NullParameterIsPassedToCustomExceptionFactory() => + Test.CustomException( + null, + (value, exceptionFactory) => value.MustBeUri(UriKind.RelativeOrAbsolute, exceptionFactory) + ); + + [Fact] + public static void NullParameterAndUriKindArePassedToCustomExceptionFactory() => + Test.CustomException( + (string?) null, + UriKind.Absolute, + (value, uriKind, exceptionFactory) => value.MustBeUri(uriKind, exceptionFactory) + ); + + [Fact] + public static void ParameterOnlyFactoryIsNotInvokedOnSuccess() + { + var value = new string("https://example.com".ToCharArray()); + + var result = value.MustBeUri(UriKind.Absolute, (Func) null!); + + result.Should().BeSameAs(value); + } + + [Fact] + public static void ParameterAndUriKindFactoryIsNotInvokedOnSuccess() + { + var value = new string("api/orders".ToCharArray()); + + var result = value.MustBeUri(UriKind.Relative, (Func) null!); + + result.Should().BeSameAs(value); + } + + [Fact] + public static void NullParameterOnlyFactoryThrowsArgumentNullException() + { + Action act = () => "api/orders".MustBeUri(UriKind.Absolute, (Func) null!); + + act.Should().Throw() + .WithParameterName("exceptionFactory"); + } + + [Fact] + public static void NullParameterAndUriKindFactoryThrowsArgumentNullException() + { + Action act = () => "api/orders".MustBeUri( + UriKind.Absolute, + (Func) null! + ); + + act.Should().Throw() + .WithParameterName("exceptionFactory"); + } +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index 107cf52..02474db 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -175,6 +175,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustBeUpperCase { get; init; } = new(); + public AssertionEntry MustBeUri { get; init; } = new(); + public AssertionEntry MustBeUtc { get; init; } = new(); public AssertionEntry MustBeUuidVersion7 { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index e769b2f..18f4c9f 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -101,6 +101,7 @@ "MustBeTrimmedAtStart": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeUnspecified": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeUpperCase": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeUtc": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeUuidVersion7": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeValidEnumValue": { "Include": true, "IncludeExceptionFactoryOverload": true },