From c6acf8a433273873c762c86f6b88fa25cb986d99 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 5 May 2026 08:52:40 +0200 Subject: [PATCH 1/5] Drop Maybe binding and OpenAPI unwraps on non-body slots (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maybe only makes sense on the wire where the protocol can express "explicitly null" — that's a JSON body. On query/route/header/form, the slot is either present or absent, so the three-state semantic that motivates Maybe? collapses to two-state and T? already covers it. Forms have the same problem: application/x-www-form-urlencoded carries strings, not nulls, so the binder's empty-string -> None mapping conflates "user left textbox blank" with "user wants this wiped". The Swashbuckle filter only unwrapped Maybe on [FromForm], leaving [FromQuery]/[FromHeader] rendering the body's wrapper-object schema — an asymmetry between the two pipelines. Easiest fix: drop the binder and the unwrap branches everywhere. - Removes MaybeModelBinder, MaybeModelBinderProvider, and the registration in StrongTypesServiceCollectionExtensions. - Drops the Maybe form-property reshape and the inline Maybe unwraps in the Swashbuckle non-body filter. - Drops the Maybe unwrap in PaintSlot on the Microsoft transformer. - Removes the form-maybe endpoint, MaybeFormRequest record, the matching OpenAPI integration test, and the AspNetCore Maybe binding tests + Maybe form fixture in the test API. - Updates the package readme, the top-level readme, and the skill docs (SKILL.md + references/aspnetcore.md) to describe the package as a NonEmptyEnumerable-only binder and to direct three-state PATCH callers to Maybe? from [FromBody]. Breaking change for anyone on [FromQuery]/[FromForm] Maybe today — acceptable since v1.1 hasn't shipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- Skill/SKILL.md | 6 +- Skill/references/aspnetcore.md | 104 +++++++++--------- readme.md | 2 +- .../Tests/BindingTests/MaybeBindingTests.cs | 70 ------------ .../Controllers/BindingProbeController.cs | 12 +- .../MaybeModelBinder.cs | 78 ------------- .../MaybeModelBinderProvider.cs | 29 ----- .../StrongTypesServiceCollectionExtensions.cs | 3 +- src/StrongTypes.AspNetCore/readme.md | 52 ++++----- ...OpenApiDocumentTestsBase.NonBodyBinding.cs | 22 ---- .../NonBodyStrongTypeOperationTransformer.cs | 9 -- .../NonBodyStrongTypeOperationFilter.cs | 53 --------- .../BasicControllers.cs | 8 -- 13 files changed, 80 insertions(+), 368 deletions(-) delete mode 100644 src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/MaybeBindingTests.cs delete mode 100644 src/StrongTypes.AspNetCore/MaybeModelBinder.cs delete mode 100644 src/StrongTypes.AspNetCore/MaybeModelBinderProvider.cs diff --git a/Skill/SKILL.md b/Skill/SKILL.md index 89b76a5..d190016 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -24,7 +24,7 @@ demand when about to write code against that surface. | `Kalicz.StrongTypes.OpenApi.Microsoft` | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so wrappers render as the wire JSON shape, not the CLR shape. | | `Kalicz.StrongTypes.OpenApi.Swashbuckle` | The same idea for Swashbuckle's `AddSwaggerGen()` pipeline — schema filters that produce the wire JSON shape. | | `Kalicz.StrongTypes.Wpf` | `TypeConverter` infrastructure for two-way MVVM binding to strong-typed view-model properties. One `this.UseStrongTypes()` call in `App.OnStartup`. | -| `Kalicz.StrongTypes.AspNetCore` | **Niche** MVC model binders for `Maybe` and `NonEmptyEnumerable` from `[FromForm]` / `[FromQuery]` / `[FromHeader]` / `[FromRoute]`. **Not needed for JSON APIs** — `[FromBody]` round-trips both via the core JSON converters. | +| `Kalicz.StrongTypes.AspNetCore` | **Niche** MVC model binder for `NonEmptyEnumerable` from `[FromForm]` / `[FromQuery]` / `[FromHeader]` / `[FromRoute]`. **Not needed for JSON APIs** — `[FromBody]` round-trips it via the core JSON converters. | Add packages only when the host project actually hits that stack: @@ -32,7 +32,7 @@ Add packages only when the host project actually hits that stack: - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. - **Wpf** — only for WPF apps that two-way bind to strong-typed VM properties. See `references/wpf.md`. -- **AspNetCore** — only when a controller takes `Maybe` or `NonEmptyEnumerable` from a non-body source (forms primarily). Don't add it to a JSON API; `[FromBody]` already handles both wrappers. See `references/aspnetcore.md`. +- **AspNetCore** — only when a controller takes `NonEmptyEnumerable` from a non-body source (forms, repeated query params, header lists). Don't add it to a JSON API; `[FromBody]` already handles it. `Maybe` is intentionally unsupported on non-body slots — use `T?` there, or `Maybe?` from `[FromBody]` for three-state PATCH. See `references/aspnetcore.md`. ## Type catalog — what's in the box @@ -67,7 +67,7 @@ demand when about to write code against that surface. | FsCheck: shared `Generators` class, shipped arbitraries | `references/fscheck.md` | | OpenAPI: `AddStrongTypes()` for either `AddOpenApi()` (`Kalicz.StrongTypes.OpenApi.Microsoft`) or `AddSwaggerGen()` (`Kalicz.StrongTypes.OpenApi.Swashbuckle`) | `references/openapi.md` | | WPF: `this.UseStrongTypes()` in `App.OnStartup` for two-way MVVM binding | `references/wpf.md` | -| ASP.NET Core MVC binders: `services.AddStrongTypes()` for `Maybe` / `NonEmptyEnumerable` from `[FromForm]` & friends (niche; not for JSON APIs) | `references/aspnetcore.md` | +| ASP.NET Core MVC binders: `services.AddStrongTypes()` for `NonEmptyEnumerable` from `[FromForm]` & friends (niche; not for JSON APIs) | `references/aspnetcore.md` | ## Design philosophy — picking the right wrapper diff --git a/Skill/references/aspnetcore.md b/Skill/references/aspnetcore.md index 2f6c421..2fa916a 100644 --- a/Skill/references/aspnetcore.md +++ b/Skill/references/aspnetcore.md @@ -3,9 +3,8 @@ A small, niche companion package. **Most ASP.NET Core apps don't need it.** Reach for it only when: -- A controller action takes `Maybe` or `NonEmptyEnumerable` from - `[FromForm]` (the primary use), `[FromQuery]`, `[FromHeader]`, or - `[FromRoute]`. +- A controller action takes `NonEmptyEnumerable` from `[FromForm]`, + `[FromQuery]`, `[FromHeader]`, or `[FromRoute]`. If the app talks JSON over `[FromBody]`, this package adds nothing — the JSON converters in the core `Kalicz.StrongTypes` package already @@ -14,33 +13,17 @@ installing it for JSON APIs. ## When you DO need it -Two specific shapes that the framework's built-in binders can't -produce on their own: +**`NonEmptyEnumerable` from repeated form fields, query +parameters, or header lists.** Multi-select inputs, checkbox groups, +list-style filters (`?tags=a&tags=b&tags=c`). The binder enforces the +non-empty invariant; an empty / missing source surfaces as a 400 with +`ValidationProblemDetails`. -1. **`Maybe?` on a form-posted patch contract.** A single HTML - form field that needs three intents — *don't touch*, *clear*, - *set* — can't be expressed with `T?` alone. With this package, - `Maybe?` binds: `null` = field omitted, `None` = field present - but empty, `Some(value)` = field set to a parsed value. - - ```csharp - public sealed record ProfilePatch(Maybe? DisplayName); - - [HttpPost("profile")] - public IActionResult Patch([FromForm] ProfilePatch patch) { ... } - ``` - -2. **`NonEmptyEnumerable` from repeated form fields or query - parameters.** Multi-select inputs, checkbox groups, list-style - filters (`?tags=a&tags=b&tags=c`). The binder enforces the - non-empty invariant; an empty / missing source surfaces as a 400 - with `ValidationProblemDetails`. - - ```csharp - [HttpPost("articles")] - public IActionResult Create( - [FromForm] NonEmptyEnumerable tags) { ... } - ``` +```csharp +[HttpPost("articles")] +public IActionResult Create( + [FromForm] NonEmptyEnumerable tags) { ... } +``` Every other strong-type wrapper (`NonEmptyString`, `Email`, `Digit`, `Positive`, `Negative`, `NonNegative`, `NonPositive`) @@ -50,18 +33,35 @@ they implement `IParsable` and ASP.NET Core's built-in ## When you DO NOT need it -- **JSON APIs.** `[FromBody]` round-trips both `Maybe` and - `NonEmptyEnumerable` (and arbitrary nesting of them) via the - JSON converters in the core package. No extra reference required. +- **JSON APIs.** `[FromBody]` round-trips `NonEmptyEnumerable` + (and arbitrary nesting with `Maybe`) via the JSON converters in + the core package. No extra reference required. - **Single-string strong types from non-body sources.** If the action signature is `[FromQuery] NonEmptyString` or `[FromRoute] Positive`, ASP.NET Core's built-in `TryParse` binder already handles it — `IParsable` is enough. -- **Three-state semantics on a query string or header.** The binder - *will* bind `Maybe` from those sources, but the third state - (omitted vs. present-but-empty) doesn't really survive on those - wire formats. Use `T?` instead — the distinction isn't - controllable by the caller. + +## `Maybe` is not supported on non-body slots + +`Maybe` is **intentionally unsupported** on `[FromQuery]`, +`[FromRoute]`, `[FromHeader]`, and `[FromForm]`. The wire formats for +those slots only model "present" vs "absent" — there's no +protocol-level "explicitly null" — so the three-state semantic that +motivates `Maybe?` collapses to two-state, which `T?` already +covers natively. + +- **Optional non-body field?** Use `T?` (e.g. `[FromQuery] int? age`, + `[FromForm] NonEmptyString? nickname`). The framework binds + "absent" to `null` and "present" to the parsed value. +- **Three-state PATCH semantics?** Use `Maybe?` from `[FromBody]` + with a JSON payload. The JSON converter distinguishes "property + omitted" (`null`), "explicit null" (`None`), and "value supplied" + (`Some`). That's the only wire format where all three are + unambiguously expressible. + +If a project today has `[FromForm] Maybe` or `[FromQuery] Maybe` +parameters, switch them to `T?` (or move the contract to `[FromBody]` +if real PATCH semantics are needed). ## Wiring @@ -72,13 +72,14 @@ builder.Services.AddControllers(); builder.Services.AddStrongTypes(); // from StrongTypes.AspNetCore ``` -`AddStrongTypes()` inserts both `IModelBinderProvider`s at the front -of `MvcOptions.ModelBinderProviders`, ahead of the framework's -collection / simple-type providers. +`AddStrongTypes()` inserts the `NonEmptyEnumerable` +`IModelBinderProvider` at the front of +`MvcOptions.ModelBinderProviders`, ahead of the framework's +collection providers. ## Element type support -Both binders parse each raw string via `IParsable`. Element types +The binder parses each raw string via `IParsable`. Element types that work: - BCL primitives that implement `IParsable` — `int`, `long`, @@ -88,18 +89,17 @@ that work: `ValidationProblemDetails`, with the failing field named in `ModelState`. -**Not supported** — wrapper-of-wrapper combinations on non-body -sources: `NonEmptyEnumerable>`, -`Maybe>`, -`NonEmptyEnumerable>`. There's no clean wire -form for them on a query string / header / form. Use `[FromBody]` if -you need that nesting. +**Not supported** — +`NonEmptyEnumerable>` on non-body sources. +There's no clean wire form for nested collections on a query string / +header / form. Use `[FromBody]` if that nesting is needed. ## Decision rule > **Default: don't add this package.** Only add it when a controller -> action needs `Maybe` or `NonEmptyEnumerable` from a non-body -> source — and that's not just a workaround for "I want a strong -> type in my query string." For single-value wrappers from query / -> route / header, the framework already handles them; for JSON APIs, -> `[FromBody]` already handles them. +> action needs `NonEmptyEnumerable` from a non-body source — and +> that's not just a workaround for "I want a strong type in my query +> string." For single-value wrappers from query / route / header, the +> framework already handles them; for JSON APIs, `[FromBody]` already +> handles `NonEmptyEnumerable` and `Maybe` (including +> three-state PATCH). diff --git a/readme.md b/readme.md index 9e02709..4f6ae1c 100644 --- a/readme.md +++ b/readme.md @@ -653,7 +653,7 @@ Result[], string> ParseOrderQuantities(IEnumerable inputs) | [`Kalicz.StrongTypes.FsCheck`](https://www.nuget.org/packages/Kalicz.StrongTypes.FsCheck/) | FsCheck `Arbitrary` generators for property-based (generative) testing of code that takes or returns the wrappers. | [readme](src/StrongTypes.FsCheck/readme.md) | | [`Kalicz.StrongTypes.OpenApi.Microsoft`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Microsoft/) | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so the generated document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Microsoft/readme.md) | | [`Kalicz.StrongTypes.OpenApi.Swashbuckle`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Swashbuckle/) | Schema filters for `Swashbuckle.AspNetCore` (`AddSwaggerGen()`) so the generated Swagger document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Swashbuckle/readme.md) | -| [`Kalicz.StrongTypes.AspNetCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.AspNetCore/) | MVC model binders for `Maybe` and `NonEmptyEnumerable` from `[FromForm]` (primary use), `[FromQuery]`, `[FromHeader]`, and `[FromRoute]`. Not needed for JSON APIs — `[FromBody]` already round-trips both via the main package's JSON converters. | [readme](src/StrongTypes.AspNetCore/readme.md) | +| [`Kalicz.StrongTypes.AspNetCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.AspNetCore/) | MVC model binder for `NonEmptyEnumerable` from `[FromForm]`, `[FromQuery]`, `[FromHeader]`, and `[FromRoute]`. Not needed for JSON APIs — `[FromBody]` already round-trips it via the main package's JSON converters. | [readme](src/StrongTypes.AspNetCore/readme.md) | | [`Kalicz.StrongTypes.Wpf`](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) | `TypeConverter`s that bridge `IParsable` into `TypeDescriptor`, enabling two-way MVVM binding to strong types in WPF (and any framework that resolves converters via `TypeDescriptor`). | [readme](src/StrongTypes.Wpf/readme.md) | [↑ Back to contents](#contents) diff --git a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/MaybeBindingTests.cs b/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/MaybeBindingTests.cs deleted file mode 100644 index e8056a8..0000000 --- a/src/StrongTypes.AspNetCore.IntegrationTests/Tests/BindingTests/MaybeBindingTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.AspNetCore.Mvc.Testing; -using StrongTypes.AspNetCore.IntegrationTests.Infrastructure; -using Xunit; - -namespace StrongTypes.AspNetCore.IntegrationTests.Tests.BindingTests; - -/// -/// Verifies the MVC binder shipped in Kalicz.StrongTypes.AspNetCore -/// for form-bound nullable Maybe values that preserve omitted vs empty vs -/// populated input. -/// -public sealed class MaybeBindingTests(AspNetCoreTestApiFactory factory) : IClassFixture, IDisposable -{ - private readonly HttpClient _client = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - }); - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - public void Dispose() => _client.Dispose(); - - [Fact] - public async Task Form_NullableMaybeWithValue_BindsToSome() - { - using var content = new FormUrlEncodedContent(new Dictionary - { - ["displayName"] = "FormName", - }); - - var response = await _client.PostAsync("/binding-probe/form", content, Ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await response.Content.ReadFromJsonAsync(Ct); - Assert.Equal("some", json.GetProperty("displayNameState").GetString()); - Assert.Equal("FormName", json.GetProperty("displayName").GetString()); - } - - [Fact] - public async Task Form_NullableMaybeOmitted_BindsToMissingState() - { - using var content = new FormUrlEncodedContent([]); - - var response = await _client.PostAsync("/binding-probe/form", content, Ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await response.Content.ReadFromJsonAsync(Ct); - Assert.Equal("missing", json.GetProperty("displayNameState").GetString()); - Assert.Equal(JsonValueKind.Null, json.GetProperty("displayName").ValueKind); - } - - [Fact] - public async Task Form_EmptyNullableMaybe_BindsToNone() - { - using var content = new FormUrlEncodedContent(new Dictionary - { - ["displayName"] = "", - }); - - var response = await _client.PostAsync("/binding-probe/form", content, Ct); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await response.Content.ReadFromJsonAsync(Ct); - Assert.Equal("none", json.GetProperty("displayNameState").GetString()); - Assert.Equal(JsonValueKind.Null, json.GetProperty("displayName").ValueKind); - } -} diff --git a/src/StrongTypes.AspNetCore.TestApi/Controllers/BindingProbeController.cs b/src/StrongTypes.AspNetCore.TestApi/Controllers/BindingProbeController.cs index 7a51830..093e3c2 100644 --- a/src/StrongTypes.AspNetCore.TestApi/Controllers/BindingProbeController.cs +++ b/src/StrongTypes.AspNetCore.TestApi/Controllers/BindingProbeController.cs @@ -41,8 +41,6 @@ public IActionResult FromForm([FromForm] BindingProbeFormRequest request) tags = request.Tags?.Select(t => t.Value).ToArray(), counts = request.Counts?.Select(c => c.Value).ToArray(), digits = request.Digits?.Select(d => (int)d.Value).ToArray(), - displayNameState = MaybeState(request.DisplayName), - displayName = MaybeValue(request.DisplayName), }); [HttpGet("query-nee-strong")] @@ -56,17 +54,9 @@ public IActionResult StrongTypedFromQuery( counts = counts.Select(c => c.Value).ToArray(), digits = digits.Select(d => (int)d.Value).ToArray(), }); - - private static string MaybeState(Maybe? maybe) - where T : notnull - => maybe is null ? "missing" : maybe.Value.IsSome ? "some" : "none"; - - private static string? MaybeValue(Maybe? maybe) - => maybe is null ? null : maybe.Value.Match(v => v.Value, () => null); } public sealed record BindingProbeFormRequest( NonEmptyEnumerable? Tags, NonEmptyEnumerable>? Counts, - NonEmptyEnumerable? Digits, - Maybe? DisplayName); + NonEmptyEnumerable? Digits); diff --git a/src/StrongTypes.AspNetCore/MaybeModelBinder.cs b/src/StrongTypes.AspNetCore/MaybeModelBinder.cs deleted file mode 100644 index 362777b..0000000 --- a/src/StrongTypes.AspNetCore/MaybeModelBinder.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace StrongTypes.AspNetCore; - -/// Binds from a non-body source. For nullable Maybe targets, an absent source binds to null, an empty source binds to None, and a non-empty source parses via on and wraps it as Some. -/// The wrapped type. Must implement (every BCL primitive in net7+ and every Kalicz.StrongTypes wrapper qualifies). -public sealed class MaybeModelBinder : IModelBinder - where T : notnull -{ - private readonly bool _isNullable; - - public MaybeModelBinder() - { - } - - public MaybeModelBinder(bool isNullable) - { - _isNullable = isNullable; - } - - public Task BindModelAsync(ModelBindingContext bindingContext) - { - ArgumentNullException.ThrowIfNull(bindingContext); - var modelName = bindingContext.ModelName; - - if (!StringElementParser.IsSupported) - { - bindingContext.ModelState.TryAddModelError(modelName, $"Maybe<{typeof(T).Name}> can't bind: {typeof(T).Name} doesn't implement IParsable<{typeof(T).Name}>."); - bindingContext.Result = ModelBindingResult.Failed(); - return Task.CompletedTask; - } - - var (raw, present) = ReadRawValue(bindingContext); - if (!present) - { - var isNullable = _isNullable || ModelMetadataNullability.IsNullable(bindingContext.ModelMetadata); - bindingContext.Result = ModelBindingResult.Success(isNullable ? null : default(Maybe)); - return Task.CompletedTask; - } - - if (string.IsNullOrEmpty(raw)) - { - bindingContext.Result = ModelBindingResult.Success(default(Maybe)); - return Task.CompletedTask; - } - - if (!StringElementParser.TryParse(raw, out var value)) - { - bindingContext.ModelState.TryAddModelError(modelName, $"Could not parse '{raw}' as {typeof(T).Name}."); - bindingContext.Result = ModelBindingResult.Failed(); - return Task.CompletedTask; - } - - bindingContext.Result = ModelBindingResult.Success(Maybe.Some(value)); - return Task.CompletedTask; - } - - private static (string? raw, bool present) ReadRawValue(ModelBindingContext bindingContext) - { - var bindingSource = bindingContext.BindingSource; - if (bindingSource is not null && bindingSource.CanAcceptDataFrom(BindingSource.Header)) - { - var headers = bindingContext.HttpContext.Request.Headers; - if (!headers.TryGetValue(bindingContext.FieldName, out var values)) - return (null, false); - return (values.ToString(), true); - } - - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - if (valueProviderResult == ValueProviderResult.None) - return (null, false); - - bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult); - return (valueProviderResult.FirstValue, true); - } -} diff --git a/src/StrongTypes.AspNetCore/MaybeModelBinderProvider.cs b/src/StrongTypes.AspNetCore/MaybeModelBinderProvider.cs deleted file mode 100644 index 37ea147..0000000 --- a/src/StrongTypes.AspNetCore/MaybeModelBinderProvider.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace StrongTypes.AspNetCore; - -/// Resolves an for action parameters typed as . -/// Useful for form-bound patch contracts where nullable Maybe<T> can preserve omitted vs empty vs populated fields. -public sealed class MaybeModelBinderProvider : IModelBinderProvider -{ - public IModelBinder? GetBinder(ModelBinderProviderContext context) - { - ArgumentNullException.ThrowIfNull(context); - - var modelType = context.Metadata.ModelType; - var isNullable = ModelMetadataNullability.IsNullable(context.Metadata); - if (modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(Nullable<>)) - { - modelType = modelType.GetGenericArguments()[0]; - isNullable = true; - } - - if (!modelType.IsGenericType || modelType.GetGenericTypeDefinition() != typeof(Maybe<>)) - return null; - - var innerType = modelType.GetGenericArguments()[0]; - var binderType = typeof(MaybeModelBinder<>).MakeGenericType(innerType); - return (IModelBinder)Activator.CreateInstance(binderType, isNullable)!; - } -} diff --git a/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs b/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs index 8c4d4f4..c31ed2b 100644 --- a/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs +++ b/src/StrongTypes.AspNetCore/StrongTypesServiceCollectionExtensions.cs @@ -6,14 +6,13 @@ namespace StrongTypes.AspNetCore; /// Registration entry point for the StrongTypes ASP.NET Core MVC model binders. public static class StrongTypesServiceCollectionExtensions { - /// Inserts the StrongTypes model binders for and at the front of . + /// Inserts the StrongTypes model binder for at the front of . /// The service collection to configure. public static IServiceCollection AddStrongTypes(this IServiceCollection services) { services.Configure(options => { options.ModelBinderProviders.Insert(0, new NonEmptyEnumerableModelBinderProvider()); - options.ModelBinderProviders.Insert(0, new MaybeModelBinderProvider()); }); return services; } diff --git a/src/StrongTypes.AspNetCore/readme.md b/src/StrongTypes.AspNetCore/readme.md index 1a1dd88..e8f69ef 100644 --- a/src/StrongTypes.AspNetCore/readme.md +++ b/src/StrongTypes.AspNetCore/readme.md @@ -4,20 +4,30 @@ A small companion package to [Kalicz.StrongTypes](https://www.nuget.org/packages/Kalicz.StrongTypes). -You only need it if you want to bind `NonEmptyEnumerable` or -`Maybe` from `[FromForm]` (the primary expected use), or from -`[FromQuery]`, `[FromHeader]`, or `[FromRoute]`. +You only need it if you want to bind `NonEmptyEnumerable` from +`[FromForm]`, `[FromQuery]`, `[FromHeader]`, or `[FromRoute]`. If you're writing a standard JSON API, you don't need this package. -`[FromBody]` round-tripping for both wrappers — and arbitrary nesting -of them — already works through the JSON converters that ship with -the main `Kalicz.StrongTypes` package. +`[FromBody]` round-tripping for `NonEmptyEnumerable` — and +arbitrary nesting with the other wrappers — already works through +the JSON converters that ship with the main `Kalicz.StrongTypes` +package. The other wrappers (`NonEmptyString`, `Email`, `Digit`, the numeric ones) bind from every non-body source out of the box because they implement `IParsable` and ASP.NET Core's built-in `TryParse` binder picks them up — no extra package needed for those either. +`Maybe` is intentionally **not** supported on non-body slots. On +the wire, a query / route / header value is either present or +absent — there is no protocol-level "explicitly null" — so the +three-state semantic that motivates `Maybe?` collapses to +two-state, which `T?` already covers. Forms have the same problem +(`application/x-www-form-urlencoded` carries strings, not nulls), so +they're excluded for the same reason. Use `Maybe` from +`[FromBody]` if you need three-state PATCH semantics — the JSON +converter handles that natively. + ## Install ```powershell @@ -33,28 +43,11 @@ builder.Services.AddControllers(); builder.Services.AddStrongTypes(); ``` -`AddStrongTypes()` inserts both binder providers at the front of -`MvcOptions.ModelBinderProviders`, so they run before the default -collection / simple-type binders. - -## Examples - -A form-posted patch contract — the original motivation for the -package. `Maybe?` distinguishes the three update intents that a -single field on an HTML form has to express: - -```csharp -[HttpPost("profile")] -public IActionResult PatchProfile([FromForm] ProfilePatch patch) -{ - // DisplayName == null: field omitted, leave it alone. - // DisplayName == None: field was present but empty, clear it. - // DisplayName == Some(value): set it to a non-empty string. - return Ok(); -} +`AddStrongTypes()` inserts the `NonEmptyEnumerable` binder +provider at the front of `MvcOptions.ModelBinderProviders`, so it +runs before the default collection binders. -public sealed record ProfilePatch(Maybe? DisplayName); -``` +## Example A multi-value form field (or query string) that must be non-empty: @@ -71,7 +64,7 @@ public IActionResult Create([FromForm] NonEmptyEnumerable tags) ## Supported element types -Both binders parse each raw string via `IParsable` on the element +The binder parses each raw string via `IParsable` on the element type. That covers: - Every BCL primitive and value type that implements `IParsable` @@ -85,8 +78,7 @@ type. That covers: `Email`) surface as 400 + `ValidationProblemDetails`. Wrapper-of-wrapper combinations -(`NonEmptyEnumerable>`, `Maybe>`, -`NonEmptyEnumerable>`) are genuinely out of +(`NonEmptyEnumerable>`) are genuinely out of scope: they have no clean wire form on a non-body source. Use `[FromBody]` for those — the JSON converters in the main package handle arbitrary nesting. diff --git a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs index fb85387..488accd 100644 --- a/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs +++ b/src/StrongTypes.OpenApi.IntegrationTests/Tests/OpenApiDocumentTestsBase.NonBodyBinding.cs @@ -274,28 +274,6 @@ public async Task FromForm_AllStrongTypes_FormBody_RendersEveryWrapperWithItsAnn : """{"type":"array","minItems":2,"items":{"type":"number","format":"double","maximum":0,"exclusiveMaximum":true}}"""); } - /// - /// bound from a non-body slot via - /// StrongTypes.AspNetCore's model binder reads a single raw - /// form-data value, parses it as , and wraps - /// the result in Some/None. The wire is therefore the - /// inner — not the body-side - /// {"Value":<T>} wrapper object the JSON converter - /// emits. Both pipelines must paint the inner shape on every - /// [FromForm] property; recursion - /// matches what we already do for . - /// - [Fact] - public async Task FromForm_Maybe_FormBody_RendersInnerWireShapeForEachProperty() - { - var formSchema = FormRequestSchema(await GetDocumentAsync(), "/binding-probe/form-maybe"); - AssertFormBodyHasObjectShape(formSchema, "Greeting", "Quantity", "ContactEmail"); - - AssertFormPropertyNonEmptyStringSchema(formSchema, "Greeting"); - AssertFormPropertyPositiveIntSchema(formSchema, "Quantity", Version); - AssertFormPropertyEmailSchema(formSchema, "ContactEmail"); - } - [Fact] public async Task FromForm_Mixed_FormBody_RendersBothPrimitivesAndWrappersWithAnnotations() { diff --git a/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs b/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs index c8ce00d..21d04a2 100644 --- a/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs +++ b/src/StrongTypes.OpenApi.Microsoft/Binding/NonBodyStrongTypeOperationTransformer.cs @@ -98,15 +98,6 @@ private static void RewriteFormProperty(OpenApiOperation operation, ApiParameter private static IOpenApiSchema PaintSlot(IOpenApiSchema? existing, Type clrType, ApiParameterDescription pd) { - // Maybe bound from a non-body slot via the StrongTypes.AspNetCore - // model binder reads a single raw form-data value and wraps it as - // Some/None — the wire is the inner T, not the body-side - // {"Value":} wrapper object the JSON converter emits. Unwrap - // here so the rest of this pass paints the inner shape and merges - // slot annotations against it. - if (StrongTypeSchemaTypes.TryGetMaybeValue(clrType, out var maybeInner)) - clrType = maybeInner; - // Mutate the existing schema when possible so any keywords the // pipeline already wrote (description, default, caller-applied // [StringLength] on the IParsable string overload, …) survive the diff --git a/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs b/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs index 2f3f245..e609b28 100644 --- a/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs +++ b/src/StrongTypes.OpenApi.Swashbuckle/Binding/NonBodyStrongTypeOperationFilter.cs @@ -49,7 +49,6 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) if (descriptions is null || descriptions.Count == 0) return; ReshapeFormAllOfIntoProperties(operation, descriptions, context.SchemaGenerator, context.SchemaRepository, logger); - ReshapeMaybeFormPropertiesIntoInnerWireShape(operation, descriptions, context.SchemaGenerator, context.SchemaRepository); foreach (var pd in descriptions) { @@ -88,8 +87,6 @@ private static void MergeFormPropertyAnnotations(OpenApiOperation operation, Api { if (operation.RequestBody?.Content is not { } content) return; var clrType = ResolveParameterClrType(pd); - if (StrongTypeSchemaTypes.TryGetMaybeValue(clrType, out var maybeInner)) - clrType = maybeInner; if (GetSlotAttributes(pd).Count == 0) return; foreach (var contentType in s_formContentTypes) @@ -143,15 +140,6 @@ private static void ReshapeFormAllOfIntoProperties( var clrType = ResolveParameterClrType(pd); - // Maybe bound from a non-body slot via the StrongTypes.AspNetCore - // model binder reads a single raw form-data value and wraps - // it as Some/None — the wire is the inner T, not the - // body-side {"Value":} wrapper object. Generate the - // schema for the inner T so consumers see the field's - // actual on-the-wire shape. - if (StrongTypeSchemaTypes.TryGetMaybeValue(clrType, out var maybeInner)) - clrType = maybeInner; - // For primitives, hand Swashbuckle the form record's // PropertyInfo so its generator surfaces caller annotations // (`[StringLength]`, `[Range]`, …) directly. For wrappers @@ -188,47 +176,6 @@ private static void ReshapeFormAllOfIntoProperties( } } - /// - /// Replaces the per-property schemas of -typed - /// form fields with the inner type's wire shape. The body-side Maybe - /// schema is the wrapper object ({"type":"object","properties":{"Value":<T>}}) - /// the JSON converter emits, but the StrongTypes.AspNetCore - /// model binder reads non-body slots as a single raw value of the - /// inner type, so the form-data wire is the inner type. Runs after - /// so it covers both the - /// reshaped path and the case where Swashbuckle natively emitted a - /// properties map. - /// - private static void ReshapeMaybeFormPropertiesIntoInnerWireShape( - OpenApiOperation operation, - IList descriptions, - ISchemaGenerator schemaGenerator, - SchemaRepository schemaRepository) - { - if (operation.RequestBody?.Content is not { } content) return; - - foreach (var contentType in s_formContentTypes) - { - if (!content.TryGetValue(contentType, out var media)) continue; - if (media.Schema is not OpenApiSchema formSchema) continue; - if (formSchema.Properties is not { Count: > 0 } properties) continue; - - foreach (var pd in descriptions) - { - if (pd.Source != BindingSource.Form) continue; - var clrType = ResolveParameterClrType(pd); - if (!StrongTypeSchemaTypes.TryGetMaybeValue(clrType, out var innerType)) continue; - if (!properties.ContainsKey(pd.Name)) continue; - - MemberInfo? memberInfo = null; - if (!StrongTypeSchemaTypes.IsInlineable(innerType)) - memberInfo = ResolveFormPropertyMember(pd); - - properties[pd.Name] = schemaGenerator.GenerateSchema(innerType, schemaRepository, memberInfo); - } - } - } - private static MemberInfo? ResolveFormPropertyMember(ApiParameterDescription pd) { if (pd.ModelMetadata is not { ContainerType: { } containerType, PropertyName: { } propertyName }) return null; diff --git a/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs b/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs index d3a9757..4004f3e 100644 --- a/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs +++ b/src/StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs @@ -191,9 +191,6 @@ public IActionResult FromQueryAnnotated( [HttpPost("form-mixed")] public IActionResult FromFormMixed([FromForm] MixedFormRequest request) => Ok(); - - [HttpPost("form-maybe")] - public IActionResult FromFormMaybe([FromForm] MaybeFormRequest request) => Ok(); } public sealed record BindingProbeFormRequest( @@ -235,8 +232,3 @@ public sealed record MixedFormRequest( [property: Range(1, 100)] Positive Stock, Email ContactEmail, NonEmptyEnumerable Tags); - -public sealed record MaybeFormRequest( - Maybe Greeting, - Maybe> Quantity, - Maybe ContactEmail); From c5aeba44e242663e7907706e6d4fd1b718e615d2 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 5 May 2026 09:17:21 +0200 Subject: [PATCH 2/5] Tighten Maybe-on-non-body section in aspnetcore skill reference Co-Authored-By: Claude Opus 4.7 (1M context) --- Skill/references/aspnetcore.md | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/Skill/references/aspnetcore.md b/Skill/references/aspnetcore.md index 2fa916a..b634ea7 100644 --- a/Skill/references/aspnetcore.md +++ b/Skill/references/aspnetcore.md @@ -44,24 +44,11 @@ they implement `IParsable` and ASP.NET Core's built-in ## `Maybe` is not supported on non-body slots `Maybe` is **intentionally unsupported** on `[FromQuery]`, -`[FromRoute]`, `[FromHeader]`, and `[FromForm]`. The wire formats for -those slots only model "present" vs "absent" — there's no -protocol-level "explicitly null" — so the three-state semantic that -motivates `Maybe?` collapses to two-state, which `T?` already -covers natively. - -- **Optional non-body field?** Use `T?` (e.g. `[FromQuery] int? age`, - `[FromForm] NonEmptyString? nickname`). The framework binds - "absent" to `null` and "present" to the parsed value. -- **Three-state PATCH semantics?** Use `Maybe?` from `[FromBody]` - with a JSON payload. The JSON converter distinguishes "property - omitted" (`null`), "explicit null" (`None`), and "value supplied" - (`Some`). That's the only wire format where all three are - unambiguously expressible. - -If a project today has `[FromForm] Maybe` or `[FromQuery] Maybe` -parameters, switch them to `T?` (or move the contract to `[FromBody]` -if real PATCH semantics are needed). +`[FromRoute]`, `[FromHeader]`, and `[FromForm]`. Those wire formats +model "present" vs "absent" only, so the three-state semantic +collapses to two-state — `T?` covers it. Use `Maybe?` from +`[FromBody]` when real three-state PATCH semantics are needed; the +JSON converter handles it. ## Wiring From 70ac9b356612bfaa20515bf27fe031593a3fe22b Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 5 May 2026 09:19:15 +0200 Subject: [PATCH 3/5] Tighten AspNetCore package-add bullet in SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the Maybe-on-non-body note from the "when to add each package" list — it's covered in references/aspnetcore.md and doesn't belong in the package-add bullet. Rephrases the JSON-API line so "it" clearly refers to the package. Co-Authored-By: Claude Opus 4.7 (1M context) --- Skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Skill/SKILL.md b/Skill/SKILL.md index d190016..06c16b0 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -32,7 +32,7 @@ Add packages only when the host project actually hits that stack: - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. - **Wpf** — only for WPF apps that two-way bind to strong-typed VM properties. See `references/wpf.md`. -- **AspNetCore** — only when a controller takes `NonEmptyEnumerable` from a non-body source (forms, repeated query params, header lists). Don't add it to a JSON API; `[FromBody]` already handles it. `Maybe` is intentionally unsupported on non-body slots — use `T?` there, or `Maybe?` from `[FromBody]` for three-state PATCH. See `references/aspnetcore.md`. +- **AspNetCore** — only when a controller takes `NonEmptyEnumerable` from a non-body source (forms, repeated query params, header lists). A pure JSON API doesn't need it; `[FromBody]` already handles `NonEmptyEnumerable` via the core JSON converters. See `references/aspnetcore.md`. ## Type catalog — what's in the box From 3922962ee0f83efb5cb9398d9742fdef22d6d25e Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 5 May 2026 09:20:37 +0200 Subject: [PATCH 4/5] Drop Maybe from AspNetCore card in package layout diagram Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/diagrams/package-layout-dark.svg | 2 +- docs/diagrams/package-layout.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/diagrams/package-layout-dark.svg b/docs/diagrams/package-layout-dark.svg index c704ed3..e5262e0 100644 --- a/docs/diagrams/package-layout-dark.svg +++ b/docs/diagrams/package-layout-dark.svg @@ -121,7 +121,7 @@ Kalicz.StrongTypes.AspNetCore rarely needed - Maybe<T> / NonEmptyEnumerable<T> binders from forms + NonEmptyEnumerable<T> binder from forms diff --git a/docs/diagrams/package-layout.svg b/docs/diagrams/package-layout.svg index 3091e95..0bacdd1 100644 --- a/docs/diagrams/package-layout.svg +++ b/docs/diagrams/package-layout.svg @@ -121,7 +121,7 @@ Kalicz.StrongTypes.AspNetCore rarely needed - Maybe<T> / NonEmptyEnumerable<T> binders from forms + NonEmptyEnumerable<T> binder from forms From 1375f1faecae242765fe5edbc44d566734c1678a Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 5 May 2026 09:21:49 +0200 Subject: [PATCH 5/5] Spell out non-body slots on AspNetCore card in package layout diagram Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/diagrams/package-layout-dark.svg | 2 +- docs/diagrams/package-layout.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/diagrams/package-layout-dark.svg b/docs/diagrams/package-layout-dark.svg index e5262e0..ef8ef93 100644 --- a/docs/diagrams/package-layout-dark.svg +++ b/docs/diagrams/package-layout-dark.svg @@ -121,7 +121,7 @@ Kalicz.StrongTypes.AspNetCore rarely needed - NonEmptyEnumerable<T> binder from forms + NonEmptyEnumerable<T> binder from forms / query / headers diff --git a/docs/diagrams/package-layout.svg b/docs/diagrams/package-layout.svg index 0bacdd1..5412897 100644 --- a/docs/diagrams/package-layout.svg +++ b/docs/diagrams/package-layout.svg @@ -121,7 +121,7 @@ Kalicz.StrongTypes.AspNetCore rarely needed - NonEmptyEnumerable<T> binder from forms + NonEmptyEnumerable<T> binder from forms / query / headers