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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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<T>` and `NonEmptyEnumerable<T>` 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<T>` 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:

- **EfCore** — only if EF Core is in use.
- **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<T>` or `NonEmptyEnumerable<T>` 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<T>` from a non-body source (forms, repeated query params, header lists). A pure JSON API doesn't need it; `[FromBody]` already handles `NonEmptyEnumerable<T>` via the core JSON converters. See `references/aspnetcore.md`.

## Type catalog — what's in the box

Expand Down Expand Up @@ -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<T>` / `NonEmptyEnumerable<T>` from `[FromForm]` & friends (niche; not for JSON APIs) | `references/aspnetcore.md` |
| ASP.NET Core MVC binders: `services.AddStrongTypes()` for `NonEmptyEnumerable<T>` from `[FromForm]` & friends (niche; not for JSON APIs) | `references/aspnetcore.md` |

## Design philosophy — picking the right wrapper

Expand Down
91 changes: 39 additions & 52 deletions Skill/references/aspnetcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` or `NonEmptyEnumerable<T>` from
`[FromForm]` (the primary use), `[FromQuery]`, `[FromHeader]`, or
`[FromRoute]`.
- A controller action takes `NonEmptyEnumerable<T>` 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
Expand All @@ -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<T>` 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<T>?` 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<T>?` binds: `null` = field omitted, `None` = field present
but empty, `Some(value)` = field set to a parsed value.

```csharp
public sealed record ProfilePatch(Maybe<NonEmptyString>? DisplayName);

[HttpPost("profile")]
public IActionResult Patch([FromForm] ProfilePatch patch) { ... }
```

2. **`NonEmptyEnumerable<T>` 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<NonEmptyString> tags) { ... }
```
```csharp
[HttpPost("articles")]
public IActionResult Create(
[FromForm] NonEmptyEnumerable<NonEmptyString> tags) { ... }
```

Every other strong-type wrapper (`NonEmptyString`, `Email`, `Digit`,
`Positive<T>`, `Negative<T>`, `NonNegative<T>`, `NonPositive<T>`)
Expand All @@ -50,18 +33,22 @@ they implement `IParsable<TSelf>` and ASP.NET Core's built-in

## When you DO NOT need it

- **JSON APIs.** `[FromBody]` round-trips both `Maybe<T>` and
`NonEmptyEnumerable<T>` (and arbitrary nesting of them) via the
JSON converters in the core package. No extra reference required.
- **JSON APIs.** `[FromBody]` round-trips `NonEmptyEnumerable<T>`
(and arbitrary nesting with `Maybe<T>`) 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<int>`, ASP.NET Core's built-in `TryParse`
binder already handles it — `IParsable<TSelf>` is enough.
- **Three-state semantics on a query string or header.** The binder
*will* bind `Maybe<T>` 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<T>` is not supported on non-body slots

`Maybe<T>` is **intentionally unsupported** on `[FromQuery]`,
`[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<T>?` from
`[FromBody]` when real three-state PATCH semantics are needed; the
JSON converter handles it.

## Wiring

Expand All @@ -72,13 +59,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<T>`
`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<T>`. Element types
The binder parses each raw string via `IParsable<T>`. Element types
that work:

- BCL primitives that implement `IParsable<T>` — `int`, `long`,
Expand All @@ -88,18 +76,17 @@ that work:
`ValidationProblemDetails`, with the failing field named in
`ModelState`.

**Not supported** — wrapper-of-wrapper combinations on non-body
sources: `NonEmptyEnumerable<Maybe<T>>`,
`Maybe<NonEmptyEnumerable<T>>`,
`NonEmptyEnumerable<NonEmptyEnumerable<T>>`. There's no clean wire
form for them on a query string / header / form. Use `[FromBody]` if
you need that nesting.
**Not supported** —
`NonEmptyEnumerable<NonEmptyEnumerable<T>>` 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<T>` or `NonEmptyEnumerable<T>` 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<T>` 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<T>` and `Maybe<T>` (including
> three-state PATCH).
2 changes: 1 addition & 1 deletion docs/diagrams/package-layout-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/diagrams/package-layout.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ Result<Positive<int>[], string> ParseOrderQuantities(IEnumerable<int> inputs)
| [`Kalicz.StrongTypes.FsCheck`](https://www.nuget.org/packages/Kalicz.StrongTypes.FsCheck/) | FsCheck `Arbitrary<T>` 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<T>` and `NonEmptyEnumerable<T>` 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<T>` 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<T>` 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)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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<T>(Maybe<T>? maybe)
where T : notnull
=> maybe is null ? "missing" : maybe.Value.IsSome ? "some" : "none";

private static string? MaybeValue(Maybe<NonEmptyString>? maybe)
=> maybe is null ? null : maybe.Value.Match<string?>(v => v.Value, () => null);
}

public sealed record BindingProbeFormRequest(
NonEmptyEnumerable<NonEmptyString>? Tags,
NonEmptyEnumerable<Positive<int>>? Counts,
NonEmptyEnumerable<Digit>? Digits,
Maybe<NonEmptyString>? DisplayName);
NonEmptyEnumerable<Digit>? Digits);
78 changes: 0 additions & 78 deletions src/StrongTypes.AspNetCore/MaybeModelBinder.cs

This file was deleted.

Loading