Skip to content

Commit d50dfec

Browse files
committed
Improve decode diagnostics across formats
1 parent 7f35354 commit d50dfec

11 files changed

Lines changed: 354 additions & 40 deletions

File tree

TASKS.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@ Completed rename, parser, bridge, compatibility, JSON Schema, docs, and projecti
2323
- Prefer expanding safe static auto-resolution over making `fieldWith` implicit through runtime metadata.
2424
- Keep `fieldWith` for true schema boundaries such as validated wrappers, imported contracts, and explicit child schemas.
2525

26-
- [ ] **Task 32: Improve decode diagnostics**
27-
- Make decode failures consistently report path, expected shape, actual token/value, and validation/wrapper context.
28-
- Align JSON, XML, KeyValue, YAML, and imported JSON Schema errors around the same mental model.
29-
- Treat debuggability as a core ergonomics feature for the explicit schema approach.
26+
- [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.
3027

3128
- [ ] **Task 33: Ship canonical pattern docs**
3229
- Add copy-pasteable reference patterns for basic records, nested records, validated wrappers, versioned contracts, config contracts, JSON Schema import, and the C# bridge.
@@ -44,3 +41,14 @@ Completed rename, parser, bridge, compatibility, JSON Schema, docs, and projecti
4441
- Add a verification step so docs generation catches broken asset references before publishing.
4542

4643
- [x] **Task 30:** Fixed the docs-site asset root by aligning `PackageProjectUrl` with the GitHub Pages URL instead of the repo URL, and hardened `scripts/generate-api-docs.sh` to clear stale `fsdocs` cache, build the doc assemblies first, and fail if generated output points theme/search assets at `github.com/adz/CodecMapper/...`.
44+
45+
- [ ] **Task 35: Add property-based test coverage for codec laws**
46+
- Add property-based tests for the real F# implementation rather than a sidecar model, since the main risks here are semantic drift, parser edge cases, and encode/decode symmetry across many inputs.
47+
- Start with fixed representative schemas that already exist in the repo, then generate values for them: primitives, nested records, options, validated wrappers, collections, and numeric boundary cases.
48+
- Make round-trip laws the first goal: `deserialize (serialize x) = x` for JSON and XML wherever the format supports the same shape.
49+
- Add parser robustness properties for malformed inputs so failures stay deterministic and do not hang, over-consume input, or silently accept trailing content.
50+
- Add format-symmetry properties where appropriate so one authored schema preserves the same semantic value across JSON and XML.
51+
- Prefer `FsCheck.Xunit` in `tests/CodecMapper.Tests` so the property layer stays close to the existing xUnit and `Swensen.Unquote` test style.
52+
- Keep the current example-based parser tests for exact regressions and expected error text; property tests should expand coverage, not replace those focused cases.
53+
- Avoid starting with arbitrary recursive schema generation. The first iteration should optimize for debuggable failures and useful shrinking, not maximal generator cleverness.
54+
- Treat generator design as part of the contract: keep generated values inside the supported deterministic surface instead of exploring JSON/XML features that the library intentionally leaves out.

src/CodecMapper/Json.fs

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,59 @@ module Json =
2525
Decode: Decoder<'T>
2626
}
2727

