From 65f9a60679bd6fefe6ad7d875135914212db9b9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:32:49 +0000 Subject: [PATCH 1/2] feat(dummies): add the Any.Uri() family of URI generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Any.Uri(), an arbitrary-yet-valid System.Uri generator organised as a typed family. The unconstrained root spans the safe URI space — an absolute web (http/https), WebSocket (ws/wss), FTP or mailto URI, or a relative reference — and each narrowing (Web/WebSocket/Ftp/Mailto/Relative) returns a family-specific builder that exposes only that family's valid components, so an impossible combination (a port on a mailto, a fragment on a WebSocket) cannot be written rather than being caught at run time. Every component is drawn from ASCII-unreserved characters and the URI is assembled directly, so a value is valid by construction — never generated then filtered — and reproducible under a seed on every target framework. Internationalized (IDN) hosts are rejected (pass punycode) and the file scheme is kept out of the unconstrained draw, because a platform-dependent path would not round-trip identically across frameworks — the way Any.Double() keeps NaN and the infinities out of its default draw. Mirror the factory on AnyContext, declare the surface in both per-TFM public API baselines, and cover it with per-family valid-by-construction fuzzing, scheme and family reachability, the conflict cases, argument validation (IDN rejection, port and segment bounds) and seeded reproducibility. Refs: #226 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FPAdCRsiaVo2orjJwCaDnb --- Dummies.UnitTests/AnyUriTests.cs | 274 +++++++++++++ Dummies/Any.cs | 12 + Dummies/AnyContext.cs | 9 + Dummies/AnyFtpUri.cs | 78 ++++ Dummies/AnyMailtoUri.cs | 49 +++ Dummies/AnyRelativeUri.cs | 52 +++ Dummies/AnyUri.cs | 79 ++++ Dummies/AnyWebSocketUri.cs | 75 ++++ Dummies/AnyWebUri.cs | 100 +++++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 54 +++ .../netstandard2.0/PublicAPI.Unshipped.txt | 54 +++ Dummies/README.nuget.md | 9 + Dummies/UriSpec.cs | 360 ++++++++++++++++++ 13 files changed, 1205 insertions(+) create mode 100644 Dummies.UnitTests/AnyUriTests.cs create mode 100644 Dummies/AnyFtpUri.cs create mode 100644 Dummies/AnyMailtoUri.cs create mode 100644 Dummies/AnyRelativeUri.cs create mode 100644 Dummies/AnyUri.cs create mode 100644 Dummies/AnyWebSocketUri.cs create mode 100644 Dummies/AnyWebUri.cs create mode 100644 Dummies/UriSpec.cs diff --git a/Dummies.UnitTests/AnyUriTests.cs b/Dummies.UnitTests/AnyUriTests.cs new file mode 100644 index 00000000..ccb15f57 --- /dev/null +++ b/Dummies.UnitTests/AnyUriTests.cs @@ -0,0 +1,274 @@ +#region Usings declarations + +using System; +using System.Collections.Generic; + +using JetBrains.Annotations; + +using NFluent; + +using Xunit; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(AnyUri))] +public sealed class AnyUriTests { + + private const int FuzzSeeds = 500; + private const int SampleCount = 300; + + #region Statics members declarations + + // Valid by construction: across many seeds a family generator never throws and yields the expected URI kind. + private static void GeneratesAcross(Func> build, UriKind? kind) { + for (int seed = 0; seed < FuzzSeeds; seed++) { + Uri value; + try { value = build(Any.WithSeed(seed)).Generate(); } + catch (Exception error) { Assert.Fail($"seed {seed}: {error.GetType().Name}: {error.Message}"); return; } + + if (kind.HasValue) { + Check.WithCustomMessage($"seed {seed}: '{value.OriginalString}'") + .That(value.IsAbsoluteUri).IsEqualTo(kind.Value == UriKind.Absolute); + } + } + } + + private static IEnumerable Sample(IAny generator) { + for (int i = 0; i < SampleCount; i++) { yield return generator.Generate(); } + } + + private static IAny Seeded(Func> build) { + return build(Any.WithSeed(20260723)); + } + + #endregion + + [Fact(DisplayName = "Every family is valid by construction: it never throws and yields the expected URI kind.")] + public void EveryFamilyGeneratesValidUris() { + GeneratesAcross(context => context.Uri(), null); + GeneratesAcross(context => context.Uri().Web(), UriKind.Absolute); + GeneratesAcross(context => context.Uri().WebSocket(), UriKind.Absolute); + GeneratesAcross(context => context.Uri().Ftp(), UriKind.Absolute); + GeneratesAcross(context => context.Uri().Mailto(), UriKind.Absolute); + GeneratesAcross(context => context.Uri().Relative(), UriKind.Relative); + } + + [Fact(DisplayName = "The unconstrained generator reaches every family.")] + public void UnconstrainedReachesEveryFamily() { + HashSet seen = new(); + foreach (Uri value in Sample(Seeded(context => context.Uri()))) { + seen.Add(value.IsAbsoluteUri ? value.Scheme : "relative"); + } + + Check.That(seen.Contains("http") || seen.Contains("https")).IsTrue(); + Check.That(seen.Contains("ws") || seen.Contains("wss")).IsTrue(); + Check.That(seen.Contains("ftp")).IsTrue(); + Check.That(seen.Contains("mailto")).IsTrue(); + Check.That(seen.Contains("relative")).IsTrue(); + } + + [Fact(DisplayName = "Web reaches both http and https; each generated value is one of them.")] + public void WebReachesBothSchemes() { + HashSet seen = new(); + foreach (Uri value in Sample(Seeded(context => context.Uri().Web()))) { + seen.Add(value.Scheme); + Check.That(value.Scheme is "http" or "https").IsTrue(); + } + + Check.That(seen.Contains("http")).IsTrue(); + Check.That(seen.Contains("https")).IsTrue(); + } + + [Fact(DisplayName = "WebSocket reaches both ws and wss.")] + public void WebSocketReachesBothSchemes() { + HashSet seen = new(); + foreach (Uri value in Sample(Seeded(context => context.Uri().WebSocket()))) { + seen.Add(value.Scheme); + Check.That(value.Scheme is "ws" or "wss").IsTrue(); + } + + Check.That(seen.Contains("ws")).IsTrue(); + Check.That(seen.Contains("wss")).IsTrue(); + } + + [Fact(DisplayName = "UsingHttps pins the scheme to https.")] + public void UsingHttpsPinsTheScheme() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().UsingHttps()))) { + Check.That(value.Scheme).IsEqualTo("https"); + } + } + + [Fact(DisplayName = "WithHost pins the host.")] + public void WithHostPinsTheHost() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithHost("api.example.com")))) { + Check.That(value.Host).IsEqualTo("api.example.com"); + } + } + + [Fact(DisplayName = "WithPort pins the port.")] + public void WithPortPinsThePort() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithPort(8443)))) { + Check.That(value.Port).IsEqualTo(8443); + } + } + + [Fact(DisplayName = "WithoutPath renders the root path.")] + public void WithoutPathRendersRoot() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithoutPath()))) { + Check.That(value.AbsolutePath).IsEqualTo("/"); + } + } + + [Fact(DisplayName = "WithPathSegments renders exactly that many segments.")] + public void WithPathSegmentsRendersThatManySegments() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithHost("h.test").WithPathSegments(3)))) { + Check.That(value.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries).Length).IsEqualTo(3); + } + } + + [Fact(DisplayName = "WithUserInfo, WithQuery and WithFragment add their components.")] + public void OptionalComponentsAreIncluded() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithUserInfo().WithQuery().WithFragment()))) { + Check.That(value.UserInfo.Length).IsStrictlyGreaterThan(0); + Check.That(value.Query.Length).IsStrictlyGreaterThan(0); + Check.That(value.Fragment.Length).IsStrictlyGreaterThan(0); + } + } + + [Fact(DisplayName = "WithUserInfo(user, password) pins both parts.")] + public void WithUserInfoPinsBothParts() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Ftp().WithUserInfo("admin", "s3cret")))) { + Check.That(value.UserInfo).IsEqualTo("admin:s3cret"); + } + } + + [Fact(DisplayName = "Ftp yields the ftp scheme with user-info.")] + public void FtpYieldsFtpScheme() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Ftp().WithUserInfo()))) { + Check.That(value.Scheme).IsEqualTo("ftp"); + Check.That(value.UserInfo.Length).IsStrictlyGreaterThan(0); + } + } + + [Fact(DisplayName = "Mailto yields the mailto scheme and an address.")] + public void MailtoYieldsAnAddress() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Mailto()))) { + Check.That(value.Scheme).IsEqualTo("mailto"); + Check.That(value.OriginalString).StartsWith("mailto:"); + Check.That(value.OriginalString).Contains("@"); + } + } + + [Fact(DisplayName = "Mailto pins the local-part and domain.")] + public void MailtoPinsLocalPartAndDomain() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Mailto().WithLocalPart("john").WithDomain("example.test")))) { + Check.That(value.OriginalString).IsEqualTo("mailto:john@example.test"); + } + } + + [Fact(DisplayName = "Relative yields a relative reference (not absolute).")] + public void RelativeYieldsARelativeReference() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Relative()))) { + Check.That(value.IsAbsoluteUri).IsFalse(); + } + } + + [Fact(DisplayName = "Relative().Rooted() starts the path with a slash.")] + public void RootedRelativeStartsWithSlash() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Relative().Rooted().WithPathSegments(2)))) { + Check.That(value.OriginalString).StartsWith("/"); + } + } + + [Fact(DisplayName = "A second scheme pin conflicts, naming both sides.")] + public void SecondSchemePinConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Uri().Web().UsingHttp().UsingHttps()); + + Check.That(conflict.Message).Contains("UsingHttps()"); + Check.That(conflict.Message).Contains("UsingHttp()"); + } + + [Fact(DisplayName = "A second path constraint conflicts, naming both sides.")] + public void SecondPathConstraintConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Uri().Web().WithoutPath().WithPathSegments(2)); + + Check.That(conflict.Message).Contains("WithPathSegments(2)"); + Check.That(conflict.Message).Contains("WithoutPath()"); + } + + [Fact(DisplayName = "WithHost rejects a non-ASCII (IDN) host, pointing to punycode.")] + public void WithHostRejectsIdnHost() { + ArgumentException error = Assert.Throws(() => Any.Uri().Web().WithHost("münchen.de")); + + Check.That(error.Message).Contains("punycode"); + } + + [Fact(DisplayName = "Host, user-info, port and segment-count arguments are validated as arguments.")] + public void ArgumentsAreValidated() { + Check.ThatCode(() => Any.Uri().Web().WithHost(null!)).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithHost("")).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithHost("bad host")).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithUserInfo("a:b")).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithPort(0)).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithPort(70000)).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithPathSegments(-1)).Throws(); + } + + [Fact(DisplayName = "WithUserInfo(user) keeps the user and draws an arbitrary password.")] + public void WithUserInfoPinsUserAndDrawsPassword() { + foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithUserInfo("bob")))) { + Check.That(value.UserInfo).StartsWith("bob:"); + Check.That(value.UserInfo.Length).IsStrictlyGreaterThan("bob:".Length); + } + } + + [Fact(DisplayName = "A relative URI with an explicit 0 segments and nothing else fails at generation with a seed.")] + public void EmptyRelativeFailsAtGeneration() { + AnyGenerationException error = Assert.Throws( + () => Any.WithSeed(20260723).Uri().Relative().WithPathSegments(0).Generate()); + + Check.That(error.Seed).IsEqualTo(20260723); + } + + [Fact(DisplayName = "A relative URI with 0 segments still generates when it carries a query.")] + public void ZeroSegmentRelativeWithQueryGenerates() { + Uri value = Any.WithSeed(1).Uri().Relative().WithPathSegments(0).WithQuery().Generate(); + + Check.That(value.IsAbsoluteUri).IsFalse(); + Check.That(value.OriginalString).StartsWith("?"); + } + + [Fact(DisplayName = "WithHost rejects a non-canonical shorthand-IPv4 host that would not round-trip.")] + public void WithHostRejectsNonCanonicalHost() { + ArgumentException error = Assert.Throws(() => Any.Uri().Web().WithHost("123")); + Check.That(error.Message).Contains("canonical"); + + Check.ThatCode(() => Any.Uri().Web().WithHost("1.2")).Throws(); + Check.ThatCode(() => Any.Uri().Web().WithHost("1.2.3.4")).DoesNotThrow(); + Check.ThatCode(() => Any.Uri().Web().WithHost("api.example.com")).DoesNotThrow(); + } + + [Fact(DisplayName = "A seeded URI draw is reproducible: the same seed yields the same value.")] + public void SeededDrawIsReproducible() { + Uri first = Any.WithSeed(4242).Uri().Web().WithQuery().Generate(); + Uri second = Any.WithSeed(4242).Uri().Web().WithQuery().Generate(); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "A URI generator composes like any other: through As into a domain value.")] + public void ComposesThroughAs() { + foreach (string scheme in Sample(Seeded(context => context.Uri().Web()).As(uri => uri.Scheme))) { + Check.That(scheme is "http" or "https").IsTrue(); + } + } + + private static IEnumerable Sample(IAny generator) { + for (int i = 0; i < SampleCount; i++) { yield return generator.Generate(); } + } + +} diff --git a/Dummies/Any.cs b/Dummies/Any.cs index 5f0eb8b0..1252a644 100644 --- a/Dummies/Any.cs +++ b/Dummies/Any.cs @@ -103,6 +103,18 @@ public static AnyPattern StringMatching(Regex pattern) { return AnyPattern.FromPattern(AmbientRandomSource.Instance, pattern.ToString(), (pattern.Options & RegexOptions.IgnoreCase) != 0); } + /// + /// Starts an arbitrary generator drawing from the ambient random context. + /// Unconstrained, it yields any valid URI from the safe space — an absolute web (http/https), + /// WebSocket (ws/wss), FTP or mailto URI, or a relative reference. Narrow it to a family + /// (Web(), WebSocket(), Ftp(), Mailto(), Relative()) to reach that family's + /// component constraints; each narrowing returns a builder exposing only that family's valid components. + /// + /// A URI generator to narrow fluently. + public static AnyUri Uri() { + return new AnyUri(AmbientRandomSource.Instance, UriSpec.Unconstrained); + } + /// /// Starts an arbitrary generator drawing from the ambient random context. Unconstrained, it /// draws from the full range; chain constraints to express what the surrounding code diff --git a/Dummies/AnyContext.cs b/Dummies/AnyContext.cs index e144c2ae..649055a8 100644 --- a/Dummies/AnyContext.cs +++ b/Dummies/AnyContext.cs @@ -82,6 +82,15 @@ public AnyPattern StringMatching(Regex pattern) { return AnyPattern.FromPattern(_source, pattern.ToString(), (pattern.Options & RegexOptions.IgnoreCase) != 0); } + /// + /// Starts an arbitrary generator drawing from this context — same fluent surface as + /// , deterministic under this context's seed. + /// + /// A URI generator to narrow fluently. + public AnyUri Uri() { + return new AnyUri(_source, UriSpec.Unconstrained); + } + /// /// Starts an arbitrary generator drawing from this context — same fluent surface as /// , deterministic under this context's seed. diff --git a/Dummies/AnyFtpUri.cs b/Dummies/AnyFtpUri.cs new file mode 100644 index 00000000..e6f6088b --- /dev/null +++ b/Dummies/AnyFtpUri.cs @@ -0,0 +1,78 @@ +namespace Dummies; + +/// +/// A generator of arbitrary ftp URIs — the classic ftp://user:password@host/path shape of legacy +/// code. An FTP URI carries user-info but no query and no fragment, so this builder does not expose them. +/// +public sealed class AnyFtpUri : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly UriSpec _spec; + + #endregion + + internal AnyFtpUri(RandomSource source, UriSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Pins the host. Must be an ASCII host name (pass the punycode form for internationalized hosts). + /// Thrown when is null. + /// Thrown when is empty, non-ASCII or not a valid host name. + public AnyFtpUri WithHost(string host) { + return new AnyFtpUri(_source, _spec.WithHost(UriSpec.RequireHost(host, nameof(host)))); + } + + /// Includes arbitrary user:password user-info. + public AnyFtpUri WithUserInfo() { + return new AnyFtpUri(_source, _spec.WithUserInfo(null, null)); + } + + /// Includes user-info with the given and an arbitrary password. + /// Thrown when is null. + /// Thrown when contains a non-unreserved character. + public AnyFtpUri WithUserInfo(string user) { + return new AnyFtpUri(_source, _spec.WithUserInfo(UriSpec.RequireUserInfoPart(user, nameof(user)), null)); + } + + /// Includes the given and user-info. + /// Thrown when an argument is null. + /// Thrown when an argument contains a non-unreserved character. + public AnyFtpUri WithUserInfo(string user, string password) { + return new AnyFtpUri(_source, _spec.WithUserInfo(UriSpec.RequireUserInfoPart(user, nameof(user)), UriSpec.RequireUserInfoPart(password, nameof(password)))); + } + + /// Includes an arbitrary non-default port. + public AnyFtpUri WithPort() { + return new AnyFtpUri(_source, _spec.WithPort(null)); + } + + /// Includes the given . + /// Thrown when is outside 1..65535. + public AnyFtpUri WithPort(int port) { + return new AnyFtpUri(_source, _spec.WithPort(UriSpec.RequirePort(port, nameof(port)))); + } + + /// Fixes the path to exactly segments. Declared once per generator. + /// Thrown when is negative. + /// Thrown when a path constraint is already declared. + public AnyFtpUri WithPathSegments(int count) { + return new AnyFtpUri(_source, _spec.WithPath(UriPathMode.Exact, UriSpec.RequireSegmentCount(count, nameof(count)), UriSpec.SegmentsLabel(count))); + } + + /// Renders the root path (/) with no segments. Declared once per generator. + /// Thrown when a path constraint is already declared. + public AnyFtpUri WithoutPath() { + return new AnyFtpUri(_source, _spec.WithPath(UriPathMode.Root, 0, "WithoutPath()")); + } + + /// + public Uri Generate() { + return _spec.Generate(_source); + } + +} diff --git a/Dummies/AnyMailtoUri.cs b/Dummies/AnyMailtoUri.cs new file mode 100644 index 00000000..bf9fb723 --- /dev/null +++ b/Dummies/AnyMailtoUri.cs @@ -0,0 +1,49 @@ +namespace Dummies; + +/// +/// A generator of arbitrary mailto URIs — mailto:local@domain, optionally with headers. The +/// local-part and domain are drawn from ASCII characters, so the value is an arbitrary well-formed address, never +/// a realistic one (this library does not fabricate plausible data). A mailto URI is not authority-based, so this +/// builder exposes no host/port/user-info in the authority sense — only the address parts and headers. +/// +public sealed class AnyMailtoUri : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly UriSpec _spec; + + #endregion + + internal AnyMailtoUri(RandomSource source, UriSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Pins the local-part (the text before @). + /// Thrown when is null. + /// Thrown when contains a non-unreserved character. + public AnyMailtoUri WithLocalPart(string localPart) { + return new AnyMailtoUri(_source, _spec.WithUserInfo(UriSpec.RequireUserInfoPart(localPart, nameof(localPart)), null)); + } + + /// Pins the domain (the text after @). Must be an ASCII host name. + /// Thrown when is null. + /// Thrown when is empty, non-ASCII or not a valid host name. + public AnyMailtoUri WithDomain(string domain) { + return new AnyMailtoUri(_source, _spec.WithHost(UriSpec.RequireHost(domain, nameof(domain)))); + } + + /// Includes an arbitrary header (e.g. ?subject=...). + public AnyMailtoUri WithHeaders() { + return new AnyMailtoUri(_source, _spec.WithQuery()); + } + + /// + public Uri Generate() { + return _spec.Generate(_source); + } + +} diff --git a/Dummies/AnyRelativeUri.cs b/Dummies/AnyRelativeUri.cs new file mode 100644 index 00000000..94caf5e4 --- /dev/null +++ b/Dummies/AnyRelativeUri.cs @@ -0,0 +1,52 @@ +namespace Dummies; + +/// +/// A generator of arbitrary relative URI references — a path with an optional query and fragment, and no +/// scheme or authority (e.g. orders/42?page=2 or /a/b/c#top). A relative reference is well-formed on +/// its own; only its resolution against a base needs a base, not its validity. +/// returns a with false. +/// +public sealed class AnyRelativeUri : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly UriSpec _spec; + + #endregion + + internal AnyRelativeUri(RandomSource source, UriSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Starts the path with / (an absolute-path reference). + public AnyRelativeUri Rooted() { + return new AnyRelativeUri(_source, _spec.Rooted()); + } + + /// Fixes the path to exactly segments. Declared once per generator. + /// Thrown when is negative. + /// Thrown when a path constraint is already declared. + public AnyRelativeUri WithPathSegments(int count) { + return new AnyRelativeUri(_source, _spec.WithPath(UriPathMode.Exact, UriSpec.RequireSegmentCount(count, nameof(count)), UriSpec.SegmentsLabel(count))); + } + + /// Includes an arbitrary query string. + public AnyRelativeUri WithQuery() { + return new AnyRelativeUri(_source, _spec.WithQuery()); + } + + /// Includes an arbitrary fragment. + public AnyRelativeUri WithFragment() { + return new AnyRelativeUri(_source, _spec.WithFragment()); + } + + /// + public Uri Generate() { + return _spec.Generate(_source); + } + +} diff --git a/Dummies/AnyUri.cs b/Dummies/AnyUri.cs new file mode 100644 index 00000000..c2fb0d4e --- /dev/null +++ b/Dummies/AnyUri.cs @@ -0,0 +1,79 @@ +namespace Dummies; + +/// +/// A fluent generator of arbitrary yet valid values. Unconstrained, it spans the whole +/// safe URI space — an absolute web (http/https), WebSocket (ws/wss), FTP or mailto +/// URI, or a relative reference — drawn from the ambient random context. Narrow it to one family to reach +/// that family's constraints: each narrowing returns a family-specific builder that exposes only the components +/// that family actually has, so an impossible combination (a port on a mailto, a fragment on a WebSocket) cannot +/// even be written. +/// +/// +/// +/// Every component is drawn from ASCII-unreserved characters and the URI is assembled directly, so a value is +/// valid by construction — never generated then filtered — and a run is reproducible under a seed on every +/// target framework. Internationalized (IDN) hosts and the file scheme are deliberately outside the +/// unconstrained draw: an IDN host and a file path do not round-trip identically across frameworks, which +/// would break the determinism contract. +/// +/// +/// +/// Uri any = Any.Uri().Generate(); // any family, absolute or relative +/// Uri endpoint = Any.Uri().Web().UsingHttps().WithHost("api.example.com").Generate(); +/// Uri socket = Any.Uri().WebSocket().Generate(); // ws:// or wss:// +/// Uri relative = Any.Uri().Relative().Rooted().Generate(); // /a/b/c +/// +/// +/// +public sealed class AnyUri : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly UriSpec _spec; + + #endregion + + internal AnyUri(RandomSource source, UriSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Narrows to a web URI: http or https, with the full authority surface. + /// A web-URI generator. + public AnyWebUri Web() { + return new AnyWebUri(_source, _spec.WithFamily(UriFamily.Web)); + } + + /// Narrows to a WebSocket URI: ws or wss (no user-info, no fragment — RFC 6455). + /// A WebSocket-URI generator. + public AnyWebSocketUri WebSocket() { + return new AnyWebSocketUri(_source, _spec.WithFamily(UriFamily.WebSocket)); + } + + /// Narrows to an ftp URI (authority with user-info, no query or fragment). + /// An FTP-URI generator. + public AnyFtpUri Ftp() { + return new AnyFtpUri(_source, _spec.WithFamily(UriFamily.Ftp)); + } + + /// Narrows to a mailto URI: local@domain, optionally with headers. + /// A mailto-URI generator. + public AnyMailtoUri Mailto() { + return new AnyMailtoUri(_source, _spec.WithFamily(UriFamily.Mailto)); + } + + /// Narrows to a relative reference: a path with an optional query and fragment, no scheme or authority. + /// A relative-URI generator. + public AnyRelativeUri Relative() { + return new AnyRelativeUri(_source, _spec.WithFamily(UriFamily.Relative)); + } + + /// + public Uri Generate() { + return _spec.Generate(_source); + } + +} diff --git a/Dummies/AnyWebSocketUri.cs b/Dummies/AnyWebSocketUri.cs new file mode 100644 index 00000000..fa2a8575 --- /dev/null +++ b/Dummies/AnyWebSocketUri.cs @@ -0,0 +1,75 @@ +namespace Dummies; + +/// +/// A generator of arbitrary ws/wss URIs. Per RFC 6455 a WebSocket URI carries no user-info and +/// no fragment, so — unlike — this builder does not expose them. Pin the TLS variant +/// with /; unpinned, the scheme is drawn from both. +/// +public sealed class AnyWebSocketUri : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly UriSpec _spec; + + #endregion + + internal AnyWebSocketUri(RandomSource source, UriSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Pins the scheme to ws. Declared once per generator. + public AnyWebSocketUri UsingWs() { + return new AnyWebSocketUri(_source, _spec.WithScheme("ws", "UsingWs()")); + } + + /// Pins the scheme to wss. Declared once per generator. + public AnyWebSocketUri UsingWss() { + return new AnyWebSocketUri(_source, _spec.WithScheme("wss", "UsingWss()")); + } + + /// Pins the host. Must be an ASCII host name (pass the punycode form for internationalized hosts). + /// Thrown when is null. + /// Thrown when is empty, non-ASCII or not a valid host name. + public AnyWebSocketUri WithHost(string host) { + return new AnyWebSocketUri(_source, _spec.WithHost(UriSpec.RequireHost(host, nameof(host)))); + } + + /// Includes an arbitrary non-default port. + public AnyWebSocketUri WithPort() { + return new AnyWebSocketUri(_source, _spec.WithPort(null)); + } + + /// Includes the given . + /// Thrown when is outside 1..65535. + public AnyWebSocketUri WithPort(int port) { + return new AnyWebSocketUri(_source, _spec.WithPort(UriSpec.RequirePort(port, nameof(port)))); + } + + /// Fixes the path to exactly segments. Declared once per generator. + /// Thrown when is negative. + /// Thrown when a path constraint is already declared. + public AnyWebSocketUri WithPathSegments(int count) { + return new AnyWebSocketUri(_source, _spec.WithPath(UriPathMode.Exact, UriSpec.RequireSegmentCount(count, nameof(count)), UriSpec.SegmentsLabel(count))); + } + + /// Renders the root path (/) with no segments. Declared once per generator. + /// Thrown when a path constraint is already declared. + public AnyWebSocketUri WithoutPath() { + return new AnyWebSocketUri(_source, _spec.WithPath(UriPathMode.Root, 0, "WithoutPath()")); + } + + /// Includes an arbitrary query string. + public AnyWebSocketUri WithQuery() { + return new AnyWebSocketUri(_source, _spec.WithQuery()); + } + + /// + public Uri Generate() { + return _spec.Generate(_source); + } + +} diff --git a/Dummies/AnyWebUri.cs b/Dummies/AnyWebUri.cs new file mode 100644 index 00000000..dcf731b3 --- /dev/null +++ b/Dummies/AnyWebUri.cs @@ -0,0 +1,100 @@ +namespace Dummies; + +/// +/// A generator of arbitrary http/https URIs. Exposes the full authority surface — user-info, host, +/// port, path, query and fragment. Pin the TLS variant with /; +/// unpinned, the scheme is drawn from both. Every component is drawn from ASCII-unreserved characters, so a value +/// is valid by construction and reproducible under a seed. +/// +public sealed class AnyWebUri : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly RandomSource _source; + private readonly UriSpec _spec; + + #endregion + + internal AnyWebUri(RandomSource source, UriSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Pins the scheme to http. Declared once per generator. + public AnyWebUri UsingHttp() { + return new AnyWebUri(_source, _spec.WithScheme("http", "UsingHttp()")); + } + + /// Pins the scheme to https. Declared once per generator. + public AnyWebUri UsingHttps() { + return new AnyWebUri(_source, _spec.WithScheme("https", "UsingHttps()")); + } + + /// Pins the host. Must be an ASCII host name (pass the punycode form for internationalized hosts). + /// Thrown when is null. + /// Thrown when is empty, non-ASCII or not a valid host name. + public AnyWebUri WithHost(string host) { + return new AnyWebUri(_source, _spec.WithHost(UriSpec.RequireHost(host, nameof(host)))); + } + + /// Includes arbitrary user:password user-info. + public AnyWebUri WithUserInfo() { + return new AnyWebUri(_source, _spec.WithUserInfo(null, null)); + } + + /// Includes user-info with the given and an arbitrary password. + /// Thrown when is null. + /// Thrown when contains a non-unreserved character. + public AnyWebUri WithUserInfo(string user) { + return new AnyWebUri(_source, _spec.WithUserInfo(UriSpec.RequireUserInfoPart(user, nameof(user)), null)); + } + + /// Includes the given and user-info. + /// Thrown when an argument is null. + /// Thrown when an argument contains a non-unreserved character. + public AnyWebUri WithUserInfo(string user, string password) { + return new AnyWebUri(_source, _spec.WithUserInfo(UriSpec.RequireUserInfoPart(user, nameof(user)), UriSpec.RequireUserInfoPart(password, nameof(password)))); + } + + /// Includes an arbitrary non-default port. + public AnyWebUri WithPort() { + return new AnyWebUri(_source, _spec.WithPort(null)); + } + + /// Includes the given . + /// Thrown when is outside 1..65535. + public AnyWebUri WithPort(int port) { + return new AnyWebUri(_source, _spec.WithPort(UriSpec.RequirePort(port, nameof(port)))); + } + + /// Fixes the path to exactly segments. Declared once per generator. + /// Thrown when is negative. + /// Thrown when a path constraint is already declared. + public AnyWebUri WithPathSegments(int count) { + return new AnyWebUri(_source, _spec.WithPath(UriPathMode.Exact, UriSpec.RequireSegmentCount(count, nameof(count)), UriSpec.SegmentsLabel(count))); + } + + /// Renders the root path (/) with no segments. Declared once per generator. + /// Thrown when a path constraint is already declared. + public AnyWebUri WithoutPath() { + return new AnyWebUri(_source, _spec.WithPath(UriPathMode.Root, 0, "WithoutPath()")); + } + + /// Includes an arbitrary query string. + public AnyWebUri WithQuery() { + return new AnyWebUri(_source, _spec.WithQuery()); + } + + /// Includes an arbitrary fragment. + public AnyWebUri WithFragment() { + return new AnyWebUri(_source, _spec.WithFragment()); + } + + /// + public Uri Generate() { + return _spec.Generate(_source); + } + +} diff --git a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index b38386bc..7beea317 100644 --- a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -71,6 +71,7 @@ Dummies.AnyContext.UInt128() -> Dummies.AnyUInt128! Dummies.AnyContext.UInt16() -> Dummies.AnyUInt16! Dummies.AnyContext.UInt32() -> Dummies.AnyUInt32! Dummies.AnyContext.UInt64() -> Dummies.AnyUInt64! +Dummies.AnyContext.Uri() -> Dummies.AnyUri! Dummies.AnyDateOnly Dummies.AnyDateOnly.After(System.DateOnly date) -> Dummies.AnyDateOnly! Dummies.AnyDateOnly.AfterOrEqualTo(System.DateOnly date) -> Dummies.AnyDateOnly! @@ -149,6 +150,16 @@ Dummies.AnyException Dummies.AnyException.AnyException(string! message) -> void Dummies.AnyException.AnyException(string! message, System.Exception! innerException) -> void Dummies.AnyExtensions +Dummies.AnyFtpUri +Dummies.AnyFtpUri.Generate() -> System.Uri! +Dummies.AnyFtpUri.WithHost(string! host) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithoutPath() -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithPathSegments(int count) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithPort() -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithPort(int port) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithUserInfo() -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithUserInfo(string! user) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithUserInfo(string! user, string! password) -> Dummies.AnyFtpUri! Dummies.AnyGenerationException Dummies.AnyGenerationException.AnyGenerationException(string! message) -> void Dummies.AnyGenerationException.AnyGenerationException(string! message, System.Exception! innerException) -> void @@ -233,10 +244,21 @@ Dummies.AnyInt64.Zero() -> Dummies.AnyInt64! Dummies.AnyList Dummies.AnyList.Distinct() -> Dummies.AnyList! Dummies.AnyList.Distinct(System.Collections.Generic.IEqualityComparer! comparer) -> Dummies.AnyList! +Dummies.AnyMailtoUri +Dummies.AnyMailtoUri.Generate() -> System.Uri! +Dummies.AnyMailtoUri.WithDomain(string! domain) -> Dummies.AnyMailtoUri! +Dummies.AnyMailtoUri.WithHeaders() -> Dummies.AnyMailtoUri! +Dummies.AnyMailtoUri.WithLocalPart(string! localPart) -> Dummies.AnyMailtoUri! Dummies.AnyOneOf Dummies.AnyOneOf.Generate() -> T Dummies.AnyPattern Dummies.AnyPattern.Generate() -> string! +Dummies.AnyRelativeUri +Dummies.AnyRelativeUri.Generate() -> System.Uri! +Dummies.AnyRelativeUri.Rooted() -> Dummies.AnyRelativeUri! +Dummies.AnyRelativeUri.WithFragment() -> Dummies.AnyRelativeUri! +Dummies.AnyRelativeUri.WithPathSegments(int count) -> Dummies.AnyRelativeUri! +Dummies.AnyRelativeUri.WithQuery() -> Dummies.AnyRelativeUri! Dummies.AnySByte Dummies.AnySByte.Between(sbyte minimum, sbyte maximum) -> Dummies.AnySByte! Dummies.AnySByte.DifferentFrom(sbyte value) -> Dummies.AnySByte! @@ -363,6 +385,37 @@ Dummies.AnyUInt64.LessThanOrEqualTo(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.NonZero() -> Dummies.AnyUInt64! Dummies.AnyUInt64.OneOf(params ulong[]! values) -> Dummies.AnyUInt64! Dummies.AnyUInt64.Zero() -> Dummies.AnyUInt64! +Dummies.AnyUri +Dummies.AnyUri.Ftp() -> Dummies.AnyFtpUri! +Dummies.AnyUri.Generate() -> System.Uri! +Dummies.AnyUri.Mailto() -> Dummies.AnyMailtoUri! +Dummies.AnyUri.Relative() -> Dummies.AnyRelativeUri! +Dummies.AnyUri.Web() -> Dummies.AnyWebUri! +Dummies.AnyUri.WebSocket() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri +Dummies.AnyWebSocketUri.Generate() -> System.Uri! +Dummies.AnyWebSocketUri.UsingWs() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.UsingWss() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithHost(string! host) -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithoutPath() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithPathSegments(int count) -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithPort() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithPort(int port) -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithQuery() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebUri +Dummies.AnyWebUri.Generate() -> System.Uri! +Dummies.AnyWebUri.UsingHttp() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.UsingHttps() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithFragment() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithHost(string! host) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithoutPath() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithPathSegments(int count) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithPort() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithPort(int port) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithQuery() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithUserInfo() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithUserInfo(string! user) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithUserInfo(string! user, string! password) -> Dummies.AnyWebUri! Dummies.ConflictingAnyConstraintException Dummies.ConflictingAnyConstraintException.ConflictingAnyConstraintException(string! message) -> void Dummies.IAny @@ -420,6 +473,7 @@ static Dummies.Any.UInt128() -> Dummies.AnyUInt128! static Dummies.Any.UInt16() -> Dummies.AnyUInt16! static Dummies.Any.UInt32() -> Dummies.AnyUInt32! static Dummies.Any.UInt64() -> Dummies.AnyUInt64! +static Dummies.Any.Uri() -> Dummies.AnyUri! static Dummies.Any.WithSeed(int seed) -> Dummies.AnyContext! static Dummies.AnyExtensions.As(this Dummies.IAny! generator, System.Func! factory) -> Dummies.IAny! static Dummies.NullableExtensions.OrNull(this Dummies.IAny! generator) -> Dummies.IAny! diff --git a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 516db73c..8d2c638f 100644 --- a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -66,6 +66,7 @@ Dummies.AnyContext.TimeSpan() -> Dummies.AnyTimeSpan! Dummies.AnyContext.UInt16() -> Dummies.AnyUInt16! Dummies.AnyContext.UInt32() -> Dummies.AnyUInt32! Dummies.AnyContext.UInt64() -> Dummies.AnyUInt64! +Dummies.AnyContext.Uri() -> Dummies.AnyUri! Dummies.AnyDateTime Dummies.AnyDateTime.After(System.DateTime instant) -> Dummies.AnyDateTime! Dummies.AnyDateTime.AfterOrEqualTo(System.DateTime instant) -> Dummies.AnyDateTime! @@ -134,6 +135,16 @@ Dummies.AnyException Dummies.AnyException.AnyException(string! message) -> void Dummies.AnyException.AnyException(string! message, System.Exception! innerException) -> void Dummies.AnyExtensions +Dummies.AnyFtpUri +Dummies.AnyFtpUri.Generate() -> System.Uri! +Dummies.AnyFtpUri.WithHost(string! host) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithoutPath() -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithPathSegments(int count) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithPort() -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithPort(int port) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithUserInfo() -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithUserInfo(string! user) -> Dummies.AnyFtpUri! +Dummies.AnyFtpUri.WithUserInfo(string! user, string! password) -> Dummies.AnyFtpUri! Dummies.AnyGenerationException Dummies.AnyGenerationException.AnyGenerationException(string! message) -> void Dummies.AnyGenerationException.AnyGenerationException(string! message, System.Exception! innerException) -> void @@ -190,10 +201,21 @@ Dummies.AnyInt64.Zero() -> Dummies.AnyInt64! Dummies.AnyList Dummies.AnyList.Distinct() -> Dummies.AnyList! Dummies.AnyList.Distinct(System.Collections.Generic.IEqualityComparer! comparer) -> Dummies.AnyList! +Dummies.AnyMailtoUri +Dummies.AnyMailtoUri.Generate() -> System.Uri! +Dummies.AnyMailtoUri.WithDomain(string! domain) -> Dummies.AnyMailtoUri! +Dummies.AnyMailtoUri.WithHeaders() -> Dummies.AnyMailtoUri! +Dummies.AnyMailtoUri.WithLocalPart(string! localPart) -> Dummies.AnyMailtoUri! Dummies.AnyOneOf Dummies.AnyOneOf.Generate() -> T Dummies.AnyPattern Dummies.AnyPattern.Generate() -> string! +Dummies.AnyRelativeUri +Dummies.AnyRelativeUri.Generate() -> System.Uri! +Dummies.AnyRelativeUri.Rooted() -> Dummies.AnyRelativeUri! +Dummies.AnyRelativeUri.WithFragment() -> Dummies.AnyRelativeUri! +Dummies.AnyRelativeUri.WithPathSegments(int count) -> Dummies.AnyRelativeUri! +Dummies.AnyRelativeUri.WithQuery() -> Dummies.AnyRelativeUri! Dummies.AnySByte Dummies.AnySByte.Between(sbyte minimum, sbyte maximum) -> Dummies.AnySByte! Dummies.AnySByte.DifferentFrom(sbyte value) -> Dummies.AnySByte! @@ -298,6 +320,37 @@ Dummies.AnyUInt64.LessThanOrEqualTo(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.NonZero() -> Dummies.AnyUInt64! Dummies.AnyUInt64.OneOf(params ulong[]! values) -> Dummies.AnyUInt64! Dummies.AnyUInt64.Zero() -> Dummies.AnyUInt64! +Dummies.AnyUri +Dummies.AnyUri.Ftp() -> Dummies.AnyFtpUri! +Dummies.AnyUri.Generate() -> System.Uri! +Dummies.AnyUri.Mailto() -> Dummies.AnyMailtoUri! +Dummies.AnyUri.Relative() -> Dummies.AnyRelativeUri! +Dummies.AnyUri.Web() -> Dummies.AnyWebUri! +Dummies.AnyUri.WebSocket() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri +Dummies.AnyWebSocketUri.Generate() -> System.Uri! +Dummies.AnyWebSocketUri.UsingWs() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.UsingWss() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithHost(string! host) -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithoutPath() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithPathSegments(int count) -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithPort() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithPort(int port) -> Dummies.AnyWebSocketUri! +Dummies.AnyWebSocketUri.WithQuery() -> Dummies.AnyWebSocketUri! +Dummies.AnyWebUri +Dummies.AnyWebUri.Generate() -> System.Uri! +Dummies.AnyWebUri.UsingHttp() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.UsingHttps() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithFragment() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithHost(string! host) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithoutPath() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithPathSegments(int count) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithPort() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithPort(int port) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithQuery() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithUserInfo() -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithUserInfo(string! user) -> Dummies.AnyWebUri! +Dummies.AnyWebUri.WithUserInfo(string! user, string! password) -> Dummies.AnyWebUri! Dummies.ConflictingAnyConstraintException Dummies.ConflictingAnyConstraintException.ConflictingAnyConstraintException(string! message) -> void Dummies.IAny @@ -350,6 +403,7 @@ static Dummies.Any.TripleOf(Dummies.IAny! first, Dummies.IAny Dummies.AnyUInt16! static Dummies.Any.UInt32() -> Dummies.AnyUInt32! static Dummies.Any.UInt64() -> Dummies.AnyUInt64! +static Dummies.Any.Uri() -> Dummies.AnyUri! static Dummies.Any.WithSeed(int seed) -> Dummies.AnyContext! static Dummies.AnyExtensions.As(this Dummies.IAny! generator, System.Func! factory) -> Dummies.IAny! static Dummies.NullableExtensions.OrNull(this Dummies.IAny! generator) -> Dummies.IAny! diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 5592727e..ff68a22e 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -58,6 +58,15 @@ matter — and that is the point. Terminal and uniform like the string set: duplicates collapse under the default comparer, the pool's distinct count gates distinct collections, and a `null` element is refused — make the whole draw optional with `.OrNull()` instead. +- **URIs by family**: `Any.Uri()` yields an arbitrary yet valid `System.Uri` — an + absolute web (`http`/`https`), WebSocket (`ws`/`wss`), FTP or mailto URI, or a relative + reference. Narrow it to a family and each returns a builder exposing only that family's + valid components, so an impossible combination cannot even be written (`Mailto()` has no + `WithPort`, `WebSocket()` no `WithUserInfo`): + `Any.Uri().Web().UsingHttps().WithHost("api.example.com")`. Every part is drawn from + ASCII-unreserved characters, so a value is valid by construction and reproducible across + frameworks; internationalized (IDN) hosts and the `file` scheme stay out of the default + draw to keep that determinism. - **Domain vocabulary where it belongs**: dates constrain with `After`/`Before`/`Between`, quantities with `Positive`/`Between`/`NonZero`, identities with `NonEmpty`/`DifferentFrom` — and deliberately no clock-relative diff --git a/Dummies/UriSpec.cs b/Dummies/UriSpec.cs new file mode 100644 index 00000000..f65f400a --- /dev/null +++ b/Dummies/UriSpec.cs @@ -0,0 +1,360 @@ +#region Usings declarations + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +#endregion + +namespace Dummies; + +/// The URI families the builder can produce, one per distinct valid shape. +internal enum UriFamily { + + Web, // http / https — full authority: host, userinfo, port, path, query, fragment + WebSocket, // ws / wss — authority without userinfo or fragment (RFC 6455) + Ftp, // ftp — authority without query or fragment + Mailto, // mailto — local@domain (+ optional headers) + Relative // no scheme — path (+ optional query, fragment) + +} + +/// How the path component is drawn. +internal enum UriPathMode { + + Auto, // 0 to 2 arbitrary segments + Root, // no segments (an authority family still renders "/") + Exact // a fixed number of segments + +} + +/// +/// The immutable specification behind every AnyUri family builder. It records the constrained family and +/// scheme plus the per-component pins, and assembles a directly — every component is +/// drawn from ASCII-unreserved characters, so a generated URI is valid by construction (never a throw, never a +/// retry) and reproducible under a seed on every target framework. Contradictory constraints fail eagerly with a +/// naming both sides; an invalid pinned component fails as an +/// argument at the call site. +/// +internal sealed class UriSpec { + + #region Constants + + private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz"; + private const string LowerAlphaNum = "abcdefghijklmnopqrstuvwxyz0123456789"; + private const string Unreserved = "abcdefghijklmnopqrstuvwxyz0123456789-._~"; + private const int MinDynamicPort = 1025; // above every default we emit (http 80, https 443, ftp 21, ws 80, wss 443) + + #endregion + + #region Statics members declarations + + internal static readonly UriSpec Unconstrained = new(null, null, null, null, + false, null, null, + false, null, + UriPathMode.Auto, 0, + false, false, false); + + private static readonly UriFamily[] DefaultFamilies = { UriFamily.Web, UriFamily.WebSocket, UriFamily.Ftp, UriFamily.Mailto, UriFamily.Relative }; + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + internal static string SegmentsLabel(int count) { + return $"WithPathSegments({V(count)})"; + } + + #endregion + + #region Fields declarations + + private readonly UriFamily? _family; + private readonly string? _scheme; // pinned concrete scheme within the family (e.g. "https") + private readonly string? _schemeConstraint; // the call that pinned it, for a conflict message + private readonly string? _host; // pinned host / mailto domain + private readonly bool _hasUserInfo; + private readonly string? _user; + private readonly string? _password; + private readonly bool _hasPort; + private readonly int? _port; + private readonly UriPathMode _pathMode; + private readonly string? _pathConstraint; + private readonly int _pathSegments; + private readonly bool _hasQuery; + private readonly bool _hasFragment; + private readonly bool _rooted; + + #endregion + + private UriSpec(UriFamily? family, string? scheme, string? schemeConstraint, string? host, + bool hasUserInfo, string? user, string? password, + bool hasPort, int? port, + UriPathMode pathMode, int pathSegments, + bool hasQuery, bool hasFragment, bool rooted, + string? pathConstraint = null) { + _family = family; + _scheme = scheme; + _schemeConstraint = schemeConstraint; + _host = host; + _hasUserInfo = hasUserInfo; + _user = user; + _password = password; + _hasPort = hasPort; + _port = port; + _pathMode = pathMode; + _pathConstraint = pathConstraint; + _pathSegments = pathSegments; + _hasQuery = hasQuery; + _hasFragment = hasFragment; + _rooted = rooted; + } + + #region Family narrowing + + internal UriSpec WithFamily(UriFamily family) { + return new UriSpec(family, _scheme, _schemeConstraint, _host, _hasUserInfo, _user, _password, + _hasPort, _port, _pathMode, _pathSegments, _hasQuery, _hasFragment, _rooted, _pathConstraint); + } + + internal UriSpec WithScheme(string scheme, string applying) { + if (_schemeConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_schemeConstraint} is already defined."); } + + return new UriSpec(_family, scheme, applying, _host, _hasUserInfo, _user, _password, + _hasPort, _port, _pathMode, _pathSegments, _hasQuery, _hasFragment, _rooted, _pathConstraint); + } + + #endregion + + #region Component pins + + internal UriSpec WithHost(string host) { + return new UriSpec(_family, _scheme, _schemeConstraint, host, _hasUserInfo, _user, _password, + _hasPort, _port, _pathMode, _pathSegments, _hasQuery, _hasFragment, _rooted, _pathConstraint); + } + + internal UriSpec WithUserInfo(string? user, string? password) { + return new UriSpec(_family, _scheme, _schemeConstraint, _host, true, user, password, + _hasPort, _port, _pathMode, _pathSegments, _hasQuery, _hasFragment, _rooted, _pathConstraint); + } + + internal UriSpec WithPort(int? port) { + return new UriSpec(_family, _scheme, _schemeConstraint, _host, _hasUserInfo, _user, _password, + true, port, _pathMode, _pathSegments, _hasQuery, _hasFragment, _rooted, _pathConstraint); + } + + internal UriSpec WithPath(UriPathMode mode, int segments, string applying) { + if (_pathConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_pathConstraint} is already defined."); } + + return new UriSpec(_family, _scheme, _schemeConstraint, _host, _hasUserInfo, _user, _password, + _hasPort, _port, mode, segments, _hasQuery, _hasFragment, _rooted, applying); + } + + internal UriSpec WithQuery() { + return new UriSpec(_family, _scheme, _schemeConstraint, _host, _hasUserInfo, _user, _password, + _hasPort, _port, _pathMode, _pathSegments, true, _hasFragment, _rooted, _pathConstraint); + } + + internal UriSpec WithFragment() { + return new UriSpec(_family, _scheme, _schemeConstraint, _host, _hasUserInfo, _user, _password, + _hasPort, _port, _pathMode, _pathSegments, _hasQuery, true, _rooted, _pathConstraint); + } + + internal UriSpec Rooted() { + return new UriSpec(_family, _scheme, _schemeConstraint, _host, _hasUserInfo, _user, _password, + _hasPort, _port, _pathMode, _pathSegments, _hasQuery, _hasFragment, true, _pathConstraint); + } + + #endregion + + #region Generation + + internal Uri Generate(RandomSource source) { + Random random = source.Current.Random; + UriFamily family = _family ?? DefaultFamilies[random.Next(DefaultFamilies.Length)]; + + return family == UriFamily.Relative + ? new Uri(BuildRelative(source), UriKind.Relative) + : new Uri(BuildAbsolute(family, random), UriKind.Absolute); + } + + private string BuildAbsolute(UriFamily family, Random random) { + string scheme = ResolveScheme(family, random); + StringBuilder builder = new(); + builder.Append(scheme).Append(':'); + + if (family == UriFamily.Mailto) { + builder.Append(_user ?? Draw(random, LowerAlphaNum, 3, 8)); + builder.Append('@'); + builder.Append(_host ?? Host(random)); + if (_hasQuery) { builder.Append("?subject=").Append(Draw(random, LowerAlphaNum, 3, 8)); } + + return builder.ToString(); + } + + builder.Append("//"); + if (AllowsUserInfo(family) && _hasUserInfo) { + builder.Append(_user ?? Draw(random, LowerAlphaNum, 3, 8)); + builder.Append(':').Append(_password ?? Draw(random, LowerAlphaNum, 3, 8)); + builder.Append('@'); + } + + builder.Append(_host ?? Host(random)); + if (_hasPort) { builder.Append(':').Append(V(_port ?? random.Next(MinDynamicPort, 65536))); } + + builder.Append(Path(random, leadingSlash: true)); + if (AllowsQuery(family) && _hasQuery) { builder.Append(Query(random)); } + if (AllowsFragment(family) && _hasFragment) { builder.Append('#').Append(Draw(random, LowerAlphaNum, 1, 8)); } + + return builder.ToString(); + } + + private string BuildRelative(RandomSource source) { + Random random = source.Current.Random; + StringBuilder builder = new(); + builder.Append(Path(random, leadingSlash: _rooted)); + if (_hasQuery) { builder.Append(Query(random)); } + if (_hasFragment) { builder.Append('#').Append(Draw(random, LowerAlphaNum, 1, 8)); } + + string result = builder.ToString(); + if (result.Length > 0) { return result; } + + // The reference rendered empty, which is not a valid URI. An unconstrained (Auto) path incidentally drew zero + // segments — resolve it to an arbitrary segment. An explicit WithPathSegments(0) with no query, fragment or + // root asked for the empty reference, which cannot generate: surface it with the seed to replay, like the + // library's other unsatisfiable specs. + if (_pathMode == UriPathMode.Exact) { + int seed = source.Current.Seed; + throw new AnyGenerationException( + "A relative URI with exactly 0 path segments and no query, fragment or root is empty, which is not a valid URI reference. Add a query, a fragment, Rooted(), or a positive segment count. " + source.ReplayHint(seed), + seed); + } + + return Draw(random, LowerAlphaNum, 1, 8); + } + + private string ResolveScheme(UriFamily family, Random random) { + if (_scheme is not null) { return _scheme; } + + return family switch { + UriFamily.Web => random.Next(2) == 0 ? "http" : "https", + UriFamily.WebSocket => random.Next(2) == 0 ? "ws" : "wss", + UriFamily.Ftp => "ftp", + UriFamily.Mailto => "mailto", + _ => throw new InvalidOperationException("Relative URIs have no scheme.") + }; + } + + private string Path(Random random, bool leadingSlash) { + int count = _pathMode switch { + UriPathMode.Root => 0, + UriPathMode.Exact => _pathSegments, + _ => random.Next(3) // 0..2 + }; + + if (count == 0) { return leadingSlash ? "/" : string.Empty; } + + StringBuilder builder = new(); + for (int i = 0; i < count; i++) { + if (leadingSlash || i > 0) { builder.Append('/'); } + builder.Append(Draw(random, LowerAlphaNum, 1, 8)); + } + + return builder.ToString(); + } + + private static string Query(Random random) { + int pairs = random.Next(1, 3); // 1..2 + StringBuilder builder = new("?"); + for (int i = 0; i < pairs; i++) { + if (i > 0) { builder.Append('&'); } + builder.Append(Draw(random, LowerAlphaNum, 1, 6)).Append('=').Append(Draw(random, LowerAlphaNum, 1, 6)); + } + + return builder.ToString(); + } + + private static string Host(Random random) { + return Label(random) + "." + Draw(random, LowerLetters, 2, 4); + } + + private static string Label(Random random) { + // A DNS-safe label: starts with a letter, then letters/digits — no leading digit, no hyphen edges. + return LowerLetters[random.Next(LowerLetters.Length)].ToString() + Draw(random, LowerAlphaNum, 0, 7); + } + + private static string Draw(Random random, string pool, int min, int max) { + int length = min == max ? min : random.Next(min, max + 1); + StringBuilder builder = new(length); + for (int i = 0; i < length; i++) { builder.Append(pool[random.Next(pool.Length)]); } + + return builder.ToString(); + } + + private static bool AllowsUserInfo(UriFamily family) { + return family is UriFamily.Web or UriFamily.Ftp; + } + + private static bool AllowsQuery(UriFamily family) { + return family is UriFamily.Web or UriFamily.WebSocket; + } + + private static bool AllowsFragment(UriFamily family) { + return family is UriFamily.Web; + } + + #endregion + + #region Argument validation helpers (shared by the public builders) + + internal static string RequireHost(string host, string parameterName) { + if (host is null) { throw new ArgumentNullException(parameterName); } + if (host.Length == 0) { throw new ArgumentException("The host must not be empty.", parameterName); } + if (host.Any(character => character > 127)) { + throw new ArgumentException("The host must be ASCII: an internationalized (IDN) host would not round-trip identically across target frameworks. Pass the punycode form instead (e.g. \"xn--mnchen-3ya.de\").", parameterName); + } + if (Uri.CheckHostName(host) == UriHostNameType.Unknown) { + throw new ArgumentException($"\"{host}\" is not a valid host name.", parameterName); + } + + // Reject a host System.Uri would rewrite (a shorthand IPv4 such as "1" -> "0.0.0.1"): it would not round-trip, + // and shorthand parsing has historically differed across target frameworks — the same determinism hazard as an + // IDN host. A case-only difference is invariant and stays allowed. + string canonical; + try { canonical = new Uri("http://" + host + "/", UriKind.Absolute).Host; } + catch (FormatException) { throw new ArgumentException($"\"{host}\" is not a usable host name.", parameterName); } + if (!string.Equals(canonical, host, StringComparison.OrdinalIgnoreCase)) { + throw new ArgumentException($"The host \"{host}\" is not in canonical form: System.Uri would rewrite it to \"{canonical}\", which would not round-trip identically across target frameworks. Pass the canonical form (\"{canonical}\").", parameterName); + } + + return host; + } + + internal static string RequireUserInfoPart(string value, string parameterName) { + if (value is null) { throw new ArgumentNullException(parameterName); } + foreach (char character in value) { + if (Unreserved.IndexOf(char.ToLowerInvariant(character)) < 0) { + throw new ArgumentException($"The user-info part may only contain unreserved characters (letters, digits, '-', '.', '_', '~'); '{character}' is not allowed.", parameterName); + } + } + + return value; + } + + internal static int RequirePort(int port, string parameterName) { + if (port is < 1 or > 65535) { throw new ArgumentOutOfRangeException(parameterName, port, "The port must be between 1 and 65535."); } + + return port; + } + + internal static int RequireSegmentCount(int count, string parameterName) { + if (count < 0) { throw new ArgumentOutOfRangeException(parameterName, count, "The segment count must not be negative."); } + + return count; + } + + #endregion + +} From 19ffc210eae011b7df2103d35970aec64ec2368b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 13:46:37 +0000 Subject: [PATCH 2/2] fix(dummies): keep the Any.Uri family cross-TFM safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the not-yet-shipped Any.Uri family so it builds and validates identically down to the net472 floor: - AnyUriTests used string.Split(char, StringSplitOptions), an overload that only exists on .NET Core; switch to the char[] overload so the test compiles on net472. - RequireHost rejected shorthand-IPv4 hosts by round-tripping the host through System.Uri, whose shorthand parsing is itself what differs across target frameworks — so the guard was fragile in the same way as the input it rejects. Replace it with a framework-independent dotted-quad check (IsCanonicalIpv4) that never touches System.Uri. Refs: #226 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FPAdCRsiaVo2orjJwCaDnb --- Dummies.UnitTests/AnyUriTests.cs | 2 +- Dummies/UriSpec.cs | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Dummies.UnitTests/AnyUriTests.cs b/Dummies.UnitTests/AnyUriTests.cs index ccb15f57..707764d1 100644 --- a/Dummies.UnitTests/AnyUriTests.cs +++ b/Dummies.UnitTests/AnyUriTests.cs @@ -124,7 +124,7 @@ public void WithoutPathRendersRoot() { [Fact(DisplayName = "WithPathSegments renders exactly that many segments.")] public void WithPathSegmentsRendersThatManySegments() { foreach (Uri value in Sample(Seeded(context => context.Uri().Web().WithHost("h.test").WithPathSegments(3)))) { - Check.That(value.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries).Length).IsEqualTo(3); + Check.That(value.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Length).IsEqualTo(3); } } diff --git a/Dummies/UriSpec.cs b/Dummies/UriSpec.cs index f65f400a..641fbf2d 100644 --- a/Dummies/UriSpec.cs +++ b/Dummies/UriSpec.cs @@ -319,19 +319,29 @@ internal static string RequireHost(string host, string parameterName) { throw new ArgumentException($"\"{host}\" is not a valid host name.", parameterName); } - // Reject a host System.Uri would rewrite (a shorthand IPv4 such as "1" -> "0.0.0.1"): it would not round-trip, - // and shorthand parsing has historically differed across target frameworks — the same determinism hazard as an - // IDN host. A case-only difference is invariant and stays allowed. - string canonical; - try { canonical = new Uri("http://" + host + "/", UriKind.Absolute).Host; } - catch (FormatException) { throw new ArgumentException($"\"{host}\" is not a usable host name.", parameterName); } - if (!string.Equals(canonical, host, StringComparison.OrdinalIgnoreCase)) { - throw new ArgumentException($"The host \"{host}\" is not in canonical form: System.Uri would rewrite it to \"{canonical}\", which would not round-trip identically across target frameworks. Pass the canonical form (\"{canonical}\").", parameterName); + // A host of only digits and dots is interpreted as an IPv4 literal by System.Uri, and its shorthand forms + // ("1" -> "0.0.0.1") parse differently across target frameworks — the same determinism hazard as an IDN host. + // Reject any such host that is not already a canonical four-octet dotted-quad, with a framework-independent + // check (never through System.Uri, whose parsing is the very thing that differs). + if (host.All(character => character is >= '0' and <= '9' or '.') && !IsCanonicalIpv4(host)) { + throw new ArgumentException($"The host \"{host}\" looks like a shorthand IPv4 literal, which System.Uri parses differently across target frameworks. Pass a DNS host name, or a canonical dotted-quad such as \"1.2.3.4\".", parameterName); } return host; } + private static bool IsCanonicalIpv4(string host) { + string[] parts = host.Split(new[] { '.' }); + if (parts.Length != 4) { return false; } + foreach (string part in parts) { + if (part.Length is 0 or > 3) { return false; } + if (part.Length > 1 && part[0] == '0') { return false; } // a leading zero is a non-canonical (octal-ish) octet + if (!int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out int octet) || octet > 255) { return false; } + } + + return true; + } + internal static string RequireUserInfoPart(string value, string parameterName) { if (value is null) { throw new ArgumentNullException(parameterName); } foreach (char character in value) {