Skip to content

Commit 4b13040

Browse files
committed
Add small opt-in schema helpers
1 parent 11145f2 commit 4b13040

4 files changed

Lines changed: 69 additions & 5 deletions

File tree

TASKS.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,7 @@ Completed rename, parser, bridge, compatibility, JSON Schema, docs, and projecti
1717

1818
- [x] **Task 29:** Split `src/CodecMapper/Library.fs` into explicit dependency-ordered files (`Core.fs`, `Schema.fs`, `Json.fs`, `JsonSchema.fs`, `Xml.fs`, `KeyValue.fs`, and `Yaml.fs`) and updated `CodecMapper.fsproj` to preserve the existing no-behavior-change compilation order.
1919

20-
- [ ] **Task 31: Ergonomics without reflection**
21-
- Keep the runtime DSL explicit and AOT/Fable-safe; do not introduce a second magic authoring path.
22-
- Add small authoring helpers where they make the current DSL read smaller, such as compile aliases and more built-in validated/domain combinators.
23-
- Prefer expanding safe static auto-resolution over making `fieldWith` implicit through runtime metadata.
24-
- Keep `fieldWith` for true schema boundaries such as validated wrappers, imported contracts, and explicit child schemas.
20+
- [x] **Task 31:** Improved explicit authoring ergonomics without adding reflection or a competing magic DSL by shipping compile aliases plus opt-in validated helpers such as `Schema.nonEmptyString`, `Schema.trimmedString`, `Schema.positiveInt`, and `Schema.nonEmptyList`, with matching docs and regression coverage.
2521

2622
- [x] **Task 32:** Added path-aware decode diagnostics across `Json`, `Xml`, `KeyValue`, and `Yaml`, including missing-field paths, collection indices/items, and `Schema.tryMap` validation context, with matching regression coverage in the unit test suite.
2723

docs/GETTING_STARTED.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ let userIdSchema =
280280

281281
Read `Schema.tryMap` as "decode the wire value first, then validate/refine it into a stronger domain type."
282282

283+
For common opt-in boundary rules, `Schema` also exposes small validated helpers:
284+
285+
```fsharp
286+
let userNameSchema = Schema.nonEmptyString
287+
let retryCountSchema = Schema.positiveInt
288+
let tagsSchema = Schema.nonEmptyList Schema.string
289+
let normalizedLabelSchema = Schema.trimmedString
290+
```
291+
292+
These stay explicit authoring choices. They do not weaken the normal `Schema.string`, `Schema.int`, or `Schema.list` defaults.
293+
283294
That schema can then be used inside larger records:
284295

285296
```fsharp
@@ -361,6 +372,7 @@ Still out of scope:
361372
- Prefer `Schema.field` when the type auto-resolves cleanly.
362373
- Use `Schema.fieldWith` when the nested or wrapped type has an explicit schema.
363374
- Use `Schema.tryMap` when decode needs validation.
375+
- Reach for `Schema.nonEmptyString`, `Schema.trimmedString`, `Schema.positiveInt`, and `Schema.nonEmptyList` when those boundary rules are part of the contract.
364376
- Keep schemas in one place so JSON and XML stay aligned.
365377
- Use [How To Export JSON Schema](HOW_TO_EXPORT_JSON_SCHEMA.md) when you need external schema documents.
366378
- Use [C# Attribute Bridge Design](CSHARP_ATTRIBUTE_BRIDGE.md) when you are importing existing C# contracts instead of authoring schemas directly.

src/CodecMapper/Schema.fs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,32 @@ module Schema =
138138
)
139139
)
140140

141+
///
142+
/// Empty strings are often accidental placeholders rather than meaningful
143+
/// values. This helper keeps that validation explicit and reusable.
144+
let nonEmptyString: Schema<string> =
145+
string
146+
|> tryMap
147+
(fun value ->
148+
if System.String.IsNullOrEmpty(value) then
149+
Error "string must not be empty"
150+
else
151+
Ok value)
152+
id
153+
154+
///
155+
/// Some contracts normalize surrounding whitespace at the boundary rather
156+
/// than making every caller remember to trim before encode and after decode.
157+
let trimmedString: Schema<string> =
158+
string |> map (fun value -> value.Trim()) (fun value -> value.Trim())
159+
160+
///
161+
/// Positive identifiers and counters are a common wire-level constraint,
162+
/// and `tryMap` keeps that rule opt-in rather than global.
163+
let positiveInt: Schema<int> =
164+
int
165+
|> tryMap (fun value -> if value > 0 then Ok value else Error "int must be positive") id
166+
141167
///
142168
/// Narrow numeric types can safely reuse the integer codec as long as the
143169
/// schema enforces range checks on decode.
@@ -246,6 +272,18 @@ module Schema =
246272
/// Builds a schema for an F# list.
247273
let inline list (inner: Schema<'T>) : Schema<'T list> = create (List(inner :> ISchema))
248274

275+
///
276+
/// Some wire contracts require at least one item, but keeping that rule in
277+
/// the schema is still clearer than scattering ad hoc list checks elsewhere.
278+
let inline nonEmptyList (inner: Schema<'T>) : Schema<'T list> =
279+
list inner
280+
|> tryMap
281+
(fun values ->
282+
match values with
283+
| [] -> Error "list must contain at least one item"
284+
| _ -> Ok values)
285+
id
286+
249287
/// Builds a schema for an array.
250288
let inline array (inner: Schema<'T>) : Schema<'T[]> = create (Array(inner :> ISchema))
251289

tests/CodecMapper.Tests/CustomizationTests.fs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,21 @@ let ``Smart-constructor wrapper rejects invalid decoded values`` () =
4242

4343
expectFailure "XML decode error at $/id: Validation failed: UserId must be positive" (fun () ->
4444
Xml.deserialize xmlCodec "<account><id>0</id><name>Ada</name></account>")
45+
46+
[<Fact>]
47+
let ``Opt-in validated helpers reject invalid scalar values`` () =
48+
expectFailure "Validation failed: string must not be empty" (fun () ->
49+
Json.deserialize (Json.compile Schema.nonEmptyString) "\"\"")
50+
51+
expectFailure "Validation failed: int must be positive" (fun () ->
52+
Json.deserialize (Json.compile Schema.positiveInt) "0")
53+
54+
expectFailure "Validation failed: list must contain at least one item" (fun () ->
55+
Json.deserialize (Json.compile (Schema.nonEmptyList Schema.int)) "[]")
56+
57+
[<Fact>]
58+
let ``Trimmed string helper normalizes on encode and decode`` () =
59+
let codec = Json.compile Schema.trimmedString
60+
61+
test <@ Json.deserialize codec "\" Ada \"" = "Ada" @>
62+
test <@ Json.serialize codec " Ada " = "\"Ada\"" @>

0 commit comments

Comments
 (0)