28+
type internal DecodePathSegment =
29+
| Property of string
30+
| Index of int
31+
32+
type internal JsonDecodeException(path: DecodePathSegment list, detail: string, ?inner: exn) =
33+
inherit System.Exception(detail, defaultArg inner null)
34+
35+
member _.Path = path
36+
member _.Detail = detail
37+
38+
override _.Message =
39+
let renderPath segments =
40+
let builder = StringBuilder("$")
41+
42+
for segment in segments do
43+
match segment with
44+
| Property name ->
45+
builder.Append('.') |> ignore
46+
builder.Append(name) |> ignore
47+
| Index index ->
48+
builder.Append('[') |> ignore
49+
builder.Append(index) |> ignore
50+
builder.Append(']') |> ignore
51+
52+
builder.ToString()
53+
54+
sprintf "JSON decode error at %s: %s" (renderPath path) detail
55+
2856
module internal Runtime =
57+
let private asDecodeException detail path inner =
58+
JsonDecodeException(path, detail, inner) :> exn
59+
60+
let decodeFailure detail =
61+
raise (asDecodeException detail [] null)
62+
63+
let private prependPath segment (ex: exn) =
64+
match ex with
65+
| :? JsonDecodeException as decodeEx -> asDecodeException decodeEx.Detail (segment :: decodeEx.Path) ex
66+
| _ -> asDecodeException ex.Message [ segment ] ex
67+
68+
let withPath segment f =
69+
try
70+
f ()
71+
with ex ->
72+
raise (prependPath segment ex)
73+
74+
let withValidationContext f =
75+
try
76+
f ()
77+
with
78+
| :? JsonDecodeException -> reraise ()
79+
| ex -> raise (asDecodeException ("Validation failed: " + ex.Message) [] ex)
80+
2981
let inline skipWhitespace (src: JsonSource) =
3082
let mutable i = src.Offset
3183
let data = src.Data
@@ -878,10 +930,13 @@ module Json =
878930
if valSrc.Data = null then
879931
match f.Codec.MissingValue with
880932
| Some value -> value
881-
| None -> failwithf "Missing required key: %s" f.Name
933+
| None ->
934+
Runtime.withPath (Property f.Name) (fun () ->
935+
Runtime.decodeFailure (sprintf "Missing required key '%s'" f.Name))
882936
else
883-
let struct (v, _) = f.Codec.Decode valSrc
884-
v)
937+
Runtime.withPath (Property f.Name) (fun () ->
938+
let struct (v, _) = f.Codec.Decode valSrc
939+
v))
885940

886941
struct (ctor args, current)
887942

@@ -922,10 +977,15 @@ module Json =
922977
continueLoop <- false
923978
src <- src.Advance(1)
924979

980+
let mutable index = 0
981+
925982
while continueLoop do
926-
let struct (item, nextSrc) = innerCodec.Decode src
983+
let struct (item, nextSrc) =
984+
Runtime.withPath (Index index) (fun () -> innerCodec.Decode src)
985+
927986
results <- item :: results
928987
src <- Runtime.skipWhitespace nextSrc
988+
index <- index + 1
929989

930990
if src.Offset < src.Data.Length && src.Data[src.Offset] = 44uy then
931991
src <- src.Advance(1)
@@ -973,10 +1033,15 @@ module Json =
9731033
continueLoop <- false
9741034
src <- src.Advance(1)
9751035

1036+
let mutable index = 0
1037+
9761038
while continueLoop do
977-
let struct (item, nextSrc) = innerCodec.Decode src
1039+
let struct (item, nextSrc) =
1040+
Runtime.withPath (Index index) (fun () -> innerCodec.Decode src)
1041+
9781042
results.Add(item)
9791043
src <- Runtime.skipWhitespace nextSrc
1044+
index <- index + 1
9801045

9811046
if src.Offset < src.Data.Length && src.Data[src.Offset] = 44uy then
9821047
src <- src.Advance(1)
@@ -1007,7 +1072,10 @@ module Json =
10071072

10081073
{
10091074
Encode = (fun w v -> innerCodec.Encode w (unwrapFunc v))
1010-
Decode = (fun src -> let struct (v, s) = innerCodec.Decode src in struct (wrap v, s))
1075+
Decode =
1076+
(fun src ->
1077+
let struct (v, s) = innerCodec.Decode src
1078+
struct (Runtime.withValidationContext (fun () -> wrap v), s))
10111079
MissingValue = innerCodec.MissingValue |> Option.map wrap
10121080
}
10131081
| _ -> failwithf "Unsupported schema type: %O" schema.Definition
@@ -1018,7 +1086,15 @@ module Json =
10181086

10191087
{
10201088
Encode = (fun w v -> compiled.Encode w (box v))
1021-
Decode = (fun src -> let struct (v, s) = compiled.Decode src in struct (unbox v, s))
1089+
Decode =
1090+
(fun src ->
1091+
try
1092+
let struct (v, s) = compiled.Decode src
1093+
struct (unbox v, s)
1094+
with ex ->
1095+
match ex with
1096+
| :? JsonDecodeException -> raise ex
1097+
| _ -> Runtime.decodeFailure ex.Message)
10221098
}
10231099

10241100
///
@@ -1042,7 +1118,7 @@ module Json =
10421118
let rest = Runtime.skipWhitespace rest
10431119

10441120
if rest.Offset <> bytes.Length then
1045-
failwith "Trailing content after top-level JSON value"
1121+
Runtime.decodeFailure "Trailing content after top-level JSON value"
10461122

10471123
v
10481124

@@ -1052,6 +1128,6 @@ module Json =
10521128
let rest = Runtime.skipWhitespace rest
10531129

10541130
if rest.Offset <> bytes.Length then
1055-
failwith "Trailing content after top-level JSON value"
1131+
Runtime.decodeFailure "Trailing content after top-level JSON value"
10561132

10571133
v

src/CodecMapper/KeyValue.fs

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,47 @@ module KeyValue =
3232
Decode: Map<string, string> -> 'T
3333
}
3434

35+
type internal KeyValueDecodeException(path: string list, detail: string, ?inner: exn) =
36+
inherit System.Exception(detail, defaultArg inner null)
37+
38+
member _.Path = path
39+
member _.Detail = detail
40+
41+
override _.Message =
42+
let renderedPath =
43+
match path with
44+
| [] -> "$"
45+
| _ -> "$." + String.concat "." path
46+
47+
sprintf "KeyValue decode error at %s: %s" renderedPath detail
48+
3549
type CompiledCodec = {
3650
Encode: string list -> obj -> (string * string) list
3751
Decode: string list -> Map<string, string> -> obj option
3852
MissingValue: obj option
3953
}
4054

55+
let private asDecodeException detail path inner =
56+
KeyValueDecodeException(path, detail, inner) :> exn
57+
58+
let private decodeFailure path detail =
59+
raise (asDecodeException detail path null)
60+
61+
let private withPath path f =
62+
try
63+
f ()
64+
with ex ->
65+
match ex with
66+
| :? KeyValueDecodeException as decodeEx -> raise (asDecodeException decodeEx.Detail path ex)
67+
| _ -> raise (asDecodeException ex.Message path ex)
68+
69+
let private withValidationContext path f =
70+
try
71+
f ()
72+
with
73+
| :? KeyValueDecodeException -> reraise ()
74+
| ex -> raise (asDecodeException ("Validation failed: " + ex.Message) path ex)
75+
4176
let private keyName (options: Options) (segments: string list) =
4277
match segments with
4378
| [] -> failwith "KeyValue paths must contain at least one segment"
@@ -106,7 +141,10 @@ module KeyValue =
106141
match schema.Definition with
107142
| Primitive targetType -> {
108143
Encode = (fun path value -> [ keyName options path, formatPrimitive targetType value ])
109-
Decode = (fun path values -> tryFindValue options path values |> Option.map (parsePrimitive targetType))
144+
Decode =
145+
(fun path values ->
146+
tryFindValue options path values
147+
|> Option.map (fun value -> withPath path (fun () -> parsePrimitive targetType value)))
110148
MissingValue = None
111149
}
112150
| Record(_, fields, ctor) ->
@@ -143,9 +181,11 @@ module KeyValue =
143181
match field.Codec.MissingValue with
144182
| Some value -> value
145183
| None ->
146-
failwithf
147-
"Missing required key: %s"
148-
(keyName options (path @ [ field.Field.Name ])))
184+
let fieldPath = path @ [ field.Field.Name ]
185+
186+
decodeFailure
187+
fieldPath
188+
(sprintf "Missing required key '%s'" (keyName options fieldPath)))
149189

150190
Some(ctor args))
151191
MissingValue = None
@@ -231,7 +271,10 @@ module KeyValue =
231271

232272
{
233273
Encode = (fun path value -> innerCodec.Encode path (unwrap value))
234-
Decode = (fun path values -> innerCodec.Decode path values |> Option.map wrap)
274+
Decode =
275+
(fun path values ->
276+
innerCodec.Decode path values
277+
|> Option.map (fun value -> withValidationContext path (fun () -> wrap value)))
235278
MissingValue = innerCodec.MissingValue |> Option.map wrap
236279
}
237280
| List _
@@ -247,9 +290,14 @@ module KeyValue =
247290
Encode = (fun value -> compiled.Encode [] (box value) |> Map.ofList)
248291
Decode =
249292
(fun values ->
250-
match compiled.Decode [] values with
251-
| Some value -> unbox value
252-
| None -> failwith "KeyValue payload did not contain any decodable fields")
293+
try
294+
match compiled.Decode [] values with
295+
| Some value -> unbox value
296+
| None -> decodeFailure [] "Payload did not contain any decodable fields"
297+
with ex ->
298+
match ex with
299+
| :? KeyValueDecodeException -> raise ex
300+
| _ -> decodeFailure [] ex.Message)
253301
}
254302

255303
/// Compiles a schema into a reusable flat key/value codec using dotted keys.

0 commit comments

Comments
 (0)