From 70b1ba9b79c4ed941d5f8130f1406d70ce4d0ef0 Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Tue, 15 Sep 2026 09:19:50 +0100 Subject: [PATCH 1/2] fix(generate): decode the scalar encoding blueprints' own UI writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A blueprint component configuration is validated on write and then served back verbatim: the service deserialises it into typed models — Jackson coerces "5" to 5 and the declared minimum and maximum are still enforced — but it does not re-serialise from those models on read, so whatever JSON scalar encoding the writer used is what every later read returns. The Jamf Pro web UI writes these as JSON strings, so a blueprint built there answers {"Value": "5"} where the spec declares an integer. Component.Configuration is json.RawMessage, so no SDK method decodes a configuration and the consumer does. That is why this shipped with every SDK test passing and surfaced downstream as terraform-provider-jamfplatform#431: "cannot unmarshal string into … MajorPeriodInDays.Value of type int", with Go's decoder stopping at the first fault and the whole component dropped from state, so terraform plan -generate-config-out emitted a resource missing a component the blueprint has. Fixed at the generator with a new spec-level config key, lenientScalarRoots, naming the Component union. lenientScalarSeed walks the schema graph from there — through the oneOf, each variant's configuration $ref and everything below — so a component added upstream inherits the tolerance with no config change; lenientScalarTypes closes that seed over the emitted types' own field references and selects the number and boolean fields, emitting 43 tolerant UnmarshalJSON methods into blueprints/lenient_scalars.go. Zero change to any field type, signature, marshalled body or api/*.json: the tolerance is in the decode, not in the surface a consumer programs against, so nothing downstream recompiles and the SDK keeps writing the spec's own encoding. CI parity re-checked through the api/ fallback and the tree is identical. Four properties of the emitted decoder are load-bearing. The strict decode runs first, so a conforming body costs one error check and a nested value is already fixed by its own type's method — which is why each type describes only its own keys. Only a JSON string is rewritten, and only when the text is a valid JSON scalar of the declared kind: json.Valid plus a leading-byte check, so "abc" still fails and the forms Go's parsers accept but JSON does not (Inf, NaN, hex floats, a leading +, 01) are refused. The number branch re-emits the text verbatim, so a value wider than float64 keeps its digits. And namedStructError puts the real type name back into a failure, since each decoder decodes into a local type named lenient to shed the method. Wire-verified on eu under environment scope, with a bogus path in the same namespace at 403 as the control: a UI-built blueprint returns "5" for all four Deferrals periods while the same config written through the SDK's types on the same tenant returns 5, and quoted booleans and a quoted required version echo the same way, so this is neither confined to Value nor to integers nor to one component. The store does validate what it echoes — "abc" is 400 INPUT_MISMATCH and 500 is 400 MAX in both encodings — which is what lets the coercion be this narrow. Self-expiry is one-sided: generation fails when a root names no declared schema or reaches no scalar, but nothing in a spec says how a store serialises, so TestAcceptance_Blueprint_UIWrittenScalarsDecode carries the other half. It asserts the quoted form still arrives and fails the day the service starts re-serialising, which is when the config entry and the 43 decoders can go. Confirmed it reproduces #431's exact error with the generated file removed, and the whole blueprint acceptance lane passes. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 21 + docs/STYLE.md | 75 ++ docs/WIRE-FACTS.md | 101 ++ jamfplatform/acc_blueprint_test.go | 146 +++ jamfplatform/blueprints/lenient_scalars.go | 906 ++++++++++++++++++ .../blueprints/lenient_scalars_test.go | 240 +++++ tools/generate/config.go | 35 + tools/generate/config.json | 3 + tools/generate/emit.go | 41 +- tools/generate/lenient.go | 647 +++++++++++++ tools/generate/lenient_test.go | 211 ++++ 11 files changed, 2425 insertions(+), 1 deletion(-) create mode 100644 jamfplatform/blueprints/lenient_scalars.go create mode 100644 jamfplatform/blueprints/lenient_scalars_test.go create mode 100644 tools/generate/lenient.go create mode 100644 tools/generate/lenient_test.go diff --git a/CLAUDE.md b/CLAUDE.md index d4aa8712..ef20d474 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1826,6 +1826,27 @@ formatting is inert to the generator, and bundle diffs become exact. the opposite call from account-sso above, and the difference is that here the tag lives *inside* each variant where the wire carries it. Mechanism and the full refusal list: [docs/STYLE.md](docs/STYLE.md#schema-handling). +- **A store that echoes its writer's JSON is a decode problem, not a spec + problem — fix it in the decode and leave the field types alone.** blueprints + validates a component `configuration` on write and then serves it back + verbatim, so the Jamf Pro UI's habit of writing `{"Value": "5"}` where the + spec declares an integer is a permanent property of every blueprint built + there. Because `Component.Configuration` is `json.RawMessage`, no SDK method + decodes a configuration and the **consumer** does — which is why this + surfaced as `terraform-provider-jamfplatform#431` ("cannot unmarshal string + into … MajorPeriodInDays.Value of type int", the whole component dropped from + state) with every SDK test passing. `config.lenientScalarRoots` names the + `Component` union and the generator emits 43 tolerant `UnmarshalJSON` methods + across its subtree: **zero change to any field type, signature, marshalled + body or `api/*.json`**, so nothing downstream recompiles and the SDK keeps + writing the spec's own encoding. Do **not** reach for this for a spec/wire + *type* disagreement — that is `fieldTypeOverrides` or a report upstream. The + test is `TestAcceptance_Blueprint_UIWrittenScalarsDecode`, and it asserts the + quoted form still arrives, so it fails the day the service starts + re-serialising from its own model and the whole mechanism can be deleted. + Mechanism: [docs/STYLE.md](docs/STYLE.md#lenient-scalar-decoding); wire + evidence: + [WIRE-FACTS.md](docs/WIRE-FACTS.md#a-component-configuration-is-stored-as-its-writer-sent-it-and-the-ui-writes-numbers-as-strings-2026-09-15). - **A shared schema's optional scalars can change pointer-ness with no diff in their own schema**, because `needsPtr` follows request/response reachability — `SmartGroupCriteria`'s paren fields went `*bool` → `bool` at v1942 that way. diff --git a/docs/STYLE.md b/docs/STYLE.md index 156d3169..aff4f1bc 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -125,6 +125,7 @@ deliberately kept three of them for exactly that reason. | `scopeTypes` | 3 | spec → the scope kinds its operations accept (`tenant`, `environment`, `organization`), overriding a published `x-scope-types` that understates what the gateway serves. Carried to each method's `Scopes` in the Privileges registry, never into `api/`, with `ScopesSource` recording that the value is a config correction rather than the spec's own claim. Self-expiring in both directions: generation fails once the spec declares the same set, and also once the spec declares anything the override omits — an override widens an understated spec, so a spec that has moved past it is about to lose a declared scope, which the equality check alone cannot see. The account trio is the only user: no account spec declares the extension at all. `securitycloud-devices` was the second until 2026-09-04, when its hold lifted and the entry self-expired | | `schemaPatches` | 3 | schema → dotted property path → raw OpenAPI 3 Schema. Adds or replaces at that path | | `requiredPrivileges` | 3 | `"METHOD /path"` → GA capability permissions, for an operation the **published spec declares none for** but an authoritative out-of-band source does. Carried straight to the generated registry with `Source: "gateway-policy"`, never into the spec document, so `api/` stays faithful to upstream. **Fails generation if the operation now declares `x-required-privileges`** — that means upstream published them and the entry must go. All three users are the `account` specs; see [Required privileges](#required-privileges) | +| `lenientScalarRoots` | 1 | component schema names whose **reachable subtree** decodes leniently: every number and boolean under them also accepts a JSON string carrying the same value. Emitted as one `UnmarshalJSON` per affected type in `lenient_scalars.go`; field types, marshalling and `api/` are untouched. For a store that serves back the JSON its *writer* sent rather than re-serialising from its own model — blueprints is that store and `Component` is the only user, covering 43 types. Self-expiring on the spec side only: generation fails when a root names no declared schema, and when a root's subtree reaches no scalar. It cannot expire on the server being fixed, so the acceptance test carries that half. See [Lenient scalar decoding](#lenient-scalar-decoding) | | `enumAdditions` | 1 | schema name → wire values to append to its `enum`, for a value the server produces or accepts that the spec omits. Applied with the other schema patches, so the value reaches `api/` too. **Panics when the value is already declared**, when the schema declares no enum, or when the schema is missing — a duplicated enum member would emit two identical constants and compile, so nothing else would ever notice. One user: `Region` gaining `RAMP` | | `emitNullForOptional` | 2 | schema names whose optional pointer fields must marshal as explicit `null` when nil rather than being omitted. For servers that distinguish "omitted" (keep) from "present and null" (clear). Accepts snake_case or PascalCase; JSON marshalling only | | `propertyRenames` | 3 | schema → dotted path → new key. Repairs a spec key that doesn't match the wire, which otherwise decodes silently to nothing. **Panics on a missing path**, so it self-expires the day upstream adopts the wire's name. Carries the property's `required` entry with it, and rewrites the key inside the spec's own **examples** too — otherwise `api/*.json` publishes a schema declaring one name beside an example showing the other. The example rewrite is shape-guarded: an object is touched only when it carries the old key *and* every key it has is a property of the target schema, because a property name is not unique across a spec (`categories`, `users` and `security_name` are all renamed somewhere in Classic). `DomainAllocationConnection.authRegion` → `region` is the worked example, and the only one that matches an example today | @@ -802,6 +803,80 @@ item always uses pointer fields. --- +### Lenient scalar decoding + +`lenientScalarRoots` exists for one wire fact and should not be reached for +anything else: **a store that serves back the JSON its writer sent, instead of +re-serialising from its own model.** blueprints is that store. A component +`configuration` is validated on write — the service deserialises it into typed +Java models, so Jackson coerces `"5"` to `5` and the declared `minimum` and +`maximum` are still enforced — and then echoed verbatim on every later read. The +Jamf Pro web UI writes these scalars as JSON strings, so a blueprint built there +answers `{"Value": "5"}` where the spec declares an integer. Evidence, including +the four create-then-read probes and the two refusals that prove the store +validates: +[WIRE-FACTS.md](WIRE-FACTS.md#a-component-configuration-is-stored-as-its-writer-sent-it-and-the-ui-writes-numbers-as-strings-2026-09-15). + +**The tolerance is per *type*, not per operation, because the SDK never decodes +a configuration.** `Component.Configuration` is `json.RawMessage` +(`fieldTypeOverrides`), so the transport hands the bytes through and the +consumer unmarshals them into the typed configuration itself — which is how +`terraform-provider-jamfplatform` does it, and why the decode failure landed +there rather than in any SDK method. A lenient decode at the transport, or on +an opted-in operation, would never have seen the body. An `UnmarshalJSON` on +the leaf type travels with the type wherever it is used, including in a +consumer's own `json.Unmarshal`. + +**The root is the union, not the twelve configurations under it.** +`lenientScalarSeed` walks the schema graph from `Component` — through its +`oneOf`, each variant's `configuration` `$ref`, and everything below — so a +component added upstream inherits the tolerance with no config change. The key +takes roots rather than type names for exactly that reason: the covered set is +derived and cannot drift from the spec. + +**Two passes, because the seed is schema names and the emission is Go types.** +`lenientScalarTypes` closes the seed over the emitted types' own field +references, so a hoisted inline object or a type the seed named under a +different spelling still comes in; then it selects, per reached type, the +fields whose Go type is a number or a boolean (a numeric-enum alias counts, a +string enum does not, and slices and maps are excluded — a struct leaf is +decoded by its own method). A type that carries the closure but declares no +scalar of its own — `Deferrals`, whose four fields are all +`*OptionalPeriodInDays` — correctly gets no decoder. + +Four properties of the emitted decoder are load-bearing: + +- **The strict decode runs first.** A conforming body costs one error check. + The rewrite only happens on failure, and a nested value has already been + fixed by its own type's method during that first attempt — which is why each + type only describes its own keys and no walk of the tree is needed. +- **Only a JSON string is rewritten, and only into the declared kind.** + `jsonScalarLiteral` re-emits the text verbatim rather than parsing and + reformatting it, so a value wider than `float64` keeps its digits; `json.Valid` + plus a leading-byte check is what decides it is a JSON number token, which + rules out the forms Go's own parsers accept and JSON does not (`Inf`, `NaN`, + hex floats, a leading `+`, `01`). So `"abc"` for an integer still fails the + decode — it must, since decoding it to zero would turn a visible fault into a + silently wrong configuration, and the server refused it on the way in anyway. +- **Marshalling is untouched.** The SDK keeps sending the numbers and booleans + the spec declares; `api/*.json` is byte-identical. The tolerance is in the + decode, not in the surface a consumer programs against — no field type moved, + so nothing downstream recompiles. +- **The error still names the real type.** Each decoder decodes into a local + type named `lenient` to shed the method and avoid recursing, and + `encoding/json` reports the Go type it was decoding, so `namedStructError` + puts the real name back. Without it a genuine failure reads `Go struct field + lenient.Value`, which names nothing a caller can look up. + +**Self-expiry is one-sided and the acceptance test carries the other half.** +Generation fails when a root names no declared schema (a rename or withdrawal +upstream, which would otherwise drop the tolerance from a whole subtree +silently) and when a root's subtree reaches no scalar. Nothing in a spec says +how a store serialises, so no config mechanism can see the server being fixed: +`TestAcceptance_Blueprint_UIWrittenScalarsDecode` asserts the quoted form still +arrives, and fails the day it stops — which is when the config entry and the 43 +generated decoders can go. + ## A shared schema's optionality follows its request/response reachability `needsPtr` in `schemaToGoType` (`tools/generate/schema.go`) includes diff --git a/docs/WIRE-FACTS.md b/docs/WIRE-FACTS.md index 8c83c5e8..c4f8a778 100644 --- a/docs/WIRE-FACTS.md +++ b/docs/WIRE-FACTS.md @@ -1754,6 +1754,107 @@ no enum, so the vocabulary is wire-only. ## Blueprints (`blueprints`) — environment scope +### A component configuration is stored as its writer sent it, and the UI writes numbers as strings (2026-09-15) + +Probed under environment scope on `eu`, with `GET /blueprints/v1/blueprints/{id}` +at 200 and a bogus path in the same namespace returning `403 BAD_PERMISSIONS` as +the control in the same invocation. + +**A blueprint component `configuration` is validated on write and then served +back verbatim.** The service deserialises the document into its own typed +models — so Jackson's string-to-number coercion applies, and the declared +bounds are still enforced — but it does not re-serialise from those models on +read. Whatever JSON scalar encoding the writer used is what every later read +returns. + +**The Jamf Pro web UI writes these scalars as JSON strings.** `` +was built in the UI and `GET` returns: + +```json +{"identifier":"com.jamf.ddm.software-update-settings","configuration":{ + "Beta":null, + "Deferrals":{ + "MajorPeriodInDays":{"Value":"5","Included":true}, + "MinorPeriodInDays":{"Value":"2","Included":true}, + "SystemPeriodInDays":{"Value":"6","Included":true}, + "CombinedPeriodInDays":{"Value":"1","Included":true}}, + "Notifications":{"Enabled":false,"Included":false}, + …}} +``` + +`OptionalPeriodInDays.Value` is `type: integer, format: int32, minimum: 1, +maximum: 90`. The same tenant, same environment, written through the SDK's own +generated types (``), reads back `{"Value":5,"Included":true}` +— so this is the writer's encoding surviving, not a property of the field. + +**That is what broke the Terraform provider.** `Component.Configuration` is +`json.RawMessage`, so the transport never decodes a configuration and the +consumer does: `FromRawConfiguration` unmarshals the raw bytes into +`blueprints.SoftwareUpdateSettingsConfiguration`, and a strict decode fails +with `json: cannot unmarshal string into Go struct field +SoftwareUpdateSettingsConfiguration.Deferrals.MajorPeriodInDays.Value of type +int`. Go's decoder stops at the first fault, so the whole component was dropped +from state and `terraform plan -generate-config-out` emitted a resource missing +a component the blueprint has — `terraform-provider-jamfplatform#431`. The +software-update *enforcement* component in the same blueprint decoded fine +because its scalars happened to be written bare. + +**Four probes establish the shape of it**, each a create-then-read on this +environment: + +| written | read back | note | +|---|---|---| +| `"Value": 5` (integer) | `5` | the SDK's own encoding round-trips | +| `"Value": "5"` | `"5"` | echoed verbatim | +| `"Enabled": "true"`, `"Included": "true"` | `"true"`, `"true"` | quoted booleans are accepted and echoed too | +| `"version": "2"` | `"2"` | a declared-required integer is no different | + +So it is not confined to `Value`, to one component, or to integers. The +`passcode-settings` component behaves identically — `MinimumLength`, +`MaximumFailedAttempts` and `MaximumInactivityInMinutes` all echo the quoted +form — which matters because the same 8 integer `Value` properties live under +Apple's PascalCase DDM payload keys there. + +**The store does validate, and that is what lets the SDK's tolerance be +narrow.** A string that is not a number never reaches a read: + +``` +POST …/blueprints {"Deferrals":{"MajorPeriodInDays":{"Value":"abc","Included":true}}} +→ 400 INPUT_MISMATCH steps[0].components[0].configuration.Deferrals.MajorPeriodInDays.Value + "Cannot deserialize value of type `java.lang.Integer` from String \"abc\": not a valid `java.lang.Integer` value" +``` + +and the bounds are enforced against the coerced value, in both encodings: + +``` +"Value": 500 → 400 MAX steps[0].components[0].configuration.deferrals.majorPeriodInDays.value "must be less than or equal to 90" +"Value": "500" → 400 MAX (identical body) +``` + +Note the field path in the `MAX` errors is lowerCamelCase while the +`INPUT_MISMATCH` one is the wire spelling: deserialisation reports the JSON +path and bean validation reports the Java property path. Neither is a rename. + +**The SDK's fix is a read-side coercion emitted per type**, driven by +`config.lenientScalarRoots` naming the `Component` union — 43 generated +`UnmarshalJSON` methods across the subtree, no change to any field type, +signature or marshalled body. Mechanism: +[STYLE.md](STYLE.md#lenient-scalar-decoding). The generated decoders coerce a +JSON string only when the text is a valid JSON scalar of the declared kind, so +`"abc"` still fails — which costs nothing, the server having refused it on the +way in. + +**Report upstream: a read should serialise from the service's own model.** The +validated value is already in hand at write time; echoing the request document +instead makes every consumer's decoder tolerate its own UI's encoding. It is +also unbounded — any writer's encoding becomes a permanent property of that +blueprint — and a consumer cannot tell a UI-built blueprint from an API-built +one without inspecting raw JSON. + +`TestAcceptance_Blueprint_UIWrittenScalarsDecode` asserts both halves: that the +quoted form still arrives (failing the day the service starts normalising, +which is when the tolerance can be deleted), and that it decodes. + ### Blueprints do not support sites; sites reach the platform as *divisions* (2026-09-11) Probed under environment scope on `eu`, on a tenant that has one Jamf Pro site diff --git a/jamfplatform/acc_blueprint_test.go b/jamfplatform/acc_blueprint_test.go index b9ecd8bb..8de2ae47 100644 --- a/jamfplatform/acc_blueprint_test.go +++ b/jamfplatform/acc_blueprint_test.go @@ -8,6 +8,7 @@ package jamfplatform_test import ( "context" "encoding/json" + "strings" "testing" "time" @@ -660,3 +661,148 @@ func TestAcceptance_Blueprint_TypedComponents(t *testing.T) { }) } } + +// TestAcceptance_Blueprint_UIWrittenScalarsDecode pins the wire fact behind +// config.lenientScalarRoots, and it is deliberately asserted rather than +// logged. +// +// A blueprint component configuration is validated on write and then stored +// and served back verbatim: the service deserialises the document — Jackson +// coerces "5" to 5, and the declared minimum and maximum are still enforced — +// but it does not re-serialise from its own model, so a read returns whatever +// scalar encoding the writer used. The Jamf Pro web UI writes these as JSON +// strings, so a blueprint built there answers {"Value": "5"} where the spec +// declares an integer, and before the generated tolerant decoders a strict +// decode failed on the whole component. That is what cost the Terraform +// provider every software-update-settings component created in the UI +// (terraform-provider-jamfplatform#431). +// +// The raw-body assertion is the part that expires: the day the service starts +// normalising to its own model, the quoted form stops arriving, this fails, +// and config's lenientScalarRoots entry and the 43 generated decoders behind +// it can go. The typed decode either side of it is the regression itself. +func TestAcceptance_Blueprint_UIWrittenScalarsDecode(t *testing.T) { + groupID := requireSmartGroupFixture(t) + c := accEnvClient(t) + ctx := context.Background() + bp := blueprints.New(c) + + // Written the way the UI writes it: every number and every boolean quoted. + // Sent as raw JSON rather than through the generated types on purpose — + // the SDK's own types marshal the spec-compliant form, so this encoding is + // unreachable through them, which is exactly why no generated test can + // cover it. + const uiSoftwareUpdate = `{ + "Deferrals": { + "MajorPeriodInDays": {"Value": "5", "Included": true}, + "MinorPeriodInDays": {"Value": "2", "Included": true}, + "SystemPeriodInDays": {"Value": "6", "Included": true}, + "CombinedPeriodInDays": {"Value": "1", "Included": true} + }, + "Notifications": {"Enabled": "true", "Included": "true"} + }` + const uiPasscode = `{ + "version": "2", + "RequirePasscode": {"Value": true, "Included": true}, + "MinimumLength": {"Value": "8", "Included": true} + }` + + stepName := "UI-written scalars" + steps := []blueprints.BlueprintStep{{ + Name: &stepName, + Components: []blueprints.Component{ + {Identifier: "com.jamf.ddm.software-update-settings", Configuration: json.RawMessage(uiSoftwareUpdate)}, + {Identifier: "com.jamf.ddm.passcode-settings", Configuration: json.RawMessage(uiPasscode)}, + }, + }} + + name := "sdk-acc-ui-scalars-" + runSuffix() + got := createTestBlueprint(t, c, name, groupID, steps) + if len(got.Steps) != 1 || len(got.Steps[0].Components) != 2 { + t.Fatalf("expected one step carrying two components, got %d step(s)", len(got.Steps)) + } + + byIdentifier := make(map[string]json.RawMessage, 2) + for _, comp := range got.Steps[0].Components { + byIdentifier[comp.Identifier] = comp.Configuration + } + + // The server echoed the writer's encoding. When this stops being true the + // tolerance has become dead weight — see the note above. + for identifier, want := range map[string]string{ + "com.jamf.ddm.software-update-settings": `"Value":"5"`, + "com.jamf.ddm.passcode-settings": `"version":"2"`, + } { + raw := string(byIdentifier[identifier]) + if !strings.Contains(strings.ReplaceAll(raw, " ", ""), want) { + t.Errorf("%s: read-back configuration no longer carries %s — the service has started "+ + "re-serialising from its own model, so config.lenientScalarRoots for blueprints and "+ + "the generated decoders behind it can be deleted.\nbody: %s", identifier, want, raw) + } + } + + var swu blueprints.SoftwareUpdateSettingsConfiguration + if err := json.Unmarshal(byIdentifier["com.jamf.ddm.software-update-settings"], &swu); err != nil { + t.Fatalf("decoding a UI-written software-update-settings configuration: %v", err) + } + if swu.Deferrals == nil { + t.Fatal("Deferrals decoded as nil") + } + for label, pair := range map[string]struct { + got *blueprints.OptionalPeriodInDays + want int + }{ + "MajorPeriodInDays": {swu.Deferrals.MajorPeriodInDays, 5}, + "MinorPeriodInDays": {swu.Deferrals.MinorPeriodInDays, 2}, + "SystemPeriodInDays": {swu.Deferrals.SystemPeriodInDays, 6}, + "CombinedPeriodInDays": {swu.Deferrals.CombinedPeriodInDays, 1}, + } { + if pair.got == nil || pair.got.Value == nil { + t.Errorf("%s decoded as nil, want %d", label, pair.want) + continue + } + if *pair.got.Value != pair.want { + t.Errorf("%s = %d, want %d", label, *pair.got.Value, pair.want) + } + } + // The boolean half of the same coercion. The UI has not been observed + // writing a quoted boolean, but the service accepts and echoes one, so a + // third-party writer can produce it and the decoders cover it. + if swu.Notifications == nil || !swu.Notifications.Enabled { + t.Errorf("Notifications.Enabled = %v, want the quoted \"true\" to decode as true", swu.Notifications) + } + + var passcode blueprints.PasscodeSettingsConfiguration + if err := json.Unmarshal(byIdentifier["com.jamf.ddm.passcode-settings"], &passcode); err != nil { + t.Fatalf("decoding a UI-written passcode-settings configuration: %v", err) + } + if passcode.Version != 2 { + t.Errorf("Version = %d, want the quoted \"2\" to decode as 2", passcode.Version) + } + if passcode.MinimumLength == nil || passcode.MinimumLength.Value == nil || *passcode.MinimumLength.Value != 8 { + t.Errorf("MinimumLength = %+v, want 8", passcode.MinimumLength) + } + + // The other half of the wire fact, and the reason the coercion can be + // this narrow: the service does validate the document it stores, so a + // string that is not a number never reaches a later read in the first + // place. Checked with a create that must fail, which leaves nothing + // behind when it does. + desc := "SDK acceptance test — must be refused" + _, err := bp.CreateBlueprint(ctx, &blueprints.CreateBlueprintRequest{ + Name: "sdk-acc-ui-scalars-refused-" + runSuffix(), + Description: &desc, + Scope: blueprints.CreateScope{DeviceGroups: []string{groupID}}, + Steps: []blueprints.BlueprintStep{{ + Name: &stepName, + Components: []blueprints.Component{{ + Identifier: "com.jamf.ddm.software-update-settings", + Configuration: json.RawMessage(`{"Deferrals":{"MajorPeriodInDays":{"Value":"abc","Included":true}}}`), + }}, + }}, + }) + if err == nil { + t.Error("a non-numeric string for an integer property was accepted; the store no longer " + + "validates what it echoes, so a read can now carry a value no consumer can decode") + } +} diff --git a/jamfplatform/blueprints/lenient_scalars.go b/jamfplatform/blueprints/lenient_scalars.go new file mode 100644 index 00000000..0fe1ccea --- /dev/null +++ b/jamfplatform/blueprints/lenient_scalars.go @@ -0,0 +1,906 @@ +// Code generated by tools/generate; DO NOT EDIT. + +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +// Tolerant decoders for the schemas config names in lenientScalarRoots: a +// store that serves back the JSON its writer sent, rather than re-serialising +// from its own model, answers whatever scalar encoding that writer used. +// +// Emitted here rather than in types.go so the field types stay exactly what +// the spec declares — the tolerance is in the decode, not in the surface a +// consumer programs against — and so this file is the one place to read for +// what the coercion does and does not do. + +package blueprints + +import ( + "encoding/json" + "errors" +) + +// jsonScalarKind names the JSON scalar a property is declared as. +type jsonScalarKind uint8 + +const ( + // jsonScalarNumber: the spec declares an integer or a number. + jsonScalarNumber jsonScalarKind = iota + 1 + // jsonScalarBool: the spec declares a boolean. + jsonScalarBool +) + +// unmarshalLenientScalars decodes data into v, accepting a JSON string +// wherever keys declares a number or a boolean. +// +// The strict decode is tried first and the rewrite only happens when it fails, +// so a conforming body costs one extra error check and nothing else. A nested +// value is fixed by its own type's method during that first attempt, which is +// why each type only has to describe its own keys. +// +// Three properties worth relying on. Only the listed keys are considered, so a +// string the spec declares as a string is never touched. Only a JSON string is +// rewritten, and only when the text it carries is a valid JSON scalar of the +// declared kind — so "abc" for an integer still fails the decode rather than +// arriving as zero. And marshalling is untouched: the SDK keeps sending the +// numbers and booleans the spec declares. +func unmarshalLenientScalars(data []byte, v any, keys map[string]jsonScalarKind, name string) error { + err := json.Unmarshal(data, v) + if err == nil { + return nil + } + fixed, rewritten := unquoteJSONScalars(data, keys) + if rewritten { + // The second error is the one to report when it comes: the rewrite + // has handled the encoding the first error described, so what is left + // is a fault the coercion has nothing to do with. + err = json.Unmarshal(fixed, v) + if err == nil { + return nil + } + } + return namedStructError(err, name) +} + +// namedStructError puts the real type name back into a decode error. +// +// Each generated decoder decodes into a local type named "lenient" to shed the +// method and avoid recursing, and encoding/json reports the *Go* type it was +// decoding — so without this a caller reads "json: cannot unmarshal string +// into Go struct field lenient.Value", which names nothing they can look up. +func namedStructError(err error, name string) error { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) && typeErr.Struct == "lenient" { + typeErr.Struct = name + } + return err +} + +// unquoteJSONScalars returns data with every listed key whose value arrived as +// a JSON string rewritten to the bare scalar its kind declares. The second +// return is false when nothing was rewritten, which includes a body that is +// not a JSON object at all — the caller then reports the original error. +func unquoteJSONScalars(data []byte, keys map[string]jsonScalarKind) ([]byte, bool) { + if len(keys) == 0 { + return data, false + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return data, false + } + changed := false + for key, kind := range keys { + raw, present := obj[key] + if !present { + continue + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + // Not a JSON string: already the declared shape, or a shape this + // coercion has nothing to say about. + continue + } + lit, ok := jsonScalarLiteral(s, kind) + if !ok { + continue + } + obj[key] = json.RawMessage(lit) + changed = true + } + if !changed { + return data, false + } + out, err := json.Marshal(obj) + if err != nil { + return data, false + } + return out, true +} + +// jsonScalarLiteral returns s as a bare JSON literal of the given kind, and +// false when it is not one. +// +// The number branch re-emits the text verbatim rather than parsing and +// reformatting it, so a value wider than float64 keeps every digit and a +// decimal keeps its exact spelling; json.Valid plus the leading-byte check is +// what decides it is a JSON number token, which rules out the forms Go's own +// parsers accept and JSON does not (Inf, NaN, hex floats, a leading +). +func jsonScalarLiteral(s string, kind jsonScalarKind) (string, bool) { + switch kind { + case jsonScalarNumber: + if s == "" || !json.Valid([]byte(s)) { + return "", false + } + if c := s[0]; c != '-' && (c < '0' || c > '9') { + return "", false + } + return s, true + case jsonScalarBool: + if s == "true" || s == "false" { + return s, true + } + } + return "", false +} + +// lenientScalarsAcceptCookies names the scalars AcceptCookies declares, for its UnmarshalJSON. +var lenientScalarsAcceptCookies = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AcceptCookies) UnmarshalJSON(data []byte) error { + type lenient AcceptCookies + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAcceptCookies, "AcceptCookies"); err != nil { + return err + } + *s = AcceptCookies(v) + return nil +} + +// lenientScalarsAllowDisablingFraudWarning names the scalars AllowDisablingFraudWarning declares, for its UnmarshalJSON. +var lenientScalarsAllowDisablingFraudWarning = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AllowDisablingFraudWarning) UnmarshalJSON(data []byte) error { + type lenient AllowDisablingFraudWarning + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowDisablingFraudWarning, "AllowDisablingFraudWarning"); err != nil { + return err + } + *s = AllowDisablingFraudWarning(v) + return nil +} + +// lenientScalarsAllowHistoryClearing names the scalars AllowHistoryClearing declares, for its UnmarshalJSON. +var lenientScalarsAllowHistoryClearing = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AllowHistoryClearing) UnmarshalJSON(data []byte) error { + type lenient AllowHistoryClearing + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowHistoryClearing, "AllowHistoryClearing"); err != nil { + return err + } + *s = AllowHistoryClearing(v) + return nil +} + +// lenientScalarsAllowJavaScript names the scalars AllowJavaScript declares, for its UnmarshalJSON. +var lenientScalarsAllowJavaScript = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AllowJavaScript) UnmarshalJSON(data []byte) error { + type lenient AllowJavaScript + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowJavaScript, "AllowJavaScript"); err != nil { + return err + } + *s = AllowJavaScript(v) + return nil +} + +// lenientScalarsAllowPopups names the scalars AllowPopups declares, for its UnmarshalJSON. +var lenientScalarsAllowPopups = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AllowPopups) UnmarshalJSON(data []byte) error { + type lenient AllowPopups + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowPopups, "AllowPopups"); err != nil { + return err + } + *s = AllowPopups(v) + return nil +} + +// lenientScalarsAllowPrivateBrowsing names the scalars AllowPrivateBrowsing declares, for its UnmarshalJSON. +var lenientScalarsAllowPrivateBrowsing = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AllowPrivateBrowsing) UnmarshalJSON(data []byte) error { + type lenient AllowPrivateBrowsing + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowPrivateBrowsing, "AllowPrivateBrowsing"); err != nil { + return err + } + *s = AllowPrivateBrowsing(v) + return nil +} + +// lenientScalarsAllowSummary names the scalars AllowSummary declares, for its UnmarshalJSON. +var lenientScalarsAllowSummary = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AllowSummary) UnmarshalJSON(data []byte) error { + type lenient AllowSummary + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowSummary, "AllowSummary"); err != nil { + return err + } + *s = AllowSummary(v) + return nil +} + +// lenientScalarsAutomaticAction names the scalars AutomaticAction declares, for its UnmarshalJSON. +var lenientScalarsAutomaticAction = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *AutomaticAction) UnmarshalJSON(data []byte) error { + type lenient AutomaticAction + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsAutomaticAction, "AutomaticAction"); err != nil { + return err + } + *s = AutomaticAction(v) + return nil +} + +// lenientScalarsBasicMode names the scalars BasicMode declares, for its UnmarshalJSON. +var lenientScalarsBasicMode = map[string]jsonScalarKind{ + "AddSquareRoot": jsonScalarBool, + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *BasicMode) UnmarshalJSON(data []byte) error { + type lenient BasicMode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsBasicMode, "BasicMode"); err != nil { + return err + } + *s = BasicMode(v) + return nil +} + +// lenientScalarsBeta names the scalars Beta declares, for its UnmarshalJSON. +var lenientScalarsBeta = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *Beta) UnmarshalJSON(data []byte) error { + type lenient Beta + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsBeta, "Beta"); err != nil { + return err + } + *s = Beta(v) + return nil +} + +// lenientScalarsChangeAtNextAuth names the scalars ChangeAtNextAuth declares, for its UnmarshalJSON. +var lenientScalarsChangeAtNextAuth = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *ChangeAtNextAuth) UnmarshalJSON(data []byte) error { + type lenient ChangeAtNextAuth + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsChangeAtNextAuth, "ChangeAtNextAuth"); err != nil { + return err + } + *s = ChangeAtNextAuth(v) + return nil +} + +// lenientScalarsCustomDeclaration names the scalars CustomDeclaration declares, for its UnmarshalJSON. +var lenientScalarsCustomDeclaration = map[string]jsonScalarKind{ + "payloadKey": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *CustomDeclaration) UnmarshalJSON(data []byte) error { + type lenient CustomDeclaration + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsCustomDeclaration, "CustomDeclaration"); err != nil { + return err + } + *s = CustomDeclaration(v) + return nil +} + +// lenientScalarsCustomRegex names the scalars CustomRegex declares, for its UnmarshalJSON. +var lenientScalarsCustomRegex = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *CustomRegex) UnmarshalJSON(data []byte) error { + type lenient CustomRegex + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsCustomRegex, "CustomRegex"); err != nil { + return err + } + *s = CustomRegex(v) + return nil +} + +// lenientScalarsDetailsURL names the scalars DetailsURL declares, for its UnmarshalJSON. +var lenientScalarsDetailsURL = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *DetailsURL) UnmarshalJSON(data []byte) error { + type lenient DetailsURL + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsDetailsURL, "DetailsURL"); err != nil { + return err + } + *s = DetailsURL(v) + return nil +} + +// lenientScalarsDiskManagementSettingsConfiguration names the scalars DiskManagementSettingsConfiguration declares, for its UnmarshalJSON. +var lenientScalarsDiskManagementSettingsConfiguration = map[string]jsonScalarKind{ + "version": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *DiskManagementSettingsConfiguration) UnmarshalJSON(data []byte) error { + type lenient DiskManagementSettingsConfiguration + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsDiskManagementSettingsConfiguration, "DiskManagementSettingsConfiguration"); err != nil { + return err + } + *s = DiskManagementSettingsConfiguration(v) + return nil +} + +// lenientScalarsFailedAttemptsResetInMinutes names the scalars FailedAttemptsResetInMinutes declares, for its UnmarshalJSON. +var lenientScalarsFailedAttemptsResetInMinutes = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *FailedAttemptsResetInMinutes) UnmarshalJSON(data []byte) error { + type lenient FailedAttemptsResetInMinutes + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsFailedAttemptsResetInMinutes, "FailedAttemptsResetInMinutes"); err != nil { + return err + } + *s = FailedAttemptsResetInMinutes(v) + return nil +} + +// lenientScalarsInputModes names the scalars InputModes declares, for its UnmarshalJSON. +var lenientScalarsInputModes = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "RPN": jsonScalarBool, + "UnitConversion": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *InputModes) UnmarshalJSON(data []byte) error { + type lenient InputModes + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsInputModes, "InputModes"); err != nil { + return err + } + *s = InputModes(v) + return nil +} + +// lenientScalarsManagedAppAttributes names the scalars ManagedAppAttributes declares, for its UnmarshalJSON. +var lenientScalarsManagedAppAttributes = map[string]jsonScalarKind{ + "AssociatedDomainsEnableDirectDownloads": jsonScalarBool, + "Hideable": jsonScalarBool, + "Lockable": jsonScalarBool, + "TapToPayScreenLock": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *ManagedAppAttributes) UnmarshalJSON(data []byte) error { + type lenient ManagedAppAttributes + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsManagedAppAttributes, "ManagedAppAttributes"); err != nil { + return err + } + *s = ManagedAppAttributes(v) + return nil +} + +// lenientScalarsManagedAppEntry names the scalars ManagedAppEntry declares, for its UnmarshalJSON. +var lenientScalarsManagedAppEntry = map[string]jsonScalarKind{ + "IncludeInBackup": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *ManagedAppEntry) UnmarshalJSON(data []byte) error { + type lenient ManagedAppEntry + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsManagedAppEntry, "ManagedAppEntry"); err != nil { + return err + } + *s = ManagedAppEntry(v) + return nil +} + +// lenientScalarsMathNotesMode names the scalars MathNotesMode declares, for its UnmarshalJSON. +var lenientScalarsMathNotesMode = map[string]jsonScalarKind{ + "Enabled": jsonScalarBool, + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MathNotesMode) UnmarshalJSON(data []byte) error { + type lenient MathNotesMode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMathNotesMode, "MathNotesMode"); err != nil { + return err + } + *s = MathNotesMode(v) + return nil +} + +// lenientScalarsMaximumFailedAttempts names the scalars MaximumFailedAttempts declares, for its UnmarshalJSON. +var lenientScalarsMaximumFailedAttempts = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MaximumFailedAttempts) UnmarshalJSON(data []byte) error { + type lenient MaximumFailedAttempts + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumFailedAttempts, "MaximumFailedAttempts"); err != nil { + return err + } + *s = MaximumFailedAttempts(v) + return nil +} + +// lenientScalarsMaximumGracePeriodInMinutes names the scalars MaximumGracePeriodInMinutes declares, for its UnmarshalJSON. +var lenientScalarsMaximumGracePeriodInMinutes = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MaximumGracePeriodInMinutes) UnmarshalJSON(data []byte) error { + type lenient MaximumGracePeriodInMinutes + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumGracePeriodInMinutes, "MaximumGracePeriodInMinutes"); err != nil { + return err + } + *s = MaximumGracePeriodInMinutes(v) + return nil +} + +// lenientScalarsMaximumInactivityInMinutes names the scalars MaximumInactivityInMinutes declares, for its UnmarshalJSON. +var lenientScalarsMaximumInactivityInMinutes = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MaximumInactivityInMinutes) UnmarshalJSON(data []byte) error { + type lenient MaximumInactivityInMinutes + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumInactivityInMinutes, "MaximumInactivityInMinutes"); err != nil { + return err + } + *s = MaximumInactivityInMinutes(v) + return nil +} + +// lenientScalarsMaximumPasscodeAgeInDays names the scalars MaximumPasscodeAgeInDays declares, for its UnmarshalJSON. +var lenientScalarsMaximumPasscodeAgeInDays = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MaximumPasscodeAgeInDays) UnmarshalJSON(data []byte) error { + type lenient MaximumPasscodeAgeInDays + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumPasscodeAgeInDays, "MaximumPasscodeAgeInDays"); err != nil { + return err + } + *s = MaximumPasscodeAgeInDays(v) + return nil +} + +// lenientScalarsMinimumComplexCharacters names the scalars MinimumComplexCharacters declares, for its UnmarshalJSON. +var lenientScalarsMinimumComplexCharacters = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MinimumComplexCharacters) UnmarshalJSON(data []byte) error { + type lenient MinimumComplexCharacters + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMinimumComplexCharacters, "MinimumComplexCharacters"); err != nil { + return err + } + *s = MinimumComplexCharacters(v) + return nil +} + +// lenientScalarsMinimumLength names the scalars MinimumLength declares, for its UnmarshalJSON. +var lenientScalarsMinimumLength = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *MinimumLength) UnmarshalJSON(data []byte) error { + type lenient MinimumLength + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsMinimumLength, "MinimumLength"); err != nil { + return err + } + *s = MinimumLength(v) + return nil +} + +// lenientScalarsNewTabStartPage names the scalars NewTabStartPage declares, for its UnmarshalJSON. +var lenientScalarsNewTabStartPage = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *NewTabStartPage) UnmarshalJSON(data []byte) error { + type lenient NewTabStartPage + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsNewTabStartPage, "NewTabStartPage"); err != nil { + return err + } + *s = NewTabStartPage(v) + return nil +} + +// lenientScalarsOptionalPeriodInDays names the scalars OptionalPeriodInDays declares, for its UnmarshalJSON. +var lenientScalarsOptionalPeriodInDays = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *OptionalPeriodInDays) UnmarshalJSON(data []byte) error { + type lenient OptionalPeriodInDays + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsOptionalPeriodInDays, "OptionalPeriodInDays"); err != nil { + return err + } + *s = OptionalPeriodInDays(v) + return nil +} + +// lenientScalarsOptionallyEnabled names the scalars OptionallyEnabled declares, for its UnmarshalJSON. +var lenientScalarsOptionallyEnabled = map[string]jsonScalarKind{ + "Enabled": jsonScalarBool, + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *OptionallyEnabled) UnmarshalJSON(data []byte) error { + type lenient OptionallyEnabled + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsOptionallyEnabled, "OptionallyEnabled"); err != nil { + return err + } + *s = OptionallyEnabled(v) + return nil +} + +// lenientScalarsPasscodeReuseLimit names the scalars PasscodeReuseLimit declares, for its UnmarshalJSON. +var lenientScalarsPasscodeReuseLimit = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *PasscodeReuseLimit) UnmarshalJSON(data []byte) error { + type lenient PasscodeReuseLimit + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsPasscodeReuseLimit, "PasscodeReuseLimit"); err != nil { + return err + } + *s = PasscodeReuseLimit(v) + return nil +} + +// lenientScalarsPasscodeSettingsConfiguration names the scalars PasscodeSettingsConfiguration declares, for its UnmarshalJSON. +var lenientScalarsPasscodeSettingsConfiguration = map[string]jsonScalarKind{ + "version": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *PasscodeSettingsConfiguration) UnmarshalJSON(data []byte) error { + type lenient PasscodeSettingsConfiguration + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsPasscodeSettingsConfiguration, "PasscodeSettingsConfiguration"); err != nil { + return err + } + *s = PasscodeSettingsConfiguration(v) + return nil +} + +// lenientScalarsProgrammerMode names the scalars ProgrammerMode declares, for its UnmarshalJSON. +var lenientScalarsProgrammerMode = map[string]jsonScalarKind{ + "Enabled": jsonScalarBool, + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *ProgrammerMode) UnmarshalJSON(data []byte) error { + type lenient ProgrammerMode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsProgrammerMode, "ProgrammerMode"); err != nil { + return err + } + *s = ProgrammerMode(v) + return nil +} + +// lenientScalarsRecommendedCadence names the scalars RecommendedCadence declares, for its UnmarshalJSON. +var lenientScalarsRecommendedCadence = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *RecommendedCadence) UnmarshalJSON(data []byte) error { + type lenient RecommendedCadence + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsRecommendedCadence, "RecommendedCadence"); err != nil { + return err + } + *s = RecommendedCadence(v) + return nil +} + +// lenientScalarsRequireAlphanumericPasscode names the scalars RequireAlphanumericPasscode declares, for its UnmarshalJSON. +var lenientScalarsRequireAlphanumericPasscode = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *RequireAlphanumericPasscode) UnmarshalJSON(data []byte) error { + type lenient RequireAlphanumericPasscode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsRequireAlphanumericPasscode, "RequireAlphanumericPasscode"); err != nil { + return err + } + *s = RequireAlphanumericPasscode(v) + return nil +} + +// lenientScalarsRequireComplexPasscode names the scalars RequireComplexPasscode declares, for its UnmarshalJSON. +var lenientScalarsRequireComplexPasscode = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *RequireComplexPasscode) UnmarshalJSON(data []byte) error { + type lenient RequireComplexPasscode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsRequireComplexPasscode, "RequireComplexPasscode"); err != nil { + return err + } + *s = RequireComplexPasscode(v) + return nil +} + +// lenientScalarsRequirePasscode names the scalars RequirePasscode declares, for its UnmarshalJSON. +var lenientScalarsRequirePasscode = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "Value": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *RequirePasscode) UnmarshalJSON(data []byte) error { + type lenient RequirePasscode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsRequirePasscode, "RequirePasscode"); err != nil { + return err + } + *s = RequirePasscode(v) + return nil +} + +// lenientScalarsScientificMode names the scalars ScientificMode declares, for its UnmarshalJSON. +var lenientScalarsScientificMode = map[string]jsonScalarKind{ + "Enabled": jsonScalarBool, + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *ScientificMode) UnmarshalJSON(data []byte) error { + type lenient ScientificMode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsScientificMode, "ScientificMode"); err != nil { + return err + } + *s = ScientificMode(v) + return nil +} + +// lenientScalarsStorageMode names the scalars StorageMode declares, for its UnmarshalJSON. +var lenientScalarsStorageMode = map[string]jsonScalarKind{ + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *StorageMode) UnmarshalJSON(data []byte) error { + type lenient StorageMode + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsStorageMode, "StorageMode"); err != nil { + return err + } + *s = StorageMode(v) + return nil +} + +// lenientScalarsSwUpdateLatestConfiguration names the scalars SwUpdateLatestConfiguration declares, for its UnmarshalJSON. +var lenientScalarsSwUpdateLatestConfiguration = map[string]jsonScalarKind{ + "enforceAfterDays": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *SwUpdateLatestConfiguration) UnmarshalJSON(data []byte) error { + type lenient SwUpdateLatestConfiguration + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsSwUpdateLatestConfiguration, "SwUpdateLatestConfiguration"); err != nil { + return err + } + *s = SwUpdateLatestConfiguration(v) + return nil +} + +// lenientScalarsSystemBehavior names the scalars SystemBehavior declares, for its UnmarshalJSON. +var lenientScalarsSystemBehavior = map[string]jsonScalarKind{ + "Included": jsonScalarBool, + "KeyboardSuggestions": jsonScalarBool, + "MathNotes": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *SystemBehavior) UnmarshalJSON(data []byte) error { + type lenient SystemBehavior + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsSystemBehavior, "SystemBehavior"); err != nil { + return err + } + *s = SystemBehavior(v) + return nil +} + +// lenientScalarsTemporaryPairing names the scalars TemporaryPairing declares, for its UnmarshalJSON. +var lenientScalarsTemporaryPairing = map[string]jsonScalarKind{ + "Disabled": jsonScalarBool, + "Included": jsonScalarBool, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *TemporaryPairing) UnmarshalJSON(data []byte) error { + type lenient TemporaryPairing + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsTemporaryPairing, "TemporaryPairing"); err != nil { + return err + } + *s = TemporaryPairing(v) + return nil +} + +// lenientScalarsUnpairingTime names the scalars UnpairingTime declares, for its UnmarshalJSON. +var lenientScalarsUnpairingTime = map[string]jsonScalarKind{ + "Hour": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *UnpairingTime) UnmarshalJSON(data []byte) error { + type lenient UnpairingTime + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsUnpairingTime, "UnpairingTime"); err != nil { + return err + } + *s = UnpairingTime(v) + return nil +} + +// lenientScalarsUpdateRule names the scalars UpdateRule declares, for its UnmarshalJSON. +var lenientScalarsUpdateRule = map[string]jsonScalarKind{ + "enforceAfterDays": jsonScalarNumber, +} + +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *UpdateRule) UnmarshalJSON(data []byte) error { + type lenient UpdateRule + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalarsUpdateRule, "UpdateRule"); err != nil { + return err + } + *s = UpdateRule(v) + return nil +} diff --git a/jamfplatform/blueprints/lenient_scalars_test.go b/jamfplatform/blueprints/lenient_scalars_test.go new file mode 100644 index 00000000..99e6422d --- /dev/null +++ b/jamfplatform/blueprints/lenient_scalars_test.go @@ -0,0 +1,240 @@ +// Code generated by tools/generate; DO NOT EDIT. + +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +package blueprints + +import ( + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + "testing" +) + +// lenientScalarCase is one type carrying a tolerant UnmarshalJSON, paired with +// the scalar keys that decoder coerces. +type lenientScalarCase struct { + name string + typ reflect.Type + keys map[string]jsonScalarKind +} + +// body renders a JSON object setting every key in the case, quoting the values +// when quoted is true. Keys are emitted in sorted order so a failure names the +// same body every run. +func (c lenientScalarCase) body(quoted bool) string { + names := make([]string, 0, len(c.keys)) + for name := range c.keys { + names = append(names, name) + } + slices.Sort(names) + parts := make([]string, 0, len(names)) + for _, name := range names { + lit := "1" + if c.keys[name] == jsonScalarBool { + lit = "true" + } + if quoted { + lit = `"` + lit + `"` + } + parts = append(parts, fmt.Sprintf("%q:%s", name, lit)) + } + return "{" + strings.Join(parts, ",") + "}" +} + +// TestLenientScalars_StringEncodingDecodesToTheSameValue is the regression for +// a store that echoes its writer's JSON: every number and boolean under a +// lenientScalarRoots root must decode from the quoted form to exactly what the +// bare form produces. Comparing the two decodes rather than a literal expected +// value is what keeps this honest when a spec moves a field's type. +func TestLenientScalars_StringEncodingDecodesToTheSameValue(t *testing.T) { + for _, c := range lenientScalarCases { + t.Run(c.name, func(t *testing.T) { + bare := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(false)), bare.Interface()); err != nil { + t.Fatalf("decoding the bare form %s: %v", c.body(false), err) + } + quoted := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(true)), quoted.Interface()); err != nil { + t.Fatalf("decoding the string form %s: %v", c.body(true), err) + } + if !reflect.DeepEqual(bare.Elem().Interface(), quoted.Elem().Interface()) { + t.Fatalf("string form decoded differently:\n bare: %#v\n quoted: %#v", + bare.Elem().Interface(), quoted.Elem().Interface()) + } + }) + } +} + +// TestLenientScalars_NonScalarStringStillFails pins the boundary. The coercion +// unquotes a string only when the text it carries is a valid JSON scalar of the +// declared kind, so a genuinely wrong value has to keep failing the decode — +// letting it through as zero would turn a visible fault into a silently wrong +// configuration. +func TestLenientScalars_NonScalarStringStillFails(t *testing.T) { + for _, c := range lenientScalarCases { + for name, kind := range c.keys { + bad := "not-a-number" + if kind == jsonScalarBool { + bad = "yes" + } + t.Run(c.name+"/"+name, func(t *testing.T) { + v := reflect.New(c.typ) + body := fmt.Sprintf(`{%q:%q}`, name, bad) + if err := json.Unmarshal([]byte(body), v.Interface()); err == nil { + t.Fatalf("decoding %s succeeded; a string that is not a scalar of kind %d must still fail", body, kind) + } + }) + } + } +} + +// TestLenientScalars_UnlistedKeysAreUntouched pins that the rewrite is keyed on +// the type's own declared scalars: a string the spec declares as a string must +// survive verbatim, which is what stops the coercion mangling prose that +// happens to look numeric. +func TestLenientScalars_UnlistedKeysAreUntouched(t *testing.T) { + keys := map[string]jsonScalarKind{"count": jsonScalarNumber} + out, changed := unquoteJSONScalars([]byte(`{"count":"7","label":"7"}`), keys) + if !changed { + t.Fatal("the listed key was a string and should have been rewritten") + } + var got map[string]json.RawMessage + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("re-decoding the rewritten body: %v", err) + } + if string(got["count"]) != "7" { + t.Errorf("count = %s, want the bare number 7", got["count"]) + } + if string(got["label"]) != `"7"` { + t.Errorf("label = %s, want the string untouched", got["label"]) + } +} + +// TestLenientScalars_NonObjectBodyKeepsTheOriginalError pins that a body the +// rewrite cannot parse as an object reports the decode's own error rather than +// one about a body the caller never sent. +func TestLenientScalars_NonObjectBodyKeepsTheOriginalError(t *testing.T) { + var target struct { + Count int `json:"count"` + } + err := unmarshalLenientScalars([]byte(`["not","an","object"]`), &target, + map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe") + if err == nil { + t.Fatal("decoding a JSON array into a struct should fail") + } + if !strings.Contains(err.Error(), "array") { + t.Errorf("error = %v, want the original decode error naming the array", err) + } +} + +// TestLenientScalars_WideNumberKeepsItsDigits pins that the number branch +// re-emits the text rather than parsing and reformatting it, so a value beyond +// float64's exact range is not silently rounded on the way through. +func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { + var target struct { + Count int64 `json:"count"` + } + const want = 9007199254740993 + if err := unmarshalLenientScalars([]byte(`{"count":"9007199254740993"}`), &target, + map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe"); err != nil { + t.Fatalf("decoding: %v", err) + } + if target.Count != want { + t.Errorf("Count = %d, want %d", target.Count, want) + } +} + +// TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers pins the forms Go's +// own parsers accept and JSON does not. Every one of these would decode +// through strconv and none of them is a JSON number token. +func TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers(t *testing.T) { + for _, s := range []string{"", " ", "+1", "1_0", "0x10", "1e", "Inf", "NaN", "01", ".5", "1.2.3"} { + if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); ok { + t.Errorf("jsonScalarLiteral(%q) = %q, true; want rejected", s, lit) + } + } + for _, s := range []string{"0", "-1", "1.5", "1e5", "-1.5e-3", "90"} { + if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); !ok || lit != s { + t.Errorf("jsonScalarLiteral(%q) = %q, %v; want %q, true", s, lit, ok, s) + } + } + for _, s := range []string{"True", "TRUE", "1", "", "yes"} { + if lit, ok := jsonScalarLiteral(s, jsonScalarBool); ok { + t.Errorf("jsonScalarLiteral(%q, bool) = %q, true; want rejected", s, lit) + } + } +} + +// TestLenientScalars_DecodeErrorNamesTheRealType pins that a failure names the +// type a caller can look up, not the local alias each decoder decodes into. +func TestLenientScalars_DecodeErrorNamesTheRealType(t *testing.T) { + for _, c := range lenientScalarCases { + for name, kind := range c.keys { + if kind != jsonScalarNumber { + continue + } + v := reflect.New(c.typ) + err := json.Unmarshal([]byte(fmt.Sprintf(`{%q:"not-a-number"}`, name)), v.Interface()) + if err == nil { + t.Fatalf("%s.%s: decoding a non-numeric string should fail", c.name, name) + } + if strings.Contains(err.Error(), "lenient.") { + t.Errorf("%s.%s: error names the local alias: %v", c.name, name, err) + } + if !strings.Contains(err.Error(), c.name) { + t.Errorf("%s.%s: error does not name the type: %v", c.name, name, err) + } + break + } + } +} + +var lenientScalarCases = []lenientScalarCase{ + {name: "AcceptCookies", typ: reflect.TypeOf(AcceptCookies{}), keys: lenientScalarsAcceptCookies}, + {name: "AllowDisablingFraudWarning", typ: reflect.TypeOf(AllowDisablingFraudWarning{}), keys: lenientScalarsAllowDisablingFraudWarning}, + {name: "AllowHistoryClearing", typ: reflect.TypeOf(AllowHistoryClearing{}), keys: lenientScalarsAllowHistoryClearing}, + {name: "AllowJavaScript", typ: reflect.TypeOf(AllowJavaScript{}), keys: lenientScalarsAllowJavaScript}, + {name: "AllowPopups", typ: reflect.TypeOf(AllowPopups{}), keys: lenientScalarsAllowPopups}, + {name: "AllowPrivateBrowsing", typ: reflect.TypeOf(AllowPrivateBrowsing{}), keys: lenientScalarsAllowPrivateBrowsing}, + {name: "AllowSummary", typ: reflect.TypeOf(AllowSummary{}), keys: lenientScalarsAllowSummary}, + {name: "AutomaticAction", typ: reflect.TypeOf(AutomaticAction{}), keys: lenientScalarsAutomaticAction}, + {name: "BasicMode", typ: reflect.TypeOf(BasicMode{}), keys: lenientScalarsBasicMode}, + {name: "Beta", typ: reflect.TypeOf(Beta{}), keys: lenientScalarsBeta}, + {name: "ChangeAtNextAuth", typ: reflect.TypeOf(ChangeAtNextAuth{}), keys: lenientScalarsChangeAtNextAuth}, + {name: "CustomDeclaration", typ: reflect.TypeOf(CustomDeclaration{}), keys: lenientScalarsCustomDeclaration}, + {name: "CustomRegex", typ: reflect.TypeOf(CustomRegex{}), keys: lenientScalarsCustomRegex}, + {name: "DetailsURL", typ: reflect.TypeOf(DetailsURL{}), keys: lenientScalarsDetailsURL}, + {name: "DiskManagementSettingsConfiguration", typ: reflect.TypeOf(DiskManagementSettingsConfiguration{}), keys: lenientScalarsDiskManagementSettingsConfiguration}, + {name: "FailedAttemptsResetInMinutes", typ: reflect.TypeOf(FailedAttemptsResetInMinutes{}), keys: lenientScalarsFailedAttemptsResetInMinutes}, + {name: "InputModes", typ: reflect.TypeOf(InputModes{}), keys: lenientScalarsInputModes}, + {name: "ManagedAppAttributes", typ: reflect.TypeOf(ManagedAppAttributes{}), keys: lenientScalarsManagedAppAttributes}, + {name: "ManagedAppEntry", typ: reflect.TypeOf(ManagedAppEntry{}), keys: lenientScalarsManagedAppEntry}, + {name: "MathNotesMode", typ: reflect.TypeOf(MathNotesMode{}), keys: lenientScalarsMathNotesMode}, + {name: "MaximumFailedAttempts", typ: reflect.TypeOf(MaximumFailedAttempts{}), keys: lenientScalarsMaximumFailedAttempts}, + {name: "MaximumGracePeriodInMinutes", typ: reflect.TypeOf(MaximumGracePeriodInMinutes{}), keys: lenientScalarsMaximumGracePeriodInMinutes}, + {name: "MaximumInactivityInMinutes", typ: reflect.TypeOf(MaximumInactivityInMinutes{}), keys: lenientScalarsMaximumInactivityInMinutes}, + {name: "MaximumPasscodeAgeInDays", typ: reflect.TypeOf(MaximumPasscodeAgeInDays{}), keys: lenientScalarsMaximumPasscodeAgeInDays}, + {name: "MinimumComplexCharacters", typ: reflect.TypeOf(MinimumComplexCharacters{}), keys: lenientScalarsMinimumComplexCharacters}, + {name: "MinimumLength", typ: reflect.TypeOf(MinimumLength{}), keys: lenientScalarsMinimumLength}, + {name: "NewTabStartPage", typ: reflect.TypeOf(NewTabStartPage{}), keys: lenientScalarsNewTabStartPage}, + {name: "OptionalPeriodInDays", typ: reflect.TypeOf(OptionalPeriodInDays{}), keys: lenientScalarsOptionalPeriodInDays}, + {name: "OptionallyEnabled", typ: reflect.TypeOf(OptionallyEnabled{}), keys: lenientScalarsOptionallyEnabled}, + {name: "PasscodeReuseLimit", typ: reflect.TypeOf(PasscodeReuseLimit{}), keys: lenientScalarsPasscodeReuseLimit}, + {name: "PasscodeSettingsConfiguration", typ: reflect.TypeOf(PasscodeSettingsConfiguration{}), keys: lenientScalarsPasscodeSettingsConfiguration}, + {name: "ProgrammerMode", typ: reflect.TypeOf(ProgrammerMode{}), keys: lenientScalarsProgrammerMode}, + {name: "RecommendedCadence", typ: reflect.TypeOf(RecommendedCadence{}), keys: lenientScalarsRecommendedCadence}, + {name: "RequireAlphanumericPasscode", typ: reflect.TypeOf(RequireAlphanumericPasscode{}), keys: lenientScalarsRequireAlphanumericPasscode}, + {name: "RequireComplexPasscode", typ: reflect.TypeOf(RequireComplexPasscode{}), keys: lenientScalarsRequireComplexPasscode}, + {name: "RequirePasscode", typ: reflect.TypeOf(RequirePasscode{}), keys: lenientScalarsRequirePasscode}, + {name: "ScientificMode", typ: reflect.TypeOf(ScientificMode{}), keys: lenientScalarsScientificMode}, + {name: "StorageMode", typ: reflect.TypeOf(StorageMode{}), keys: lenientScalarsStorageMode}, + {name: "SwUpdateLatestConfiguration", typ: reflect.TypeOf(SwUpdateLatestConfiguration{}), keys: lenientScalarsSwUpdateLatestConfiguration}, + {name: "SystemBehavior", typ: reflect.TypeOf(SystemBehavior{}), keys: lenientScalarsSystemBehavior}, + {name: "TemporaryPairing", typ: reflect.TypeOf(TemporaryPairing{}), keys: lenientScalarsTemporaryPairing}, + {name: "UnpairingTime", typ: reflect.TypeOf(UnpairingTime{}), keys: lenientScalarsUnpairingTime}, + {name: "UpdateRule", typ: reflect.TypeOf(UpdateRule{}), keys: lenientScalarsUpdateRule}, +} diff --git a/tools/generate/config.go b/tools/generate/config.go index a14f3d31..7be9a3b5 100644 --- a/tools/generate/config.go +++ b/tools/generate/config.go @@ -298,6 +298,41 @@ type SpecDef struct { // of the same "v1" across three specs. Version string `json:"version,omitempty"` + // LenientScalarRoots names component schemas whose reachable subtree is + // decoded leniently: every number and boolean under them also accepts a + // JSON string carrying the same value. Emitted as one UnmarshalJSON per + // affected type in lenient_scalars.go; field types, marshalling and the + // published spec under api/ are all untouched. + // + // It exists for a store that serves back the JSON its writer sent instead + // of re-serialising from its own model. blueprints is that store, and + // "Component" is the root: the service validates a component + // configuration on write — Jackson coerces "5" to 5, and the declared + // minimum and maximum are still enforced — but a read returns the writer's + // own encoding. The Jamf Pro web UI writes these scalars as JSON strings, + // so a blueprint built there answers {"Value": "5"} where the spec + // declares an integer, and a strict decode fails on the whole component + // rather than on the one field. That cost the Terraform provider every + // software-update-settings component created in the UI + // (terraform-provider-jamfplatform#431); wire-verified 2026-09-15 on a + // UI-built blueprint and reproduced by writing both encodings through the + // API — see docs/WIRE-FACTS.md. + // + // The root is the *union*, not the twelve configuration schemas under it, + // so a component added upstream inherits the tolerance with no config + // change. Naming the union is also why the key is a root list rather than + // a type list: the set it covers is derived, and cannot drift from the + // spec. + // + // Self-expiring in one direction only. Generation fails when a root names + // no schema the spec declares, which is what catches a rename or a + // withdrawal upstream, and when a root's subtree reaches no scalar at all. + // It cannot expire on the server being fixed — nothing in a spec says how + // a store serialises — so the acceptance test is what carries that: + // TestAcceptance_Blueprint_UIWrittenScalarsDecode asserts the wire still + // returns a string, and fails the day it stops. + LenientScalarRoots []string `json:"lenientScalarRoots,omitempty"` + // TagRenames remaps an OpenAPI tag before it picks the output filename, // and nothing else — method names, godoc and the published spec are // untouched. Needed when two specs in one package share a tag, since diff --git a/tools/generate/config.json b/tools/generate/config.json index 529fcc85..c17b0c0b 100644 --- a/tools/generate/config.json +++ b/tools/generate/config.json @@ -148,6 +148,9 @@ "configuration": "object" } }, + "lenientScalarRoots": [ + "Component" + ], "fieldTypeOverrides": { "component.configuration": "json.RawMessage", "configuration_profile_configuration.payloadContent": "[]json.RawMessage" diff --git a/tools/generate/emit.go b/tools/generate/emit.go index a8656c2f..18acef6d 100644 --- a/tools/generate/emit.go +++ b/tools/generate/emit.go @@ -456,6 +456,8 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) var allSpecs []specWithMethods pkgEmitted := make(map[string]bool) var allTypes []GoType + lenientSeed := make(map[string]bool) + var lenientRoots []string for _, ls := range specs { doc, err := loadSpec(ls.specPath, allowedOpsSet(ls.spec)) @@ -518,6 +520,16 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) if err := applyDocNotes(types, spec.DocNotes); err != nil { return fmt.Errorf("%s: %w", spec.File, err) } + // Seeded from the doc after every schema pass, so a hoisted inline + // object is already present under its emitted name. + seed, err := lenientScalarSeed(doc, spec.LenientScalarRoots) + if err != nil { + return fmt.Errorf("%s: %w", spec.File, err) + } + for name := range seed { + lenientSeed[name] = true + } + lenientRoots = append(lenientRoots, spec.LenientScalarRoots...) for _, t := range types { pkgEmitted[t.Name] = true } @@ -569,6 +581,16 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) if err := emitUnionRoundTripTest(pkgDir, goPkgName, structTypes); err != nil { return err } + lenientEntries := lenientScalarTypes(structTypes, lenientSeed) + if err := validateLenientScalars(fmt.Sprintf("package %s", pkgName), lenientRoots, lenientEntries); err != nil { + return err + } + if err := emitPkgLenientScalars(pkgDir, goPkgName, lenientEntries); err != nil { + return err + } + if err := emitPkgLenientScalarsTest(pkgDir, goPkgName, lenientEntries); err != nil { + return err + } // Which spec claimed each tag-derived filename, so a second spec tagging // its operations the same way fails loudly instead of overwriting the @@ -633,6 +655,8 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, pkgEmitted := make(map[string]bool) var allTypes []GoType pkgFormat := "" + lenientSeed := make(map[string]bool) + var lenientRoots []string for _, ls := range specs { doc, err := loadSpec(ls.specPath, nil) @@ -678,6 +702,14 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, if err := applyDocNotes(types, ls.spec.DocNotes); err != nil { return fmt.Errorf("%s: %w", ls.spec.File, err) } + seed, err := lenientScalarSeed(doc, ls.spec.LenientScalarRoots) + if err != nil { + return fmt.Errorf("%s: %w", ls.spec.File, err) + } + for name := range seed { + lenientSeed[name] = true + } + lenientRoots = append(lenientRoots, ls.spec.LenientScalarRoots...) // This path emits types and no methods, so there is nothing for a // method note to attach to. Refuse rather than skip: a silently // dropped note leaves the gap it was written to close. @@ -705,7 +737,14 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, if err := emitTypesOnlyTest(pkgDir, goPkgName, allTypes); err != nil { return err } - return nil + lenientEntries := lenientScalarTypes(structTypes, lenientSeed) + if err := validateLenientScalars("package "+goPkgName, lenientRoots, lenientEntries); err != nil { + return err + } + if err := emitPkgLenientScalars(pkgDir, goPkgName, lenientEntries); err != nil { + return err + } + return emitPkgLenientScalarsTest(pkgDir, goPkgName, lenientEntries) } // partitionEnumTypes splits emitted declarations into the structs and scalar diff --git a/tools/generate/lenient.go b/tools/generate/lenient.go new file mode 100644 index 00000000..cc4f52d7 --- /dev/null +++ b/tools/generate/lenient.go @@ -0,0 +1,647 @@ +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "log" + "path/filepath" + "slices" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// jsonScalarKindNumber and jsonScalarKindBool name the generated +// jsonScalarKind constants a lenient key maps to. They are the only two +// kinds: a JSON string is the wire form the coercion accepts, and every +// other declared type either already arrives as a string or is a composite +// the per-type decoders reach through its own method. +const ( + jsonScalarKindNumber = "jsonScalarNumber" + jsonScalarKindBool = "jsonScalarBool" +) + +// lenientType is one emitted Go struct that gets a tolerant UnmarshalJSON, +// with the JSON keys to coerce and the kind each is declared as. +type lenientType struct { + Name string + Keys []lenientKey +} + +// lenientKey is one property of a lenientType: the JSON name as it travels +// on the wire, and the generated kind constant naming what the spec declares. +type lenientKey struct { + JSON string + Kind string +} + +// goScalarKind classifies a generated field type as the JSON scalar kind a +// string-encoded value would have to be coerced to, or "" when the field is +// not a scalar the coercion applies to. +// +// Composites are deliberately excluded rather than descended into: a field +// whose leaf is a struct is decoded by that struct's own generated method, and +// a slice or map of numbers is a shape no writer has been observed to +// string-encode. Named aliases over an integer base (a numeric enum) count, +// since the wire form is the same integer. +func goScalarKind(fieldType string, aliasBase map[string]string) string { + // A pointer is the same scalar; a slice or map is not, so only `*` comes + // off. normalizeTypeRef would strip the containers too. + bare := strings.TrimLeft(fieldType, "*") + switch bare { + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", + "float32", "float64": + return jsonScalarKindNumber + case "bool": + return jsonScalarKindBool + } + switch aliasBase[bare] { + case "int", "int64": + return jsonScalarKindNumber + } + return "" +} + +// numericEnumBases maps each emitted numeric-enum type name to its underlying +// Go base type, so a field typed as one of them is still recognised as a +// number. A string enum is absent: its wire form is already a JSON string. +func numericEnumBases(types []GoType) map[string]string { + bases := make(map[string]string) + for _, t := range types { + if len(t.EnumValues) == 0 || len(t.Fields) > 0 { + continue + } + switch t.EnumBaseType { + case "int", "int64": + bases[t.Name] = t.EnumBaseType + } + } + return bases +} + +// lenientScalarSeed walks the schema graph from each named root and returns the +// Go type names its subtree reaches. Called with the doc after every schema +// pass has run, so hoisted inline objects are present under the names they +// will be emitted with. +// +// A root that names no schema in this spec is an error rather than a skip: the +// key exists to carry a wire fact about a specific store, and a root that has +// been renamed or withdrawn upstream silently drops the tolerance from every +// type under it. +func lenientScalarSeed(doc *openapi3.T, roots []string) (map[string]bool, error) { + seed := make(map[string]bool) + if len(roots) == 0 { + return seed, nil + } + if doc.Components == nil || doc.Components.Schemas == nil { + return nil, fmt.Errorf("lenientScalarRoots names %s but the spec declares no component schemas", + strings.Join(roots, ", ")) + } + var missing []string + for _, root := range roots { + if _, ok := doc.Components.Schemas[root]; !ok { + missing = append(missing, root) + continue + } + visited := make(map[string]bool) + walk := newSchemaWalker(doc, func(name string) bool { + if visited[name] { + return false + } + visited[name] = true + seed[goTypeName(name)] = true + return true + }) + walk(doc.Components.Schemas[root]) + } + if len(missing) > 0 { + return nil, fmt.Errorf("lenientScalarRoots names %d schema(s) this spec does not declare: %s\n\n"+ + "fix: rename the entry to the schema that replaced it, or delete it — a root that resolves to "+ + "nothing removes the tolerance from its whole subtree with no other signal", + len(missing), strings.Join(missing, ", ")) + } + return seed, nil +} + +// lenientScalarTypes closes the seed over the emitted types' own field +// references and returns, in emission order, every struct that both lies in +// the closure and declares at least one number or boolean. +// +// The closure is needed because the seed is derived from schema names while +// the tolerance is emitted against Go types: a field whose type was hoisted +// out of an inline object, or renamed on the way to Go, is reachable only by +// following the emitted field types. +func lenientScalarTypes(types []GoType, seed map[string]bool) []lenientType { + byName := make(map[string]GoType, len(types)) + for _, t := range types { + byName[t.Name] = t + } + reached := make(map[string]bool) + var queue []string + for name := range seed { + if _, ok := byName[name]; ok { + queue = append(queue, name) + } + } + slices.Sort(queue) + for len(queue) > 0 { + name := queue[0] + queue = queue[1:] + if reached[name] { + continue + } + reached[name] = true + for _, f := range byName[name].Fields { + ref := normalizeTypeRef(f.Type) + if _, ok := byName[ref]; ok && !reached[ref] { + queue = append(queue, ref) + } + } + } + + aliasBase := numericEnumBases(types) + var out []lenientType + for _, t := range types { + if !reached[t.Name] || len(t.Fields) == 0 { + continue + } + var keys []lenientKey + for _, f := range t.Fields { + kind := goScalarKind(f.Type, aliasBase) + if kind == "" { + continue + } + name := jsonTagName(f.JSONTag) + if name == "" { + continue + } + keys = append(keys, lenientKey{JSON: name, Kind: kind}) + } + if len(keys) == 0 { + continue + } + slices.SortFunc(keys, func(a, b lenientKey) int { return strings.Compare(a.JSON, b.JSON) }) + out = append(out, lenientType{Name: t.Name, Keys: keys}) + } + return out +} + +// jsonTagName returns the wire name from a struct tag body, dropping the +// options after the first comma. An empty name yields "": the field does not +// travel under a key the rewrite could find. +// +// Follows encoding/json's own rule for the dash, pedantic as it is: a tag of +// exactly "-" skips the field, while "-," names it "-". Matching the decoder +// exactly is the only way a key list can be trusted to describe what the +// decoder will look for. +func jsonTagName(tag string) string { + if tag == "-" { + return "" + } + name, _, _ := strings.Cut(tag, ",") + return name +} + +// emitPkgLenientScalars writes lenient_scalars.go: the coercion helper plus one +// UnmarshalJSON per type in entries. A package with no entries gets no file, +// and a spec that asked for roots but produced none is an error — see +// validateLenientScalars. +func emitPkgLenientScalars(pkgDir, pkgName string, entries []lenientType) error { + if len(entries) == 0 { + return nil + } + var b strings.Builder + fmt.Fprintf(&b, `// Code generated by tools/generate; DO NOT EDIT. + +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +// Tolerant decoders for the schemas config names in lenientScalarRoots: a +// store that serves back the JSON its writer sent, rather than re-serialising +// from its own model, answers whatever scalar encoding that writer used. +// +// Emitted here rather than in types.go so the field types stay exactly what +// the spec declares — the tolerance is in the decode, not in the surface a +// consumer programs against — and so this file is the one place to read for +// what the coercion does and does not do. + +package %s + +import ( + "encoding/json" + "errors" +) + +// jsonScalarKind names the JSON scalar a property is declared as. +type jsonScalarKind uint8 + +const ( + // jsonScalarNumber: the spec declares an integer or a number. + jsonScalarNumber jsonScalarKind = iota + 1 + // jsonScalarBool: the spec declares a boolean. + jsonScalarBool +) + +// unmarshalLenientScalars decodes data into v, accepting a JSON string +// wherever keys declares a number or a boolean. +// +// The strict decode is tried first and the rewrite only happens when it fails, +// so a conforming body costs one extra error check and nothing else. A nested +// value is fixed by its own type's method during that first attempt, which is +// why each type only has to describe its own keys. +// +// Three properties worth relying on. Only the listed keys are considered, so a +// string the spec declares as a string is never touched. Only a JSON string is +// rewritten, and only when the text it carries is a valid JSON scalar of the +// declared kind — so "abc" for an integer still fails the decode rather than +// arriving as zero. And marshalling is untouched: the SDK keeps sending the +// numbers and booleans the spec declares. +func unmarshalLenientScalars(data []byte, v any, keys map[string]jsonScalarKind, name string) error { + err := json.Unmarshal(data, v) + if err == nil { + return nil + } + fixed, rewritten := unquoteJSONScalars(data, keys) + if rewritten { + // The second error is the one to report when it comes: the rewrite + // has handled the encoding the first error described, so what is left + // is a fault the coercion has nothing to do with. + err = json.Unmarshal(fixed, v) + if err == nil { + return nil + } + } + return namedStructError(err, name) +} + +// namedStructError puts the real type name back into a decode error. +// +// Each generated decoder decodes into a local type named "lenient" to shed the +// method and avoid recursing, and encoding/json reports the *Go* type it was +// decoding — so without this a caller reads "json: cannot unmarshal string +// into Go struct field lenient.Value", which names nothing they can look up. +func namedStructError(err error, name string) error { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) && typeErr.Struct == "lenient" { + typeErr.Struct = name + } + return err +} + +// unquoteJSONScalars returns data with every listed key whose value arrived as +// a JSON string rewritten to the bare scalar its kind declares. The second +// return is false when nothing was rewritten, which includes a body that is +// not a JSON object at all — the caller then reports the original error. +func unquoteJSONScalars(data []byte, keys map[string]jsonScalarKind) ([]byte, bool) { + if len(keys) == 0 { + return data, false + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return data, false + } + changed := false + for key, kind := range keys { + raw, present := obj[key] + if !present { + continue + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + // Not a JSON string: already the declared shape, or a shape this + // coercion has nothing to say about. + continue + } + lit, ok := jsonScalarLiteral(s, kind) + if !ok { + continue + } + obj[key] = json.RawMessage(lit) + changed = true + } + if !changed { + return data, false + } + out, err := json.Marshal(obj) + if err != nil { + return data, false + } + return out, true +} + +// jsonScalarLiteral returns s as a bare JSON literal of the given kind, and +// false when it is not one. +// +// The number branch re-emits the text verbatim rather than parsing and +// reformatting it, so a value wider than float64 keeps every digit and a +// decimal keeps its exact spelling; json.Valid plus the leading-byte check is +// what decides it is a JSON number token, which rules out the forms Go's own +// parsers accept and JSON does not (Inf, NaN, hex floats, a leading +). +func jsonScalarLiteral(s string, kind jsonScalarKind) (string, bool) { + switch kind { + case jsonScalarNumber: + if s == "" || !json.Valid([]byte(s)) { + return "", false + } + if c := s[0]; c != '-' && (c < '0' || c > '9') { + return "", false + } + return s, true + case jsonScalarBool: + if s == "true" || s == "false" { + return s, true + } + } + return "", false +} +`, pkgName) + + for _, e := range entries { + fmt.Fprintf(&b, "\n// lenientScalars%s names the scalars %s declares, for its UnmarshalJSON.\nvar lenientScalars%s = map[string]jsonScalarKind{\n", e.Name, e.Name, e.Name) + for _, k := range e.Keys { + fmt.Fprintf(&b, "\t%q: %s,\n", k.JSON, k.Kind) + } + b.WriteString("}\n") + fmt.Fprintf(&b, ` +// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and +// booleans. See unmarshalLenientScalars for what is and is not coerced. +func (s *%s) UnmarshalJSON(data []byte) error { + type lenient %s + var v lenient + if err := unmarshalLenientScalars(data, &v, lenientScalars%s, %q); err != nil { + return err + } + *s = %s(v) + return nil +} +`, e.Name, e.Name, e.Name, e.Name, e.Name) + } + + outPath := filepath.Join(pkgDir, "lenient_scalars.go") + formatted, err := formatGo("lenient_scalars.go", []byte(b.String())) + if err != nil { + return fmt.Errorf("formatting lenient_scalars.go: %w", err) + } + if err := writeGenerated(outPath, formatted, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outPath, err) + } + log.Printf("wrote %s (%d types)", outPath, len(entries)) + return nil +} + +// validateLenientScalars refuses a spec that asked for tolerance and got none. +// +// A root resolves but reaches no number or boolean when upstream has moved the +// scalars out from under it, and the entry is then a claim about a store that +// no longer needs it. Failing here is what deletes the config entry, the same +// way a redundant scopeTypes override fails. +func validateLenientScalars(pkgContext string, roots []string, entries []lenientType) error { + if len(roots) == 0 || len(entries) > 0 { + return nil + } + return fmt.Errorf("%s: lenientScalarRoots names %s but its subtree reaches no number or boolean field\n\n"+ + "fix: delete the entry — every scalar it covered has gone, so the tolerance has nothing left to do", + pkgContext, strings.Join(roots, ", ")) +} + +// lenientScalarsTestTemplate is the fixed body of lenient_scalars_test.go. +// @BQ@ and @DQ@ stand in for a backtick and a double quote so the whole thing +// can live in one raw string; @PKG@ is the package name and @CASES@ the table +// rows. Written this way rather than through a format string because the +// generated source is dense in quotes of both kinds and a Sprintf verb in the +// middle of them is where the last attempt went wrong. +const lenientScalarsTestTemplate = `// Code generated by tools/generate; DO NOT EDIT. + +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +package @PKG@ + +import ( + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + "testing" +) + +// lenientScalarCase is one type carrying a tolerant UnmarshalJSON, paired with +// the scalar keys that decoder coerces. +type lenientScalarCase struct { + name string + typ reflect.Type + keys map[string]jsonScalarKind +} + +// body renders a JSON object setting every key in the case, quoting the values +// when quoted is true. Keys are emitted in sorted order so a failure names the +// same body every run. +func (c lenientScalarCase) body(quoted bool) string { + names := make([]string, 0, len(c.keys)) + for name := range c.keys { + names = append(names, name) + } + slices.Sort(names) + parts := make([]string, 0, len(names)) + for _, name := range names { + lit := "1" + if c.keys[name] == jsonScalarBool { + lit = "true" + } + if quoted { + lit = @BQ@"@BQ@ + lit + @BQ@"@BQ@ + } + parts = append(parts, fmt.Sprintf("%q:%s", name, lit)) + } + return "{" + strings.Join(parts, ",") + "}" +} + +// TestLenientScalars_StringEncodingDecodesToTheSameValue is the regression for +// a store that echoes its writer's JSON: every number and boolean under a +// lenientScalarRoots root must decode from the quoted form to exactly what the +// bare form produces. Comparing the two decodes rather than a literal expected +// value is what keeps this honest when a spec moves a field's type. +func TestLenientScalars_StringEncodingDecodesToTheSameValue(t *testing.T) { + for _, c := range lenientScalarCases { + t.Run(c.name, func(t *testing.T) { + bare := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(false)), bare.Interface()); err != nil { + t.Fatalf("decoding the bare form %s: %v", c.body(false), err) + } + quoted := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(true)), quoted.Interface()); err != nil { + t.Fatalf("decoding the string form %s: %v", c.body(true), err) + } + if !reflect.DeepEqual(bare.Elem().Interface(), quoted.Elem().Interface()) { + t.Fatalf("string form decoded differently:\n bare: %#v\n quoted: %#v", + bare.Elem().Interface(), quoted.Elem().Interface()) + } + }) + } +} + +// TestLenientScalars_NonScalarStringStillFails pins the boundary. The coercion +// unquotes a string only when the text it carries is a valid JSON scalar of the +// declared kind, so a genuinely wrong value has to keep failing the decode — +// letting it through as zero would turn a visible fault into a silently wrong +// configuration. +func TestLenientScalars_NonScalarStringStillFails(t *testing.T) { + for _, c := range lenientScalarCases { + for name, kind := range c.keys { + bad := "not-a-number" + if kind == jsonScalarBool { + bad = "yes" + } + t.Run(c.name+"/"+name, func(t *testing.T) { + v := reflect.New(c.typ) + body := fmt.Sprintf(@BQ@{%q:%q}@BQ@, name, bad) + if err := json.Unmarshal([]byte(body), v.Interface()); err == nil { + t.Fatalf("decoding %s succeeded; a string that is not a scalar of kind %d must still fail", body, kind) + } + }) + } + } +} + +// TestLenientScalars_UnlistedKeysAreUntouched pins that the rewrite is keyed on +// the type's own declared scalars: a string the spec declares as a string must +// survive verbatim, which is what stops the coercion mangling prose that +// happens to look numeric. +func TestLenientScalars_UnlistedKeysAreUntouched(t *testing.T) { + keys := map[string]jsonScalarKind{"count": jsonScalarNumber} + out, changed := unquoteJSONScalars([]byte(@BQ@{"count":"7","label":"7"}@BQ@), keys) + if !changed { + t.Fatal("the listed key was a string and should have been rewritten") + } + var got map[string]json.RawMessage + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("re-decoding the rewritten body: %v", err) + } + if string(got["count"]) != "7" { + t.Errorf("count = %s, want the bare number 7", got["count"]) + } + if string(got["label"]) != @BQ@"7"@BQ@ { + t.Errorf("label = %s, want the string untouched", got["label"]) + } +} + +// TestLenientScalars_NonObjectBodyKeepsTheOriginalError pins that a body the +// rewrite cannot parse as an object reports the decode's own error rather than +// one about a body the caller never sent. +func TestLenientScalars_NonObjectBodyKeepsTheOriginalError(t *testing.T) { + var target struct { + Count int @BQ@json:"count"@BQ@ + } + err := unmarshalLenientScalars([]byte(@BQ@["not","an","object"]@BQ@), &target, + map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe") + if err == nil { + t.Fatal("decoding a JSON array into a struct should fail") + } + if !strings.Contains(err.Error(), "array") { + t.Errorf("error = %v, want the original decode error naming the array", err) + } +} + +// TestLenientScalars_WideNumberKeepsItsDigits pins that the number branch +// re-emits the text rather than parsing and reformatting it, so a value beyond +// float64's exact range is not silently rounded on the way through. +func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { + var target struct { + Count int64 @BQ@json:"count"@BQ@ + } + const want = 9007199254740993 + if err := unmarshalLenientScalars([]byte(@BQ@{"count":"9007199254740993"}@BQ@), &target, + map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe"); err != nil { + t.Fatalf("decoding: %v", err) + } + if target.Count != want { + t.Errorf("Count = %d, want %d", target.Count, want) + } +} + +// TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers pins the forms Go's +// own parsers accept and JSON does not. Every one of these would decode +// through strconv and none of them is a JSON number token. +func TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers(t *testing.T) { + for _, s := range []string{"", " ", "+1", "1_0", "0x10", "1e", "Inf", "NaN", "01", ".5", "1.2.3"} { + if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); ok { + t.Errorf("jsonScalarLiteral(%q) = %q, true; want rejected", s, lit) + } + } + for _, s := range []string{"0", "-1", "1.5", "1e5", "-1.5e-3", "90"} { + if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); !ok || lit != s { + t.Errorf("jsonScalarLiteral(%q) = %q, %v; want %q, true", s, lit, ok, s) + } + } + for _, s := range []string{"True", "TRUE", "1", "", "yes"} { + if lit, ok := jsonScalarLiteral(s, jsonScalarBool); ok { + t.Errorf("jsonScalarLiteral(%q, bool) = %q, true; want rejected", s, lit) + } + } +} + +// TestLenientScalars_DecodeErrorNamesTheRealType pins that a failure names the +// type a caller can look up, not the local alias each decoder decodes into. +func TestLenientScalars_DecodeErrorNamesTheRealType(t *testing.T) { + for _, c := range lenientScalarCases { + for name, kind := range c.keys { + if kind != jsonScalarNumber { + continue + } + v := reflect.New(c.typ) + err := json.Unmarshal([]byte(fmt.Sprintf(@BQ@{%q:"not-a-number"}@BQ@, name)), v.Interface()) + if err == nil { + t.Fatalf("%s.%s: decoding a non-numeric string should fail", c.name, name) + } + if strings.Contains(err.Error(), "lenient.") { + t.Errorf("%s.%s: error names the local alias: %v", c.name, name, err) + } + if !strings.Contains(err.Error(), c.name) { + t.Errorf("%s.%s: error does not name the type: %v", c.name, name, err) + } + break + } + } +} + +var lenientScalarCases = []lenientScalarCase{ +@CASES@} +` + +// emitPkgLenientScalarsTest writes lenient_scalars_test.go: a table over every +// type that got a tolerant decoder, asserting the string encoding decodes to +// the same value the bare scalar does, and that a string carrying something +// that is not a scalar of the declared kind still fails. +// +// The table is reflective rather than one hand-shaped case per type because +// what is being pinned is uniform: 43 near-identical literal fixtures would go +// stale the first time a spec moved a field, and equality against the +// bare-scalar decode is a stronger assertion than any single expected value. +func emitPkgLenientScalarsTest(pkgDir, pkgName string, entries []lenientType) error { + if len(entries) == 0 { + return nil + } + var cases strings.Builder + for _, e := range entries { + fmt.Fprintf(&cases, "\t{name: %q, typ: reflect.TypeOf(%s{}), keys: lenientScalars%s},\n", e.Name, e.Name, e.Name) + } + src := lenientScalarsTestTemplate + src = strings.ReplaceAll(src, "@PKG@", pkgName) + src = strings.ReplaceAll(src, "@CASES@", cases.String()) + src = strings.ReplaceAll(src, "@BQ@", "`") + + outPath := filepath.Join(pkgDir, "lenient_scalars_test.go") + formatted, err := formatGo("lenient_scalars_test.go", []byte(src)) + if err != nil { + return fmt.Errorf("formatting lenient_scalars_test.go: %w", err) + } + if err := writeGenerated(outPath, formatted, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outPath, err) + } + log.Printf("wrote %s", outPath) + return nil +} diff --git a/tools/generate/lenient_test.go b/tools/generate/lenient_test.go new file mode 100644 index 00000000..087a2dc7 --- /dev/null +++ b/tools/generate/lenient_test.go @@ -0,0 +1,211 @@ +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +package main + +import ( + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +// The classification decides which fields the emitted decoder will coerce, so +// it has to be exact in both directions: a missed scalar leaves the defect in +// place, and a wrongly-included container would have the rewrite unquote a +// value the type cannot hold. +func TestGoScalarKind(t *testing.T) { + aliases := map[string]string{"ConfigVersion": "int", "Cadence": ""} + cases := []struct { + field string + want string + }{ + {"int", jsonScalarKindNumber}, + {"*int", jsonScalarKindNumber}, + {"int64", jsonScalarKindNumber}, + {"*float64", jsonScalarKindNumber}, + {"uint8", jsonScalarKindNumber}, + {"bool", jsonScalarKindBool}, + {"*bool", jsonScalarKindBool}, + {"ConfigVersion", jsonScalarKindNumber}, + {"*ConfigVersion", jsonScalarKindNumber}, + {"string", ""}, + {"*string", ""}, + {"Cadence", ""}, + {"[]int", ""}, + {"*[]int", ""}, + {"[]bool", ""}, + {"map[string]int", ""}, + {"json.RawMessage", ""}, + {"time.Time", ""}, + {"OptionalPeriodInDays", ""}, + } + for _, c := range cases { + if got := goScalarKind(c.field, aliases); got != c.want { + t.Errorf("goScalarKind(%q) = %q, want %q", c.field, got, c.want) + } + } +} + +// Only a numeric enum counts: its wire form is the integer the coercion has to +// produce. A string enum already arrives as a JSON string, and a struct that +// happens to declare enum values is not a scalar at all. +func TestNumericEnumBases(t *testing.T) { + bases := numericEnumBases([]GoType{ + {Name: "RefreshRate", EnumValues: []GoEnumConst{{Literal: "60"}}, EnumBaseType: "int"}, + {Name: "Threshold", EnumValues: []GoEnumConst{{Literal: "1"}}, EnumBaseType: "int64"}, + {Name: "Cadence", EnumValues: []GoEnumConst{{Literal: `"All"`}}, EnumBaseType: "string"}, + {Name: "Unset", EnumValues: []GoEnumConst{{Literal: `"x"`}}}, + {Name: "Struct", EnumValues: []GoEnumConst{{Literal: "1"}}, EnumBaseType: "int", Fields: []GoField{{Name: "X"}}}, + {Name: "Plain"}, + }) + want := map[string]string{"RefreshRate": "int", "Threshold": "int64"} + if len(bases) != len(want) { + t.Fatalf("bases = %v, want %v", bases, want) + } + for name, base := range want { + if bases[name] != base { + t.Errorf("bases[%q] = %q, want %q", name, bases[name], base) + } + } +} + +// The root is the discriminated union, not the twelve configurations under it, +// so the walk has to follow oneOf and then each variant's own properties — +// that is what makes a component added upstream inherit the tolerance with no +// config change. +func TestLenientScalarSeedFollowsAUnionToItsConfigurations(t *testing.T) { + doc := &openapi3.T{Components: &openapi3.Components{Schemas: openapi3.Schemas{ + "Component": {Value: &openapi3.Schema{ + Type: types("object"), + OneOf: openapi3.SchemaRefs{ + schemaRef("PasscodeSettingsComponent"), + }, + Properties: openapi3.Schemas{"identifier": {Value: openapi3.NewStringSchema()}}, + }}, + "PasscodeSettingsComponent": {Value: &openapi3.Schema{ + Type: types("object"), + Properties: openapi3.Schemas{"configuration": schemaRef("PasscodeSettingsConfiguration")}, + }}, + "PasscodeSettingsConfiguration": {Value: &openapi3.Schema{ + Type: types("object"), + Properties: openapi3.Schemas{"MinimumLength": schemaRef("minimum_length")}, + }}, + "minimum_length": {Value: openapi3.NewObjectSchema()}, + "Unreferenced": {Value: openapi3.NewObjectSchema()}, + }}} + // The walker reads nested refs off the document, so the variant and + // configuration refs above have to resolve through Components rather than + // through the placeholder value schemaRef carries. + doc.Components.Schemas["PasscodeSettingsComponent"].Value.Properties["configuration"].Value = + doc.Components.Schemas["PasscodeSettingsConfiguration"].Value + + seed, err := lenientScalarSeed(doc, []string{"Component"}) + if err != nil { + t.Fatalf("lenientScalarSeed: %v", err) + } + for _, want := range []string{"PasscodeSettingsComponent", "PasscodeSettingsConfiguration", "MinimumLength"} { + if !seed[want] { + t.Errorf("seed is missing %s; got %v", want, sortedKeys(seed)) + } + } + if seed["Unreferenced"] { + t.Errorf("seed reached a schema no root references: %v", sortedKeys(seed)) + } +} + +// A root that resolves to nothing removes the tolerance from its whole subtree +// with no other signal, which is exactly the silent regression this key exists +// to prevent — so an unknown name is a build failure, not a skip. +func TestLenientScalarSeedRefusesAnUnknownRoot(t *testing.T) { + doc := &openapi3.T{Components: &openapi3.Components{Schemas: openapi3.Schemas{ + "Component": {Value: openapi3.NewObjectSchema()}, + }}} + _, err := lenientScalarSeed(doc, []string{"Component", "Renamed"}) + if err == nil { + t.Fatal("an unknown root should fail generation") + } + if !strings.Contains(err.Error(), "Renamed") { + t.Errorf("error = %v, want it to name the missing root", err) + } + if strings.Contains(err.Error(), "Component") { + t.Errorf("error = %v, want only the missing root named", err) + } +} + +// The seed is schema names; the tolerance is emitted against Go types. A type +// reached only through another type's field — a hoisted inline object, or one +// the seed named under a different spelling — has to come in through the +// closure or its scalars go untreated. +func TestLenientScalarTypesClosesOverFieldReferences(t *testing.T) { + types := []GoType{ + {Name: "SoftwareUpdateSettingsConfiguration", Fields: []GoField{ + {Name: "Deferrals", Type: "*Deferrals", JSONTag: "Deferrals,omitempty"}, + {Name: "Version", Type: "int", JSONTag: "version"}, + }}, + {Name: "Deferrals", Fields: []GoField{ + {Name: "MajorPeriodInDays", Type: "*OptionalPeriodInDays", JSONTag: "MajorPeriodInDays,omitempty"}, + }}, + {Name: "OptionalPeriodInDays", Fields: []GoField{ + {Name: "Included", Type: "*bool", JSONTag: "Included,omitempty"}, + {Name: "Value", Type: "*int", JSONTag: "Value,omitempty"}, + }}, + {Name: "Elsewhere", Fields: []GoField{ + {Name: "Count", Type: "int", JSONTag: "count"}, + }}, + } + got := lenientScalarTypes(types, map[string]bool{"SoftwareUpdateSettingsConfiguration": true}) + + var names []string + for _, e := range got { + names = append(names, e.Name) + } + // Deferrals declares no scalar of its own, so it carries the closure + // without getting a decoder. + want := []string{"SoftwareUpdateSettingsConfiguration", "OptionalPeriodInDays"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("types = %v, want %v", names, want) + } + if keys := got[1].Keys; len(keys) != 2 || + keys[0].JSON != "Included" || keys[0].Kind != jsonScalarKindBool || + keys[1].JSON != "Value" || keys[1].Kind != jsonScalarKindNumber { + t.Errorf("OptionalPeriodInDays keys = %+v, want Included/bool then Value/number", keys) + } +} + +// The wire name is what the rewrite matches on, so the options after it have +// to come off — and a field that travels under no key at all cannot be found +// by a rewrite keyed on one. +func TestJSONTagName(t *testing.T) { + cases := map[string]string{ + "Value,omitempty": "Value", + "Value": "Value", + "-": "", + "-,": "-", + ",omitempty": "", + } + for tag, want := range cases { + if got := jsonTagName(tag); got != want { + t.Errorf("jsonTagName(%q) = %q, want %q", tag, got, want) + } + } +} + +// A root that resolves but reaches no scalar is a claim about a store that no +// longer needs it. Failing is what deletes the config entry. +func TestValidateLenientScalars(t *testing.T) { + if err := validateLenientScalars("package blueprints", nil, nil); err != nil { + t.Errorf("no roots and no entries should pass: %v", err) + } + if err := validateLenientScalars("package blueprints", []string{"Component"}, + []lenientType{{Name: "T"}}); err != nil { + t.Errorf("roots with entries should pass: %v", err) + } + err := validateLenientScalars("package blueprints", []string{"Component"}, nil) + if err == nil { + t.Fatal("roots that reach no scalar should fail generation") + } + if !strings.Contains(err.Error(), "Component") { + t.Errorf("error = %v, want it to name the root", err) + } +} From 99ab5746109affcd4f16b78eb5b5794f3ee3284d Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Tue, 15 Sep 2026 10:04:41 +0100 Subject: [PATCH 2/2] fix(generate): name the field a lenient decode failed on, and guard what it skips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review of #76. The tolerance was right and the diagnostics around it were not. encoding/json returns a nested Unmarshaler's error verbatim, so once a leaf owned UnmarshalJSON the accumulated field path was gone: a failure read `OptionalPeriodInDays.Value`, and Deferrals declares four fields of that one type. The path it dropped is the one #431 was diagnosed from, while docs/STYLE.md claimed the error still named what a caller could look up. A type now earns a decoder when a coerced scalar lies at *or below* it, so the ancestors get one with an empty key map, and attributeFieldError probes each present key on the error path to name the field. 43 decoders become 67 and a UI-written "none" reads: Deferrals.SystemPeriodInDays: OptionalPeriodInDays.Value: json: cannot unmarshal string into Go value of type int Attribution runs against the *rewritten* body, so a mixed fault names the key the coercion could not fix rather than the one it already did. Three things the closure could not see, each now handled rather than assumed: - A union carries its variants in Discriminator/Union and declares no Fields at all, so the Go-space walk stopped dead at SwUpdateConfiguration and never reached SwUpdateLatestConfiguration.enforceAfterDays. lenientRefs reads the variants back. - A type that already has a generated UnmarshalJSON cannot have a second one, so unions are excluded — reported, because attribution stops there, and a hard generation failure if one also declares a coerced scalar, because then the tolerance itself would be lost. - A slice or map of scalars is skipped on an observation about writers, so every such field is now named at generation time instead of silently uncovered. The no-scalar guard is per root. It was checking the package aggregate, so a second root that lost every scalar upstream would have been covered by a sibling's entries and never expired. Two mutations that survived the suite now fail it. Removing the leading-byte check in jsonScalarLiteral's number branch was invisible because every case in the bad-list is also rejected by json.Valid; what only that check rejects is valid JSON that is not a number, and a quoted "null" rewritten to bare null decodes as a silent no-op into the zero value. Reporting the first error instead of the post-rewrite one was invisible because no case had one coercible and one uncoercible fault. Also generated: a test that decodes through a union's own dispatch rather than into the leaf directly, and one asserting every coerced key names a real field tag — the equality test cannot see a wrong key, since encoding/json ignores it on the bare and quoted side alike. 118 subtests become 245. The godoc no longer points consumers at an unexported function, and states the round-trip asymmetry: these types accept an encoding they never emit, so a read-modify-write rewrites what the store was holding. The retry's cost is recorded rather than reduced — it re-decodes the whole object, bounded at 2x and paid only on the cold path, where decoding key by key would tax every conforming body instead. The acceptance test's must-fail create now registers a cleanup, so the one outcome the assertion exists to catch no longer leaves a blueprint behind. make test, make lint and go vet -tags acceptance ./jamfplatform/ are clean, and generation is idempotent through the api/ fallback. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 8 +- docs/STYLE.md | 123 +- docs/WIRE-FACTS.md | 7 +- jamfplatform/acc_blueprint_test.go | 12 +- jamfplatform/blueprints/lenient_scalars.go | 1054 ++++++++++++++--- .../blueprints/lenient_scalars_test.go | 197 ++- tools/generate/config.go | 25 +- tools/generate/emit.go | 48 +- tools/generate/lenient.go | 889 ++++++++++++-- tools/generate/lenient_test.go | 228 +++- 10 files changed, 2256 insertions(+), 335 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef20d474..eabd4516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1836,10 +1836,14 @@ formatting is inert to the generator, and bundle diffs become exact. surfaced as `terraform-provider-jamfplatform#431` ("cannot unmarshal string into … MajorPeriodInDays.Value of type int", the whole component dropped from state) with every SDK test passing. `config.lenientScalarRoots` names the - `Component` union and the generator emits 43 tolerant `UnmarshalJSON` methods + `Component` union and the generator emits 67 tolerant `UnmarshalJSON` methods across its subtree: **zero change to any field type, signature, marshalled body or `api/*.json`**, so nothing downstream recompiles and the SDK keeps - writing the spec's own encoding. Do **not** reach for this for a spec/wire + writing the spec's own encoding. Only some of the 67 coerce anything — the + rest exist because `encoding/json` returns a nested `Unmarshaler`'s error + verbatim, so without a decoder on the parent a failure names only the + child's own type, and `Deferrals` declares four fields of the identical + `OptionalPeriodInDays`. Do **not** reach for this for a spec/wire *type* disagreement — that is `fieldTypeOverrides` or a report upstream. The test is `TestAcceptance_Blueprint_UIWrittenScalarsDecode`, and it asserts the quoted form still arrives, so it fails the day the service starts diff --git a/docs/STYLE.md b/docs/STYLE.md index aff4f1bc..4783392f 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -125,7 +125,7 @@ deliberately kept three of them for exactly that reason. | `scopeTypes` | 3 | spec → the scope kinds its operations accept (`tenant`, `environment`, `organization`), overriding a published `x-scope-types` that understates what the gateway serves. Carried to each method's `Scopes` in the Privileges registry, never into `api/`, with `ScopesSource` recording that the value is a config correction rather than the spec's own claim. Self-expiring in both directions: generation fails once the spec declares the same set, and also once the spec declares anything the override omits — an override widens an understated spec, so a spec that has moved past it is about to lose a declared scope, which the equality check alone cannot see. The account trio is the only user: no account spec declares the extension at all. `securitycloud-devices` was the second until 2026-09-04, when its hold lifted and the entry self-expired | | `schemaPatches` | 3 | schema → dotted property path → raw OpenAPI 3 Schema. Adds or replaces at that path | | `requiredPrivileges` | 3 | `"METHOD /path"` → GA capability permissions, for an operation the **published spec declares none for** but an authoritative out-of-band source does. Carried straight to the generated registry with `Source: "gateway-policy"`, never into the spec document, so `api/` stays faithful to upstream. **Fails generation if the operation now declares `x-required-privileges`** — that means upstream published them and the entry must go. All three users are the `account` specs; see [Required privileges](#required-privileges) | -| `lenientScalarRoots` | 1 | component schema names whose **reachable subtree** decodes leniently: every number and boolean under them also accepts a JSON string carrying the same value. Emitted as one `UnmarshalJSON` per affected type in `lenient_scalars.go`; field types, marshalling and `api/` are untouched. For a store that serves back the JSON its *writer* sent rather than re-serialising from its own model — blueprints is that store and `Component` is the only user, covering 43 types. Self-expiring on the spec side only: generation fails when a root names no declared schema, and when a root's subtree reaches no scalar. It cannot expire on the server being fixed, so the acceptance test carries that half. See [Lenient scalar decoding](#lenient-scalar-decoding) | +| `lenientScalarRoots` | 1 | component schema names whose **reachable subtree** decodes leniently: every number and boolean under them also accepts a JSON string carrying the same value. Emitted as one `UnmarshalJSON` per affected type in `lenient_scalars.go`; field types, marshalling and `api/` are untouched. For a store that serves back the JSON its *writer* sent rather than re-serialising from its own model — blueprints is that store and `Component` is the only user, covering 67 types — the ones that declare a coerced scalar, plus every ancestor, which carries the field name into a child's decode error. Self-expiring on the spec side only, and **per root**: generation fails when a root names no declared schema, and when *that root's* subtree reaches no scalar. It cannot expire on the server being fixed, so the acceptance test carries that half. See [Lenient scalar decoding](#lenient-scalar-decoding) | | `enumAdditions` | 1 | schema name → wire values to append to its `enum`, for a value the server produces or accepts that the spec omits. Applied with the other schema patches, so the value reaches `api/` too. **Panics when the value is already declared**, when the schema declares no enum, or when the schema is missing — a duplicated enum member would emit two identical constants and compile, so nothing else would ever notice. One user: `Region` gaining `RAMP` | | `emitNullForOptional` | 2 | schema names whose optional pointer fields must marshal as explicit `null` when nil rather than being omitted. For servers that distinguish "omitted" (keep) from "present and null" (clear). Accepts snake_case or PascalCase; JSON marshalling only | | `propertyRenames` | 3 | schema → dotted path → new key. Repairs a spec key that doesn't match the wire, which otherwise decodes silently to nothing. **Panics on a missing path**, so it self-expires the day upstream adopts the wire's name. Carries the property's `required` entry with it, and rewrites the key inside the spec's own **examples** too — otherwise `api/*.json` publishes a schema declaring one name beside an example showing the other. The example rewrite is shape-guarded: an object is touched only when it carries the old key *and* every key it has is a property of the target schema, because a property name is not unique across a spec (`categories`, `users` and `security_name` are all renamed somewhere in Classic). `DomainAllocationConnection.authRegion` → `region` is the worked example, and the only one that matches an example today | @@ -835,46 +835,101 @@ takes roots rather than type names for exactly that reason: the covered set is derived and cannot drift from the spec. **Two passes, because the seed is schema names and the emission is Go types.** -`lenientScalarTypes` closes the seed over the emitted types' own field -references, so a hoisted inline object or a type the seed named under a -different spelling still comes in; then it selects, per reached type, the -fields whose Go type is a number or a boolean (a numeric-enum alias counts, a -string enum does not, and slices and maps are excluded — a struct leaf is -decoded by its own method). A type that carries the closure but declares no -scalar of its own — `Deferrals`, whose four fields are all -`*OptionalPeriodInDays` — correctly gets no decoder. - -Four properties of the emitted decoder are load-bearing: +`lenientScalarTypes` closes the seed over the emitted types' own references, so +a hoisted inline object or a type the seed named under a different spelling +still comes in; then it selects, per reached type, the fields whose Go type is +a number or a boolean (a numeric-enum alias counts, a string enum does not, and +slices and maps are excluded — a struct leaf is decoded by its own method). + +**The closure follows a union's variants, not just its fields.** A discriminated +or request-body union carries its variant pointers in `Discriminator` or +`Union` and declares **no `Fields` at all**, so a walk that reads only `Fields` +stops dead at one: `SwUpdateConfiguration` has none, and +`SwUpdateLatestConfiguration.enforceAfterDays` is unreachable through it. +`lenientRefs` reads the variants back, which is the one place the schema seed +and the Go-space closure would otherwise disagree about what a type contains. + +**A type earns a decoder when a coerced scalar lies at *or below* it, so the +ancestors get one too.** `encoding/json` hands a nested value straight to that +type's `UnmarshalJSON` and returns whatever comes back, so a child's failure +arrives naming only the child's own type — and `Deferrals` declares four fields +of the identical `OptionalPeriodInDays`, which makes that error useless. The +parent's decoder exists to put the field name back, so `Deferrals` does get +one, with an empty key map. That is why the count is 67 rather than the 43 that +coerce anything. + +Five properties of the emitted decoder are load-bearing: - **The strict decode runs first.** A conforming body costs one error check. The rewrite only happens on failure, and a nested value has already been fixed by its own type's method during that first attempt — which is why each - type only describes its own keys and no walk of the tree is needed. + type only describes its own keys and no walk of the tree is needed. Note what + that does *not* claim about cost: the retry re-decodes the whole object, so a + quoted value on a type's own key runs every composite child's decoder a + second time. `PasscodeSettingsConfiguration`, + `DiskManagementSettingsConfiguration` and `CustomDeclaration` are that shape. + It is bounded at 2x and paid only on the cold path; decoding key by key + instead would tax every conforming body to save a failing one. - **Only a JSON string is rewritten, and only into the declared kind.** `jsonScalarLiteral` re-emits the text verbatim rather than parsing and - reformatting it, so a value wider than `float64` keeps its digits; `json.Valid` - plus a leading-byte check is what decides it is a JSON number token, which - rules out the forms Go's own parsers accept and JSON does not (`Inf`, `NaN`, - hex floats, a leading `+`, `01`). So `"abc"` for an integer still fails the - decode — it must, since decoding it to zero would turn a visible fault into a - silently wrong configuration, and the server refused it on the way in anyway. -- **Marshalling is untouched.** The SDK keeps sending the numbers and booleans - the spec declares; `api/*.json` is byte-identical. The tolerance is in the - decode, not in the surface a consumer programs against — no field type moved, - so nothing downstream recompiles. -- **The error still names the real type.** Each decoder decodes into a local - type named `lenient` to shed the method and avoid recursing, and - `encoding/json` reports the Go type it was decoding, so `namedStructError` - puts the real name back. Without it a genuine failure reads `Go struct field - lenient.Value`, which names nothing a caller can look up. - -**Self-expiry is one-sided and the acceptance test carries the other half.** -Generation fails when a root names no declared schema (a rename or withdrawal -upstream, which would otherwise drop the tolerance from a whole subtree -silently) and when a root's subtree reaches no scalar. Nothing in a spec says -how a store serialises, so no config mechanism can see the server being fixed: + reformatting it, so a value wider than `float64` keeps its digits. Two checks + decide it and **both** are load-bearing: `json.Valid` rules out the forms + Go's own parsers accept and JSON does not (`Inf`, `NaN`, hex floats, a + leading `+`, `01`), and the leading-byte check rules out the text that *is* + valid JSON but is not a number (`null`, `true`, `false`, an array, an + object). Without the second, a quoted `"null"` would be rewritten to bare + `null`, which decodes into a non-pointer field as a silent no-op and leaves + the zero value. So `"abc"` for an integer still fails the decode — it must, + since decoding it to zero would turn a visible fault into a silently wrong + configuration, and the server refused it on the way in anyway. +- **Marshalling is untouched, which makes the tolerance one-directional.** The + SDK keeps sending the numbers and booleans the spec declares; `api/*.json` is + byte-identical. The tolerance is in the decode, not in the surface a consumer + programs against — no field type moved, so nothing downstream recompiles. + The consequence is worth stating to a consumer and the generated godoc does: + decoding a quoted value and writing the struct back emits the bare scalar, so + a read-modify-write through these types rewrites the encoding the store was + holding. +- **The error names the field it came from.** `namedStructError` puts the real + type name back in place of the local `lenient` alias each decoder decodes + into, and `attributeFieldError` puts the field back: on the error path it + decodes each present key on its own into a fresh value of that field's type, + and the first key that reproduces the failure is the one named. It decides + nothing — a failure no probe reproduces is returned unattributed rather than + guessed at. A UI-written `"none"` now reads + `Deferrals.SystemPeriodInDays: OptionalPeriodInDays.Value: json: cannot + unmarshal string into Go value of type int`. +- **Attribution against the *rewritten* body, not the original.** Whenever a + rewrite happened the reported error describes the fixed document, so probing + the original would blame the key the rewrite already repaired — on + `{"version":"9","Restrictions":123}` it would name `version` instead of + `Restrictions`. + +**A type that already has a generated `UnmarshalJSON` cannot have a second +one**, so a discriminated union, a request-body union and a raw-JSON schema are +excluded. The exclusion is not silent: each such type reached by a root is +reported at generation time, because **attribution stops there** — a union's +variant fields carry `json:"-"`, so nothing below it can be probed. And a union +that *also* declares a coerced scalar **fails generation**, since there the +tolerance itself would be lost rather than merely the field name. + +**A slice or map of scalars is skipped and reported.** The coercion does not +reach into a composite, and the justification for that is an observation about +writers rather than a guarantee, so `lenientScalarTypes` names every such field +it skipped. There are none in blueprints today; a component that gained an +array-of-integer property would otherwise pass both guards below while leaving +that property completely uncovered. + +**Self-expiry is one-sided, per root, and the acceptance test carries the other +half.** Generation fails when a root names no declared schema (a rename or +withdrawal upstream, which would otherwise drop the tolerance from a whole +subtree silently) and when **that root's** subtree reaches no scalar. Per root +matters: the guard is a claim about one root, and merging the roots first would +let a sibling root's entries stand in for one that has lost every scalar, which +is the expiry becoming unreachable. Nothing in a spec says how a store +serialises, so no config mechanism can see the server being fixed: `TestAcceptance_Blueprint_UIWrittenScalarsDecode` asserts the quoted form still -arrives, and fails the day it stops — which is when the config entry and the 43 +arrives, and fails the day it stops — which is when the config entry and the 67 generated decoders can go. ## A shared schema's optionality follows its request/response reachability diff --git a/docs/WIRE-FACTS.md b/docs/WIRE-FACTS.md index c4f8a778..91b4ddfd 100644 --- a/docs/WIRE-FACTS.md +++ b/docs/WIRE-FACTS.md @@ -1836,9 +1836,12 @@ Note the field path in the `MAX` errors is lowerCamelCase while the path and bean validation reports the Java property path. Neither is a rename. **The SDK's fix is a read-side coercion emitted per type**, driven by -`config.lenientScalarRoots` naming the `Component` union — 43 generated +`config.lenientScalarRoots` naming the `Component` union — 67 generated `UnmarshalJSON` methods across the subtree, no change to any field type, -signature or marshalled body. Mechanism: +signature or marshalled body. A failure names the field it came from — +`Deferrals.SystemPeriodInDays: OptionalPeriodInDays.Value: json: cannot +unmarshal string into Go value of type int` — which is the shape the original +diagnosis rested on. Mechanism: [STYLE.md](STYLE.md#lenient-scalar-decoding). The generated decoders coerce a JSON string only when the text is a valid JSON scalar of the declared kind, so `"abc"` still fails — which costs nothing, the server having refused it on the diff --git a/jamfplatform/acc_blueprint_test.go b/jamfplatform/acc_blueprint_test.go index 8de2ae47..7ebf3e68 100644 --- a/jamfplatform/acc_blueprint_test.go +++ b/jamfplatform/acc_blueprint_test.go @@ -679,7 +679,7 @@ func TestAcceptance_Blueprint_TypedComponents(t *testing.T) { // // The raw-body assertion is the part that expires: the day the service starts // normalising to its own model, the quoted form stops arriving, this fails, -// and config's lenientScalarRoots entry and the 43 generated decoders behind +// and config's lenientScalarRoots entry and the 67 generated decoders behind // it can go. The typed decode either side of it is the regression itself. func TestAcceptance_Blueprint_UIWrittenScalarsDecode(t *testing.T) { groupID := requireSmartGroupFixture(t) @@ -787,9 +787,12 @@ func TestAcceptance_Blueprint_UIWrittenScalarsDecode(t *testing.T) { // this narrow: the service does validate the document it stores, so a // string that is not a number never reaches a later read in the first // place. Checked with a create that must fail, which leaves nothing - // behind when it does. + // behind when it does — and which registers a cleanup for the one + // outcome this assertion exists to catch, since an accepted body would + // otherwise leave a real blueprint on the tenant with nothing to delete + // it. desc := "SDK acceptance test — must be refused" - _, err := bp.CreateBlueprint(ctx, &blueprints.CreateBlueprintRequest{ + refused, err := bp.CreateBlueprint(ctx, &blueprints.CreateBlueprintRequest{ Name: "sdk-acc-ui-scalars-refused-" + runSuffix(), Description: &desc, Scope: blueprints.CreateScope{DeviceGroups: []string{groupID}}, @@ -802,6 +805,9 @@ func TestAcceptance_Blueprint_UIWrittenScalarsDecode(t *testing.T) { }}, }) if err == nil { + if refused != nil { + cleanupDelete(t, "DeleteBlueprint", func() error { return bp.DeleteBlueprint(ctx, refused.ID) }) + } t.Error("a non-numeric string for an integer property was accepted; the store no longer " + "validates what it echoes, so a read can now carry a value no consumer can decode") } diff --git a/jamfplatform/blueprints/lenient_scalars.go b/jamfplatform/blueprints/lenient_scalars.go index 0fe1ccea..19968b78 100644 --- a/jamfplatform/blueprints/lenient_scalars.go +++ b/jamfplatform/blueprints/lenient_scalars.go @@ -11,12 +11,19 @@ // the spec declares — the tolerance is in the decode, not in the surface a // consumer programs against — and so this file is the one place to read for // what the coercion does and does not do. +// +// The tolerance is one-directional. Marshalling is untouched, so decoding a +// quoted scalar and writing the struct back sends the bare value the spec +// declares, and the store then holds that encoding instead. package blueprints import ( "encoding/json" "errors" + "fmt" + "reflect" + "strings" ) // jsonScalarKind names the JSON scalar a property is declared as. @@ -29,36 +36,110 @@ const ( jsonScalarBool ) -// unmarshalLenientScalars decodes data into v, accepting a JSON string -// wherever keys declares a number or a boolean. +// unmarshalLenient decodes data into v, accepting a JSON string wherever keys +// declares a number or a boolean, and naming the field a failure came from. // -// The strict decode is tried first and the rewrite only happens when it fails, -// so a conforming body costs one extra error check and nothing else. A nested -// value is fixed by its own type's method during that first attempt, which is -// why each type only has to describe its own keys. +// The strict decode is tried first, so a conforming body costs one error check +// and nothing else. A nested value has already been fixed by its own type's +// method during that attempt, which is why each type only has to describe its +// own keys. Note what that does not say about cost: the retry re-decodes the +// whole object, so a quoted value on this type's own key runs every composite +// child's decoder a second time. The alternative — decoding key by key on the +// success path — would tax every conforming body to save a cold one. // // Three properties worth relying on. Only the listed keys are considered, so a // string the spec declares as a string is never touched. Only a JSON string is // rewritten, and only when the text it carries is a valid JSON scalar of the // declared kind — so "abc" for an integer still fails the decode rather than // arriving as zero. And marshalling is untouched: the SDK keeps sending the -// numbers and booleans the spec declares. -func unmarshalLenientScalars(data []byte, v any, keys map[string]jsonScalarKind, name string) error { +// numbers and booleans the spec declares, which makes the tolerance +// one-directional. Decoding a quoted value and writing the struct back emits +// the bare scalar, so a round trip through these types rewrites the encoding +// the store was holding. +func unmarshalLenient(data []byte, v any, keys map[string]jsonScalarKind, name string) error { err := json.Unmarshal(data, v) if err == nil { return nil } - fixed, rewritten := unquoteJSONScalars(data, keys) - if rewritten { + // body is what the reported error describes, which is the rewritten + // document whenever a rewrite happened. Attributing against the original + // would blame the key the rewrite already fixed. + body := data + if fixed, rewritten := unquoteJSONScalars(data, keys); rewritten { // The second error is the one to report when it comes: the rewrite // has handled the encoding the first error described, so what is left // is a fault the coercion has nothing to do with. - err = json.Unmarshal(fixed, v) - if err == nil { + retryErr := json.Unmarshal(fixed, v) + if retryErr == nil { return nil } + err, body = retryErr, fixed } - return namedStructError(err, name) + return attributeFieldError(body, v, err, name) +} + +// attributeFieldError prefixes err with the field the failure came from. +// +// encoding/json hands a nested value straight to that type's UnmarshalJSON and +// returns whatever it gets back, so a child decoder's error arrives with no +// record of the field it travelled through. Deferrals declares four fields of +// the identical OptionalPeriodInDays type, so the error alone cannot say which +// one failed — and that path is how the decode bug this whole mechanism exists +// to fix was diagnosed. +// +// It runs only on the error path, and it decides nothing: it decodes each +// present key on its own into a fresh value of that field's type, and the +// first key that reproduces a failure is the one to name. A key whose probe +// succeeds is left alone, and a failure no probe reproduces is returned +// unattributed rather than guessed at. +func attributeFieldError(data []byte, v any, err error, name string) error { + err = namedStructError(err, name) + var obj map[string]json.RawMessage + if json.Unmarshal(data, &obj) != nil { + return err + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Struct { + return err + } + t := rv.Elem().Type() + for i := range t.NumField() { + f := t.Field(i) + if f.PkgPath != "" { + continue + } + key := jsonFieldName(f) + if key == "" { + continue + } + raw, present := obj[key] + if !present { + continue + } + probe := reflect.New(f.Type) + if fieldErr := json.Unmarshal(raw, probe.Interface()); fieldErr != nil { + return fmt.Errorf("%s.%s: %w", name, key, fieldErr) + } + } + return err +} + +// jsonFieldName returns the wire name a struct field travels under, following +// encoding/json's own rule for the dash: a tag of exactly "-" skips the field, +// while "-," names it "-". An absent tag leaves the field name. +func jsonFieldName(f reflect.StructField) string { + tag, ok := f.Tag.Lookup("json") + if !ok { + return f.Name + } + if tag == "-" { + return "" + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + return f.Name + } + return name } // namedStructError puts the real type name back into a decode error. @@ -67,6 +148,8 @@ func unmarshalLenientScalars(data []byte, v any, keys map[string]jsonScalarKind, // method and avoid recursing, and encoding/json reports the *Go* type it was // decoding — so without this a caller reads "json: cannot unmarshal string // into Go struct field lenient.Value", which names nothing they can look up. +// It restores the type name only; the field path is what attributeFieldError +// puts back. func namedStructError(err error, name string) error { var typeErr *json.UnmarshalTypeError if errors.As(err, &typeErr) && typeErr.Struct == "lenient" { @@ -121,9 +204,14 @@ func unquoteJSONScalars(data []byte, keys map[string]jsonScalarKind) ([]byte, bo // // The number branch re-emits the text verbatim rather than parsing and // reformatting it, so a value wider than float64 keeps every digit and a -// decimal keeps its exact spelling; json.Valid plus the leading-byte check is -// what decides it is a JSON number token, which rules out the forms Go's own -// parsers accept and JSON does not (Inf, NaN, hex floats, a leading +). +// decimal keeps its exact spelling. Two checks decide it, and both are +// load-bearing: json.Valid rules out the forms Go's own parsers accept and +// JSON does not (Inf, NaN, hex floats, a leading +, 01), and the leading-byte +// check rules out the values that are valid JSON but are not numbers (null, +// true, false, an array, an object). Without the second, a quoted "null" +// would be rewritten to bare null, which decodes into a non-pointer field as +// a silent no-op and leaves the zero value — the one outcome this whole +// mechanism must never produce. func jsonScalarLiteral(s string, kind jsonScalarKind) (string, bool) { switch kind { case jsonScalarNumber: @@ -147,12 +235,16 @@ var lenientScalarsAcceptCookies = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AcceptCookies) UnmarshalJSON(data []byte) error { type lenient AcceptCookies var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAcceptCookies, "AcceptCookies"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAcceptCookies, "AcceptCookies"); err != nil { return err } *s = AcceptCookies(v) @@ -165,12 +257,16 @@ var lenientScalarsAllowDisablingFraudWarning = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AllowDisablingFraudWarning) UnmarshalJSON(data []byte) error { type lenient AllowDisablingFraudWarning var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowDisablingFraudWarning, "AllowDisablingFraudWarning"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAllowDisablingFraudWarning, "AllowDisablingFraudWarning"); err != nil { return err } *s = AllowDisablingFraudWarning(v) @@ -183,12 +279,16 @@ var lenientScalarsAllowHistoryClearing = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AllowHistoryClearing) UnmarshalJSON(data []byte) error { type lenient AllowHistoryClearing var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowHistoryClearing, "AllowHistoryClearing"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAllowHistoryClearing, "AllowHistoryClearing"); err != nil { return err } *s = AllowHistoryClearing(v) @@ -201,12 +301,16 @@ var lenientScalarsAllowJavaScript = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AllowJavaScript) UnmarshalJSON(data []byte) error { type lenient AllowJavaScript var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowJavaScript, "AllowJavaScript"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAllowJavaScript, "AllowJavaScript"); err != nil { return err } *s = AllowJavaScript(v) @@ -219,12 +323,16 @@ var lenientScalarsAllowPopups = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AllowPopups) UnmarshalJSON(data []byte) error { type lenient AllowPopups var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowPopups, "AllowPopups"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAllowPopups, "AllowPopups"); err != nil { return err } *s = AllowPopups(v) @@ -237,12 +345,16 @@ var lenientScalarsAllowPrivateBrowsing = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AllowPrivateBrowsing) UnmarshalJSON(data []byte) error { type lenient AllowPrivateBrowsing var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowPrivateBrowsing, "AllowPrivateBrowsing"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAllowPrivateBrowsing, "AllowPrivateBrowsing"); err != nil { return err } *s = AllowPrivateBrowsing(v) @@ -255,47 +367,122 @@ var lenientScalarsAllowSummary = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AllowSummary) UnmarshalJSON(data []byte) error { type lenient AllowSummary var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAllowSummary, "AllowSummary"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAllowSummary, "AllowSummary"); err != nil { return err } *s = AllowSummary(v) return nil } +// lenientScalarsAudioAccessorySettingsComponent names the scalars AudioAccessorySettingsComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsAudioAccessorySettingsComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *AudioAccessorySettingsComponent) UnmarshalJSON(data []byte) error { + type lenient AudioAccessorySettingsComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsAudioAccessorySettingsComponent, "AudioAccessorySettingsComponent"); err != nil { + return err + } + *s = AudioAccessorySettingsComponent(v) + return nil +} + +// lenientScalarsAudioAccessorySettingsConfiguration names the scalars AudioAccessorySettingsConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsAudioAccessorySettingsConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *AudioAccessorySettingsConfiguration) UnmarshalJSON(data []byte) error { + type lenient AudioAccessorySettingsConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsAudioAccessorySettingsConfiguration, "AudioAccessorySettingsConfiguration"); err != nil { + return err + } + *s = AudioAccessorySettingsConfiguration(v) + return nil +} + // lenientScalarsAutomaticAction names the scalars AutomaticAction declares, for its UnmarshalJSON. var lenientScalarsAutomaticAction = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *AutomaticAction) UnmarshalJSON(data []byte) error { type lenient AutomaticAction var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsAutomaticAction, "AutomaticAction"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsAutomaticAction, "AutomaticAction"); err != nil { return err } *s = AutomaticAction(v) return nil } +// lenientScalarsAutomaticActions names the scalars AutomaticActions declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsAutomaticActions = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *AutomaticActions) UnmarshalJSON(data []byte) error { + type lenient AutomaticActions + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsAutomaticActions, "AutomaticActions"); err != nil { + return err + } + *s = AutomaticActions(v) + return nil +} + // lenientScalarsBasicMode names the scalars BasicMode declares, for its UnmarshalJSON. var lenientScalarsBasicMode = map[string]jsonScalarKind{ "AddSquareRoot": jsonScalarBool, "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *BasicMode) UnmarshalJSON(data []byte) error { type lenient BasicMode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsBasicMode, "BasicMode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsBasicMode, "BasicMode"); err != nil { return err } *s = BasicMode(v) @@ -307,30 +494,59 @@ var lenientScalarsBeta = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *Beta) UnmarshalJSON(data []byte) error { type lenient Beta var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsBeta, "Beta"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsBeta, "Beta"); err != nil { return err } *s = Beta(v) return nil } +// lenientScalarsCalculator names the scalars Calculator declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsCalculator = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *Calculator) UnmarshalJSON(data []byte) error { + type lenient Calculator + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsCalculator, "Calculator"); err != nil { + return err + } + *s = Calculator(v) + return nil +} + // lenientScalarsChangeAtNextAuth names the scalars ChangeAtNextAuth declares, for its UnmarshalJSON. var lenientScalarsChangeAtNextAuth = map[string]jsonScalarKind{ "Included": jsonScalarBool, "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *ChangeAtNextAuth) UnmarshalJSON(data []byte) error { type lenient ChangeAtNextAuth var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsChangeAtNextAuth, "ChangeAtNextAuth"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsChangeAtNextAuth, "ChangeAtNextAuth"); err != nil { return err } *s = ChangeAtNextAuth(v) @@ -342,63 +558,163 @@ var lenientScalarsCustomDeclaration = map[string]jsonScalarKind{ "payloadKey": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *CustomDeclaration) UnmarshalJSON(data []byte) error { type lenient CustomDeclaration var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsCustomDeclaration, "CustomDeclaration"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsCustomDeclaration, "CustomDeclaration"); err != nil { return err } *s = CustomDeclaration(v) return nil } +// lenientScalarsCustomDeclarationsComponent names the scalars CustomDeclarationsComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsCustomDeclarationsComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *CustomDeclarationsComponent) UnmarshalJSON(data []byte) error { + type lenient CustomDeclarationsComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsCustomDeclarationsComponent, "CustomDeclarationsComponent"); err != nil { + return err + } + *s = CustomDeclarationsComponent(v) + return nil +} + +// lenientScalarsCustomDeclarationsConfiguration names the scalars CustomDeclarationsConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsCustomDeclarationsConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *CustomDeclarationsConfiguration) UnmarshalJSON(data []byte) error { + type lenient CustomDeclarationsConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsCustomDeclarationsConfiguration, "CustomDeclarationsConfiguration"); err != nil { + return err + } + *s = CustomDeclarationsConfiguration(v) + return nil +} + // lenientScalarsCustomRegex names the scalars CustomRegex declares, for its UnmarshalJSON. var lenientScalarsCustomRegex = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *CustomRegex) UnmarshalJSON(data []byte) error { type lenient CustomRegex var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsCustomRegex, "CustomRegex"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsCustomRegex, "CustomRegex"); err != nil { return err } *s = CustomRegex(v) return nil } +// lenientScalarsDeferrals names the scalars Deferrals declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsDeferrals = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *Deferrals) UnmarshalJSON(data []byte) error { + type lenient Deferrals + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsDeferrals, "Deferrals"); err != nil { + return err + } + *s = Deferrals(v) + return nil +} + // lenientScalarsDetailsURL names the scalars DetailsURL declares, for its UnmarshalJSON. var lenientScalarsDetailsURL = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *DetailsURL) UnmarshalJSON(data []byte) error { type lenient DetailsURL var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsDetailsURL, "DetailsURL"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsDetailsURL, "DetailsURL"); err != nil { return err } *s = DetailsURL(v) return nil } +// lenientScalarsDiskManagementComponent names the scalars DiskManagementComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsDiskManagementComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *DiskManagementComponent) UnmarshalJSON(data []byte) error { + type lenient DiskManagementComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsDiskManagementComponent, "DiskManagementComponent"); err != nil { + return err + } + *s = DiskManagementComponent(v) + return nil +} + // lenientScalarsDiskManagementSettingsConfiguration names the scalars DiskManagementSettingsConfiguration declares, for its UnmarshalJSON. var lenientScalarsDiskManagementSettingsConfiguration = map[string]jsonScalarKind{ "version": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *DiskManagementSettingsConfiguration) UnmarshalJSON(data []byte) error { type lenient DiskManagementSettingsConfiguration var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsDiskManagementSettingsConfiguration, "DiskManagementSettingsConfiguration"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsDiskManagementSettingsConfiguration, "DiskManagementSettingsConfiguration"); err != nil { return err } *s = DiskManagementSettingsConfiguration(v) @@ -411,12 +727,16 @@ var lenientScalarsFailedAttemptsResetInMinutes = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *FailedAttemptsResetInMinutes) UnmarshalJSON(data []byte) error { type lenient FailedAttemptsResetInMinutes var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsFailedAttemptsResetInMinutes, "FailedAttemptsResetInMinutes"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsFailedAttemptsResetInMinutes, "FailedAttemptsResetInMinutes"); err != nil { return err } *s = FailedAttemptsResetInMinutes(v) @@ -430,12 +750,16 @@ var lenientScalarsInputModes = map[string]jsonScalarKind{ "UnitConversion": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *InputModes) UnmarshalJSON(data []byte) error { type lenient InputModes var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsInputModes, "InputModes"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsInputModes, "InputModes"); err != nil { return err } *s = InputModes(v) @@ -450,29 +774,79 @@ var lenientScalarsManagedAppAttributes = map[string]jsonScalarKind{ "TapToPayScreenLock": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *ManagedAppAttributes) UnmarshalJSON(data []byte) error { type lenient ManagedAppAttributes var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsManagedAppAttributes, "ManagedAppAttributes"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsManagedAppAttributes, "ManagedAppAttributes"); err != nil { return err } *s = ManagedAppAttributes(v) return nil } +// lenientScalarsManagedAppComponent names the scalars ManagedAppComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsManagedAppComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *ManagedAppComponent) UnmarshalJSON(data []byte) error { + type lenient ManagedAppComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsManagedAppComponent, "ManagedAppComponent"); err != nil { + return err + } + *s = ManagedAppComponent(v) + return nil +} + +// lenientScalarsManagedAppConfiguration names the scalars ManagedAppConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsManagedAppConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *ManagedAppConfiguration) UnmarshalJSON(data []byte) error { + type lenient ManagedAppConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsManagedAppConfiguration, "ManagedAppConfiguration"); err != nil { + return err + } + *s = ManagedAppConfiguration(v) + return nil +} + // lenientScalarsManagedAppEntry names the scalars ManagedAppEntry declares, for its UnmarshalJSON. var lenientScalarsManagedAppEntry = map[string]jsonScalarKind{ "IncludeInBackup": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *ManagedAppEntry) UnmarshalJSON(data []byte) error { type lenient ManagedAppEntry var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsManagedAppEntry, "ManagedAppEntry"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsManagedAppEntry, "ManagedAppEntry"); err != nil { return err } *s = ManagedAppEntry(v) @@ -485,30 +859,80 @@ var lenientScalarsMathNotesMode = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MathNotesMode) UnmarshalJSON(data []byte) error { type lenient MathNotesMode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMathNotesMode, "MathNotesMode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMathNotesMode, "MathNotesMode"); err != nil { return err } *s = MathNotesMode(v) return nil } +// lenientScalarsMathSettingsComponent names the scalars MathSettingsComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsMathSettingsComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *MathSettingsComponent) UnmarshalJSON(data []byte) error { + type lenient MathSettingsComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsMathSettingsComponent, "MathSettingsComponent"); err != nil { + return err + } + *s = MathSettingsComponent(v) + return nil +} + +// lenientScalarsMathSettingsConfiguration names the scalars MathSettingsConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsMathSettingsConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *MathSettingsConfiguration) UnmarshalJSON(data []byte) error { + type lenient MathSettingsConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsMathSettingsConfiguration, "MathSettingsConfiguration"); err != nil { + return err + } + *s = MathSettingsConfiguration(v) + return nil +} + // lenientScalarsMaximumFailedAttempts names the scalars MaximumFailedAttempts declares, for its UnmarshalJSON. var lenientScalarsMaximumFailedAttempts = map[string]jsonScalarKind{ "Included": jsonScalarBool, "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MaximumFailedAttempts) UnmarshalJSON(data []byte) error { type lenient MaximumFailedAttempts var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumFailedAttempts, "MaximumFailedAttempts"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMaximumFailedAttempts, "MaximumFailedAttempts"); err != nil { return err } *s = MaximumFailedAttempts(v) @@ -521,12 +945,16 @@ var lenientScalarsMaximumGracePeriodInMinutes = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MaximumGracePeriodInMinutes) UnmarshalJSON(data []byte) error { type lenient MaximumGracePeriodInMinutes var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumGracePeriodInMinutes, "MaximumGracePeriodInMinutes"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMaximumGracePeriodInMinutes, "MaximumGracePeriodInMinutes"); err != nil { return err } *s = MaximumGracePeriodInMinutes(v) @@ -539,12 +967,16 @@ var lenientScalarsMaximumInactivityInMinutes = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MaximumInactivityInMinutes) UnmarshalJSON(data []byte) error { type lenient MaximumInactivityInMinutes var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumInactivityInMinutes, "MaximumInactivityInMinutes"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMaximumInactivityInMinutes, "MaximumInactivityInMinutes"); err != nil { return err } *s = MaximumInactivityInMinutes(v) @@ -557,12 +989,16 @@ var lenientScalarsMaximumPasscodeAgeInDays = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MaximumPasscodeAgeInDays) UnmarshalJSON(data []byte) error { type lenient MaximumPasscodeAgeInDays var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMaximumPasscodeAgeInDays, "MaximumPasscodeAgeInDays"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMaximumPasscodeAgeInDays, "MaximumPasscodeAgeInDays"); err != nil { return err } *s = MaximumPasscodeAgeInDays(v) @@ -575,12 +1011,16 @@ var lenientScalarsMinimumComplexCharacters = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MinimumComplexCharacters) UnmarshalJSON(data []byte) error { type lenient MinimumComplexCharacters var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMinimumComplexCharacters, "MinimumComplexCharacters"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMinimumComplexCharacters, "MinimumComplexCharacters"); err != nil { return err } *s = MinimumComplexCharacters(v) @@ -593,12 +1033,16 @@ var lenientScalarsMinimumLength = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *MinimumLength) UnmarshalJSON(data []byte) error { type lenient MinimumLength var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsMinimumLength, "MinimumLength"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsMinimumLength, "MinimumLength"); err != nil { return err } *s = MinimumLength(v) @@ -610,12 +1054,16 @@ var lenientScalarsNewTabStartPage = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *NewTabStartPage) UnmarshalJSON(data []byte) error { type lenient NewTabStartPage var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsNewTabStartPage, "NewTabStartPage"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsNewTabStartPage, "NewTabStartPage"); err != nil { return err } *s = NewTabStartPage(v) @@ -628,12 +1076,16 @@ var lenientScalarsOptionalPeriodInDays = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *OptionalPeriodInDays) UnmarshalJSON(data []byte) error { type lenient OptionalPeriodInDays var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsOptionalPeriodInDays, "OptionalPeriodInDays"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsOptionalPeriodInDays, "OptionalPeriodInDays"); err != nil { return err } *s = OptionalPeriodInDays(v) @@ -646,12 +1098,16 @@ var lenientScalarsOptionallyEnabled = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *OptionallyEnabled) UnmarshalJSON(data []byte) error { type lenient OptionallyEnabled var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsOptionallyEnabled, "OptionallyEnabled"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsOptionallyEnabled, "OptionallyEnabled"); err != nil { return err } *s = OptionallyEnabled(v) @@ -664,29 +1120,58 @@ var lenientScalarsPasscodeReuseLimit = map[string]jsonScalarKind{ "Value": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *PasscodeReuseLimit) UnmarshalJSON(data []byte) error { type lenient PasscodeReuseLimit var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsPasscodeReuseLimit, "PasscodeReuseLimit"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsPasscodeReuseLimit, "PasscodeReuseLimit"); err != nil { return err } *s = PasscodeReuseLimit(v) return nil } +// lenientScalarsPasscodeSettingsComponent names the scalars PasscodeSettingsComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsPasscodeSettingsComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *PasscodeSettingsComponent) UnmarshalJSON(data []byte) error { + type lenient PasscodeSettingsComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsPasscodeSettingsComponent, "PasscodeSettingsComponent"); err != nil { + return err + } + *s = PasscodeSettingsComponent(v) + return nil +} + // lenientScalarsPasscodeSettingsConfiguration names the scalars PasscodeSettingsConfiguration declares, for its UnmarshalJSON. var lenientScalarsPasscodeSettingsConfiguration = map[string]jsonScalarKind{ "version": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *PasscodeSettingsConfiguration) UnmarshalJSON(data []byte) error { type lenient PasscodeSettingsConfiguration var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsPasscodeSettingsConfiguration, "PasscodeSettingsConfiguration"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsPasscodeSettingsConfiguration, "PasscodeSettingsConfiguration"); err != nil { return err } *s = PasscodeSettingsConfiguration(v) @@ -699,29 +1184,58 @@ var lenientScalarsProgrammerMode = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *ProgrammerMode) UnmarshalJSON(data []byte) error { type lenient ProgrammerMode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsProgrammerMode, "ProgrammerMode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsProgrammerMode, "ProgrammerMode"); err != nil { return err } *s = ProgrammerMode(v) return nil } +// lenientScalarsRapidSecurityResponse names the scalars RapidSecurityResponse declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsRapidSecurityResponse = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *RapidSecurityResponse) UnmarshalJSON(data []byte) error { + type lenient RapidSecurityResponse + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsRapidSecurityResponse, "RapidSecurityResponse"); err != nil { + return err + } + *s = RapidSecurityResponse(v) + return nil +} + // lenientScalarsRecommendedCadence names the scalars RecommendedCadence declares, for its UnmarshalJSON. var lenientScalarsRecommendedCadence = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *RecommendedCadence) UnmarshalJSON(data []byte) error { type lenient RecommendedCadence var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsRecommendedCadence, "RecommendedCadence"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsRecommendedCadence, "RecommendedCadence"); err != nil { return err } *s = RecommendedCadence(v) @@ -734,12 +1248,16 @@ var lenientScalarsRequireAlphanumericPasscode = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *RequireAlphanumericPasscode) UnmarshalJSON(data []byte) error { type lenient RequireAlphanumericPasscode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsRequireAlphanumericPasscode, "RequireAlphanumericPasscode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsRequireAlphanumericPasscode, "RequireAlphanumericPasscode"); err != nil { return err } *s = RequireAlphanumericPasscode(v) @@ -752,12 +1270,16 @@ var lenientScalarsRequireComplexPasscode = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *RequireComplexPasscode) UnmarshalJSON(data []byte) error { type lenient RequireComplexPasscode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsRequireComplexPasscode, "RequireComplexPasscode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsRequireComplexPasscode, "RequireComplexPasscode"); err != nil { return err } *s = RequireComplexPasscode(v) @@ -770,70 +1292,254 @@ var lenientScalarsRequirePasscode = map[string]jsonScalarKind{ "Value": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *RequirePasscode) UnmarshalJSON(data []byte) error { type lenient RequirePasscode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsRequirePasscode, "RequirePasscode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsRequirePasscode, "RequirePasscode"); err != nil { return err } *s = RequirePasscode(v) return nil } +// lenientScalarsRestrictions names the scalars Restrictions declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsRestrictions = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *Restrictions) UnmarshalJSON(data []byte) error { + type lenient Restrictions + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsRestrictions, "Restrictions"); err != nil { + return err + } + *s = Restrictions(v) + return nil +} + +// lenientScalarsSafariSettingsComponent names the scalars SafariSettingsComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSafariSettingsComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SafariSettingsComponent) UnmarshalJSON(data []byte) error { + type lenient SafariSettingsComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSafariSettingsComponent, "SafariSettingsComponent"); err != nil { + return err + } + *s = SafariSettingsComponent(v) + return nil +} + +// lenientScalarsSafariSettingsConfiguration names the scalars SafariSettingsConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSafariSettingsConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SafariSettingsConfiguration) UnmarshalJSON(data []byte) error { + type lenient SafariSettingsConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSafariSettingsConfiguration, "SafariSettingsConfiguration"); err != nil { + return err + } + *s = SafariSettingsConfiguration(v) + return nil +} + // lenientScalarsScientificMode names the scalars ScientificMode declares, for its UnmarshalJSON. var lenientScalarsScientificMode = map[string]jsonScalarKind{ "Enabled": jsonScalarBool, "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *ScientificMode) UnmarshalJSON(data []byte) error { type lenient ScientificMode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsScientificMode, "ScientificMode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsScientificMode, "ScientificMode"); err != nil { return err } *s = ScientificMode(v) return nil } +// lenientScalarsSoftwareUpdateSettingsComponent names the scalars SoftwareUpdateSettingsComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSoftwareUpdateSettingsComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SoftwareUpdateSettingsComponent) UnmarshalJSON(data []byte) error { + type lenient SoftwareUpdateSettingsComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSoftwareUpdateSettingsComponent, "SoftwareUpdateSettingsComponent"); err != nil { + return err + } + *s = SoftwareUpdateSettingsComponent(v) + return nil +} + +// lenientScalarsSoftwareUpdateSettingsConfiguration names the scalars SoftwareUpdateSettingsConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSoftwareUpdateSettingsConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SoftwareUpdateSettingsConfiguration) UnmarshalJSON(data []byte) error { + type lenient SoftwareUpdateSettingsConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSoftwareUpdateSettingsConfiguration, "SoftwareUpdateSettingsConfiguration"); err != nil { + return err + } + *s = SoftwareUpdateSettingsConfiguration(v) + return nil +} + // lenientScalarsStorageMode names the scalars StorageMode declares, for its UnmarshalJSON. var lenientScalarsStorageMode = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *StorageMode) UnmarshalJSON(data []byte) error { type lenient StorageMode var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsStorageMode, "StorageMode"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsStorageMode, "StorageMode"); err != nil { return err } *s = StorageMode(v) return nil } +// lenientScalarsSwUpdateComponent names the scalars SwUpdateComponent declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSwUpdateComponent = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SwUpdateComponent) UnmarshalJSON(data []byte) error { + type lenient SwUpdateComponent + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSwUpdateComponent, "SwUpdateComponent"); err != nil { + return err + } + *s = SwUpdateComponent(v) + return nil +} + // lenientScalarsSwUpdateLatestConfiguration names the scalars SwUpdateLatestConfiguration declares, for its UnmarshalJSON. var lenientScalarsSwUpdateLatestConfiguration = map[string]jsonScalarKind{ "enforceAfterDays": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *SwUpdateLatestConfiguration) UnmarshalJSON(data []byte) error { type lenient SwUpdateLatestConfiguration var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsSwUpdateLatestConfiguration, "SwUpdateLatestConfiguration"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsSwUpdateLatestConfiguration, "SwUpdateLatestConfiguration"); err != nil { return err } *s = SwUpdateLatestConfiguration(v) return nil } +// lenientScalarsSwUpdateManualConfiguration names the scalars SwUpdateManualConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSwUpdateManualConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SwUpdateManualConfiguration) UnmarshalJSON(data []byte) error { + type lenient SwUpdateManualConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSwUpdateManualConfiguration, "SwUpdateManualConfiguration"); err != nil { + return err + } + *s = SwUpdateManualConfiguration(v) + return nil +} + +// lenientScalarsSwUpdateSemanticConfiguration names the scalars SwUpdateSemanticConfiguration declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsSwUpdateSemanticConfiguration = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *SwUpdateSemanticConfiguration) UnmarshalJSON(data []byte) error { + type lenient SwUpdateSemanticConfiguration + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsSwUpdateSemanticConfiguration, "SwUpdateSemanticConfiguration"); err != nil { + return err + } + *s = SwUpdateSemanticConfiguration(v) + return nil +} + // lenientScalarsSystemBehavior names the scalars SystemBehavior declares, for its UnmarshalJSON. var lenientScalarsSystemBehavior = map[string]jsonScalarKind{ "Included": jsonScalarBool, @@ -841,12 +1547,16 @@ var lenientScalarsSystemBehavior = map[string]jsonScalarKind{ "MathNotes": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *SystemBehavior) UnmarshalJSON(data []byte) error { type lenient SystemBehavior var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsSystemBehavior, "SystemBehavior"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsSystemBehavior, "SystemBehavior"); err != nil { return err } *s = SystemBehavior(v) @@ -859,29 +1569,58 @@ var lenientScalarsTemporaryPairing = map[string]jsonScalarKind{ "Included": jsonScalarBool, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *TemporaryPairing) UnmarshalJSON(data []byte) error { type lenient TemporaryPairing var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsTemporaryPairing, "TemporaryPairing"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsTemporaryPairing, "TemporaryPairing"); err != nil { return err } *s = TemporaryPairing(v) return nil } +// lenientScalarsTemporaryPairingConfig names the scalars TemporaryPairingConfig declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsTemporaryPairingConfig = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *TemporaryPairingConfig) UnmarshalJSON(data []byte) error { + type lenient TemporaryPairingConfig + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsTemporaryPairingConfig, "TemporaryPairingConfig"); err != nil { + return err + } + *s = TemporaryPairingConfig(v) + return nil +} + // lenientScalarsUnpairingTime names the scalars UnpairingTime declares, for its UnmarshalJSON. var lenientScalarsUnpairingTime = map[string]jsonScalarKind{ "Hour": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *UnpairingTime) UnmarshalJSON(data []byte) error { type lenient UnpairingTime var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsUnpairingTime, "UnpairingTime"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsUnpairingTime, "UnpairingTime"); err != nil { return err } *s = UnpairingTime(v) @@ -893,14 +1632,39 @@ var lenientScalarsUpdateRule = map[string]jsonScalarKind{ "enforceAfterDays": jsonScalarNumber, } -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *UpdateRule) UnmarshalJSON(data []byte) error { type lenient UpdateRule var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalarsUpdateRule, "UpdateRule"); err != nil { + if err := unmarshalLenient(data, &v, lenientScalarsUpdateRule, "UpdateRule"); err != nil { return err } *s = UpdateRule(v) return nil } + +// lenientScalarsUpdateRules names the scalars UpdateRules declares, for its UnmarshalJSON. +// It declares none of its own: the decoder exists to name the field a +// child decoder failed on. +var lenientScalarsUpdateRules = map[string]jsonScalarKind{} + +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. +func (s *UpdateRules) UnmarshalJSON(data []byte) error { + type lenient UpdateRules + var v lenient + if err := unmarshalLenient(data, &v, lenientScalarsUpdateRules, "UpdateRules"); err != nil { + return err + } + *s = UpdateRules(v) + return nil +} diff --git a/jamfplatform/blueprints/lenient_scalars_test.go b/jamfplatform/blueprints/lenient_scalars_test.go index 99e6422d..b37cb1af 100644 --- a/jamfplatform/blueprints/lenient_scalars_test.go +++ b/jamfplatform/blueprints/lenient_scalars_test.go @@ -22,6 +22,25 @@ type lenientScalarCase struct { keys map[string]jsonScalarKind } +// lenientUnionCase is one path from a discriminated union down to a leaf that +// carries a coerced number, rendered as the flat object the union's own +// UnmarshalJSON dispatches on. +type lenientUnionCase struct { + name string + typ reflect.Type + discrim [][2]string + scalarJSON string +} + +// lenientParentCase is one parent type paired with a child field carrying a +// coerced number, for the attribution assertion. +type lenientParentCase struct { + name string + typ reflect.Type + childJSON string + childKey string +} + // body renders a JSON object setting every key in the case, quoting the values // when quoted is true. Keys are emitted in sorted order so a failure names the // same body every run. @@ -45,6 +64,22 @@ func (c lenientScalarCase) body(quoted bool) string { return "{" + strings.Join(parts, ",") + "}" } +// body renders the union path as one flat object: every discriminator on the +// way down plus the leaf's own scalar, which is what the dispatch actually +// receives since a union hands the same bytes to the variant it selects. +func (c lenientUnionCase) body(quoted bool) string { + parts := make([]string, 0, len(c.discrim)+1) + for _, d := range c.discrim { + parts = append(parts, fmt.Sprintf("%q:%q", d[0], d[1])) + } + lit := "1" + if quoted { + lit = `"1"` + } + parts = append(parts, fmt.Sprintf("%q:%s", c.scalarJSON, lit)) + return "{" + strings.Join(parts, ",") + "}" +} + // TestLenientScalars_StringEncodingDecodesToTheSameValue is the regression for // a store that echoes its writer's JSON: every number and boolean under a // lenientScalarRoots root must decode from the quoted form to exactly what the @@ -69,6 +104,81 @@ func TestLenientScalars_StringEncodingDecodesToTheSameValue(t *testing.T) { } } +// TestLenientScalars_KeysAreRealFieldTags pins that every coerced key names a +// field the type actually declares. The equality test above cannot see this: +// a key matching no field is ignored by encoding/json on both the bare and the +// quoted side, so both decodes land on the same untouched zero value and the +// comparison passes with nothing coerced. +func TestLenientScalars_KeysAreRealFieldTags(t *testing.T) { + for _, c := range lenientScalarCases { + t.Run(c.name, func(t *testing.T) { + declared := make(map[string]bool) + for i := range c.typ.NumField() { + if name := jsonFieldName(c.typ.Field(i)); name != "" { + declared[name] = true + } + } + for key := range c.keys { + if !declared[key] { + t.Errorf("coerced key %q names no field of %s", key, c.name) + } + } + }) + } +} + +// TestLenientScalars_UnionDispatchReachesTheLenientLeaf decodes through a +// discriminated union's own UnmarshalJSON rather than into the leaf directly. +// Every other test here constructs the leaf type itself, so a discriminator +// template that stopped delegating to a variant's own UnmarshalJSON — by +// decoding into a value copy, say — would ship with the whole suite green. +func TestLenientScalars_UnionDispatchReachesTheLenientLeaf(t *testing.T) { + if len(lenientUnionCases) == 0 { + t.Skip("no discriminated union in this package reaches a coerced number") + } + for _, c := range lenientUnionCases { + t.Run(c.name, func(t *testing.T) { + bare := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(false)), bare.Interface()); err != nil { + t.Fatalf("decoding the bare form %s: %v", c.body(false), err) + } + quoted := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(true)), quoted.Interface()); err != nil { + t.Fatalf("decoding the string form %s through the union: %v", c.body(true), err) + } + if !reflect.DeepEqual(bare.Elem().Interface(), quoted.Elem().Interface()) { + t.Fatalf("the union dispatched the two encodings differently:\n bare: %#v\n quoted: %#v", + bare.Elem().Interface(), quoted.Elem().Interface()) + } + }) + } +} + +// TestLenientScalars_FailureNamesTheChildField is the regression for the +// diagnostic half. encoding/json returns a nested Unmarshaler's error verbatim, +// so before the parent decoders a failure inside a child arrived naming only +// the child's own type — and a parent declaring several fields of that one type +// left the caller unable to tell which field was at fault. +func TestLenientScalars_FailureNamesTheChildField(t *testing.T) { + if len(lenientParentCases) == 0 { + t.Skip("no emitted type has a child carrying a coerced number") + } + for _, c := range lenientParentCases { + t.Run(c.name+"/"+c.childJSON, func(t *testing.T) { + body := fmt.Sprintf(`{%q:{%q:"not-a-number"}}`, c.childJSON, c.childKey) + v := reflect.New(c.typ) + err := json.Unmarshal([]byte(body), v.Interface()) + if err == nil { + t.Fatalf("decoding %s should fail", body) + } + want := c.name + "." + c.childJSON + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not name the field %q: %v", want, err) + } + }) + } +} + // TestLenientScalars_NonScalarStringStillFails pins the boundary. The coercion // unquotes a string only when the text it carries is a valid JSON scalar of the // declared kind, so a genuinely wrong value has to keep failing the decode — @@ -92,6 +202,32 @@ func TestLenientScalars_NonScalarStringStillFails(t *testing.T) { } } +// TestLenientScalars_ValidJSONNonNumberStillFails is the other half of that +// boundary, and the half json.Valid cannot carry. "null", "true" and the +// bracket forms are all valid JSON, so only the leading-byte check rejects +// them — and a quoted "null" rewritten to bare null decodes into a +// non-pointer field as a silent no-op, which is the one outcome the coercion +// must never produce. +func TestLenientScalars_ValidJSONNonNumberStillFails(t *testing.T) { + for _, c := range lenientScalarCases { + for name, kind := range c.keys { + if kind != jsonScalarNumber { + continue + } + for _, bad := range []string{"null", "true", "false", "[]", "{}"} { + t.Run(c.name+"/"+name+"/"+bad, func(t *testing.T) { + v := reflect.New(c.typ) + body := fmt.Sprintf(`{%q:%q}`, name, bad) + if err := json.Unmarshal([]byte(body), v.Interface()); err == nil { + t.Fatalf("decoding %s succeeded; %q is valid JSON but not a number", body, bad) + } + }) + } + break + } + } +} + // TestLenientScalars_UnlistedKeysAreUntouched pins that the rewrite is keyed on // the type's own declared scalars: a string the spec declares as a string must // survive verbatim, which is what stops the coercion mangling prose that @@ -121,7 +257,7 @@ func TestLenientScalars_NonObjectBodyKeepsTheOriginalError(t *testing.T) { var target struct { Count int `json:"count"` } - err := unmarshalLenientScalars([]byte(`["not","an","object"]`), &target, + err := unmarshalLenient([]byte(`["not","an","object"]`), &target, map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe") if err == nil { t.Fatal("decoding a JSON array into a struct should fail") @@ -131,6 +267,31 @@ func TestLenientScalars_NonObjectBodyKeepsTheOriginalError(t *testing.T) { } } +// TestLenientScalars_ReportsThePostRewriteError pins which of the two errors a +// caller sees. The rewrite has already handled the encoding the first error +// described, so reporting that one blames a key the coercion fixed and hides +// the fault that is actually left. +func TestLenientScalars_ReportsThePostRewriteError(t *testing.T) { + type child struct { + N int `json:"n"` + } + var target struct { + Count int `json:"count"` + Child *child `json:"child"` + } + err := unmarshalLenient([]byte(`{"count":"9","child":123}`), &target, + map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe") + if err == nil { + t.Fatal("decoding an integer into a struct field should fail") + } + if !strings.Contains(err.Error(), "child") { + t.Errorf("error = %v, want it to name child, the fault the rewrite did not fix", err) + } + if strings.Contains(err.Error(), "count") { + t.Errorf("error = %v, names count, which the rewrite already fixed", err) + } +} + // TestLenientScalars_WideNumberKeepsItsDigits pins that the number branch // re-emits the text rather than parsing and reformatting it, so a value beyond // float64's exact range is not silently rounded on the way through. @@ -139,7 +300,7 @@ func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { Count int64 `json:"count"` } const want = 9007199254740993 - if err := unmarshalLenientScalars([]byte(`{"count":"9007199254740993"}`), &target, + if err := unmarshalLenient([]byte(`{"count":"9007199254740993"}`), &target, map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe"); err != nil { t.Fatalf("decoding: %v", err) } @@ -148,21 +309,30 @@ func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { } } -// TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers pins the forms Go's -// own parsers accept and JSON does not. Every one of these would decode -// through strconv and none of them is a JSON number token. +// TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers pins both halves of +// the number check. The first group is what json.Valid rejects: forms Go's own +// parsers accept and JSON does not. The second is what only the leading-byte +// check rejects: text that is valid JSON but is not a number. func TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers(t *testing.T) { for _, s := range []string{"", " ", "+1", "1_0", "0x10", "1e", "Inf", "NaN", "01", ".5", "1.2.3"} { if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); ok { t.Errorf("jsonScalarLiteral(%q) = %q, true; want rejected", s, lit) } } + for _, s := range []string{"null", "true", "false", "[]", "{}", `"5"`, "[1,2]"} { + if !json.Valid([]byte(s)) { + t.Fatalf("%q is meant to be valid JSON; the case has stopped testing the leading-byte check", s) + } + if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); ok { + t.Errorf("jsonScalarLiteral(%q) = %q, true; want rejected — valid JSON, not a number", s, lit) + } + } for _, s := range []string{"0", "-1", "1.5", "1e5", "-1.5e-3", "90"} { if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); !ok || lit != s { t.Errorf("jsonScalarLiteral(%q) = %q, %v; want %q, true", s, lit, ok, s) } } - for _, s := range []string{"True", "TRUE", "1", "", "yes"} { + for _, s := range []string{"True", "TRUE", "1", "", "yes", "null"} { if lit, ok := jsonScalarLiteral(s, jsonScalarBool); ok { t.Errorf("jsonScalarLiteral(%q, bool) = %q, true; want rejected", s, lit) } @@ -238,3 +408,18 @@ var lenientScalarCases = []lenientScalarCase{ {name: "UnpairingTime", typ: reflect.TypeOf(UnpairingTime{}), keys: lenientScalarsUnpairingTime}, {name: "UpdateRule", typ: reflect.TypeOf(UpdateRule{}), keys: lenientScalarsUpdateRule}, } + +var lenientUnionCases = []lenientUnionCase{ + {name: "SwUpdateAutomaticConfiguration/LATEST", typ: reflect.TypeOf(SwUpdateAutomaticConfiguration{}), scalarJSON: "enforceAfterDays", discrim: [][2]string{{"strategy", "LATEST"}}}, + {name: "SwUpdateConfiguration/AUTOMATIC/LATEST", typ: reflect.TypeOf(SwUpdateConfiguration{}), scalarJSON: "enforceAfterDays", discrim: [][2]string{{"enforcementType", "AUTOMATIC"}, {"strategy", "LATEST"}}}, +} + +var lenientParentCases = []lenientParentCase{ + {name: "CustomDeclarationsConfiguration", typ: reflect.TypeOf(CustomDeclarationsConfiguration{}), childJSON: "declarations", childKey: "payloadKey"}, + {name: "Deferrals", typ: reflect.TypeOf(Deferrals{}), childJSON: "CombinedPeriodInDays", childKey: "Value"}, + {name: "DiskManagementComponent", typ: reflect.TypeOf(DiskManagementComponent{}), childJSON: "configuration", childKey: "version"}, + {name: "PasscodeSettingsComponent", typ: reflect.TypeOf(PasscodeSettingsComponent{}), childJSON: "configuration", childKey: "version"}, + {name: "PasscodeSettingsConfiguration", typ: reflect.TypeOf(PasscodeSettingsConfiguration{}), childJSON: "FailedAttemptsResetInMinutes", childKey: "Value"}, + {name: "TemporaryPairingConfig", typ: reflect.TypeOf(TemporaryPairingConfig{}), childJSON: "UnpairingTime", childKey: "Hour"}, + {name: "UpdateRules", typ: reflect.TypeOf(UpdateRules{}), childJSON: "minor", childKey: "enforceAfterDays"}, +} diff --git a/tools/generate/config.go b/tools/generate/config.go index 7be9a3b5..59c63e43 100644 --- a/tools/generate/config.go +++ b/tools/generate/config.go @@ -324,13 +324,24 @@ type SpecDef struct { // a type list: the set it covers is derived, and cannot drift from the // spec. // - // Self-expiring in one direction only. Generation fails when a root names - // no schema the spec declares, which is what catches a rename or a - // withdrawal upstream, and when a root's subtree reaches no scalar at all. - // It cannot expire on the server being fixed — nothing in a spec says how - // a store serialises — so the acceptance test is what carries that: - // TestAcceptance_Blueprint_UIWrittenScalarsDecode asserts the wire still - // returns a string, and fails the day it stops. + // A type earns a decoder when a coerced scalar lies at *or below* it, so + // the ancestors get one too, with an empty key map. encoding/json returns + // a nested Unmarshaler's error verbatim, so without one the failure names + // only the child's own type — and Deferrals declares four fields of the + // identical OptionalPeriodInDays. The parent's decoder is what puts the + // field name back. A type that already carries a generated UnmarshalJSON + // is excluded and reported, since a second one will not compile, and one + // that also declares a coerced scalar fails generation outright. + // + // Self-expiring in one direction only, and per root. Generation fails when + // a root names no schema the spec declares, which is what catches a rename + // or a withdrawal upstream, and when *that root's* subtree reaches no + // scalar at all — per root because the guard is a claim about one root, and + // merging first would let a sibling root's entries stand in for one that + // has lost every scalar. It cannot expire on the server being fixed — + // nothing in a spec says how a store serialises — so the acceptance test + // is what carries that: TestAcceptance_Blueprint_UIWrittenScalarsDecode + // asserts the wire still returns a string, and fails the day it stops. LenientScalarRoots []string `json:"lenientScalarRoots,omitempty"` // TagRenames remaps an OpenAPI tag before it picks the output filename, diff --git a/tools/generate/emit.go b/tools/generate/emit.go index 18acef6d..ac4dde88 100644 --- a/tools/generate/emit.go +++ b/tools/generate/emit.go @@ -456,8 +456,7 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) var allSpecs []specWithMethods pkgEmitted := make(map[string]bool) var allTypes []GoType - lenientSeed := make(map[string]bool) - var lenientRoots []string + lenientSeeds := make(map[string]map[string]bool) for _, ls := range specs { doc, err := loadSpec(ls.specPath, allowedOpsSet(ls.spec)) @@ -521,15 +520,21 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) return fmt.Errorf("%s: %w", spec.File, err) } // Seeded from the doc after every schema pass, so a hoisted inline - // object is already present under its emitted name. - seed, err := lenientScalarSeed(doc, spec.LenientScalarRoots) + // object is already present under its emitted name. Kept per root: the + // no-scalar guard is a claim about one root, and merging first would + // let a sibling root's entries hide a root that has lost every scalar. + seeds, err := lenientScalarSeeds(doc, spec.LenientScalarRoots) if err != nil { return fmt.Errorf("%s: %w", spec.File, err) } - for name := range seed { - lenientSeed[name] = true + for root, seed := range seeds { + if lenientSeeds[root] == nil { + lenientSeeds[root] = make(map[string]bool) + } + for name := range seed { + lenientSeeds[root][name] = true + } } - lenientRoots = append(lenientRoots, spec.LenientScalarRoots...) for _, t := range types { pkgEmitted[t.Name] = true } @@ -581,14 +586,15 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) if err := emitUnionRoundTripTest(pkgDir, goPkgName, structTypes); err != nil { return err } - lenientEntries := lenientScalarTypes(structTypes, lenientSeed) - if err := validateLenientScalars(fmt.Sprintf("package %s", pkgName), lenientRoots, lenientEntries); err != nil { + lenientEntries, lenientUnions, lenientParents, err := lenientScalarPlan( + fmt.Sprintf("package %s", pkgName), structTypes, lenientSeeds) + if err != nil { return err } if err := emitPkgLenientScalars(pkgDir, goPkgName, lenientEntries); err != nil { return err } - if err := emitPkgLenientScalarsTest(pkgDir, goPkgName, lenientEntries); err != nil { + if err := emitPkgLenientScalarsTest(pkgDir, goPkgName, lenientEntries, lenientUnions, lenientParents); err != nil { return err } @@ -655,8 +661,7 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, pkgEmitted := make(map[string]bool) var allTypes []GoType pkgFormat := "" - lenientSeed := make(map[string]bool) - var lenientRoots []string + lenientSeeds := make(map[string]map[string]bool) for _, ls := range specs { doc, err := loadSpec(ls.specPath, nil) @@ -702,14 +707,18 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, if err := applyDocNotes(types, ls.spec.DocNotes); err != nil { return fmt.Errorf("%s: %w", ls.spec.File, err) } - seed, err := lenientScalarSeed(doc, ls.spec.LenientScalarRoots) + seeds, err := lenientScalarSeeds(doc, ls.spec.LenientScalarRoots) if err != nil { return fmt.Errorf("%s: %w", ls.spec.File, err) } - for name := range seed { - lenientSeed[name] = true + for root, seed := range seeds { + if lenientSeeds[root] == nil { + lenientSeeds[root] = make(map[string]bool) + } + for name := range seed { + lenientSeeds[root][name] = true + } } - lenientRoots = append(lenientRoots, ls.spec.LenientScalarRoots...) // This path emits types and no methods, so there is nothing for a // method note to attach to. Refuse rather than skip: a silently // dropped note leaves the gap it was written to close. @@ -737,14 +746,15 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, if err := emitTypesOnlyTest(pkgDir, goPkgName, allTypes); err != nil { return err } - lenientEntries := lenientScalarTypes(structTypes, lenientSeed) - if err := validateLenientScalars("package "+goPkgName, lenientRoots, lenientEntries); err != nil { + lenientEntries, lenientUnions, lenientParents, err := lenientScalarPlan( + "package "+goPkgName, structTypes, lenientSeeds) + if err != nil { return err } if err := emitPkgLenientScalars(pkgDir, goPkgName, lenientEntries); err != nil { return err } - return emitPkgLenientScalarsTest(pkgDir, goPkgName, lenientEntries) + return emitPkgLenientScalarsTest(pkgDir, goPkgName, lenientEntries, lenientUnions, lenientParents) } // partitionEnumTypes splits emitted declarations into the structs and scalar diff --git a/tools/generate/lenient.go b/tools/generate/lenient.go index cc4f52d7..16dc57fa 100644 --- a/tools/generate/lenient.go +++ b/tools/generate/lenient.go @@ -6,6 +6,7 @@ package main import ( "fmt" "log" + "maps" "path/filepath" "slices" "strings" @@ -25,6 +26,11 @@ const ( // lenientType is one emitted Go struct that gets a tolerant UnmarshalJSON, // with the JSON keys to coerce and the kind each is declared as. +// +// Keys may be empty. A type earns a decoder either because it declares a +// scalar of its own or because a scalar lives somewhere below it, and in the +// second case the decoder exists only to name the field a child decoder +// failed on — see attributeFieldError in the emitted source. type lenientType struct { Name string Keys []lenientKey @@ -37,6 +43,45 @@ type lenientKey struct { Kind string } +// lenientUnionCase is one path from a discriminated union down to a leaf type +// that carries coerced keys, rendered as the flat JSON object the union's +// generated UnmarshalJSON dispatches on. +// +// It exists because a union hands the *same* bytes to the variant it selects, +// so every discriminator on the path and the leaf's own scalars live in one +// object. Nothing else in the generated tests decodes through that dispatch: +// the per-type table decodes each leaf directly, so a discriminator template +// that stopped delegating to a variant's own UnmarshalJSON would ship green. +type lenientUnionCase struct { + Name string // "SwUpdateConfiguration/AUTOMATIC/LATEST" + UnionType string // the type to decode into + Path string // dotted variant path, for the failure message + Discrim []lenientDiscrimValue + ScalarJSON string // the leaf's coerced key + ScalarKind string +} + +// lenientDiscrimValue is one discriminator property and the value that routes +// to the next type on the path. +type lenientDiscrimValue struct { + JSON string + Value string +} + +// lenientDiagnostics records what the plan deliberately left uncovered, so a +// gap is visible in the generator's own output rather than only in a comment. +type lenientDiagnostics struct { + // SkippedComposites names each "Type.jsonKey (goType)" whose leaf is a + // number or boolean inside a slice or a map. The coercion does not reach + // into a composite, and nothing else would report that it did not. + SkippedComposites []string + // OwnDecoder names each reached type that already carries a generated + // UnmarshalJSON — a discriminated union, a request-body union, or a raw + // JSON schema — and so cannot also carry a lenient one. Attribution stops + // at such a type. + OwnDecoder []string +} + // goScalarKind classifies a generated field type as the JSON scalar kind a // string-encoded value would have to be coerced to, or "" when the field is // not a scalar the coercion applies to. @@ -44,8 +89,10 @@ type lenientKey struct { // Composites are deliberately excluded rather than descended into: a field // whose leaf is a struct is decoded by that struct's own generated method, and // a slice or map of numbers is a shape no writer has been observed to -// string-encode. Named aliases over an integer base (a numeric enum) count, -// since the wire form is the same integer. +// string-encode. That second half is an observation rather than a guarantee, +// so compositeScalarKind below reports every such field the plan skips. +// Named aliases over an integer base (a numeric enum) count, since the wire +// form is the same integer. func goScalarKind(fieldType string, aliasBase map[string]string) string { // A pointer is the same scalar; a slice or map is not, so only `*` comes // off. normalizeTypeRef would strip the containers too. @@ -65,6 +112,27 @@ func goScalarKind(fieldType string, aliasBase map[string]string) string { return "" } +// compositeScalarKind classifies a field whose leaf is a scalar the coercion +// would cover but whose container it cannot reach into — a slice or a map of +// numbers or booleans. It returns "" for everything goScalarKind already +// answers for, and for a composite of anything else. +// +// The point is not to coerce these. It is that the exclusion rests on an +// observation about writers, and an observation needs a tripwire: a component +// that gains an array-of-integer property would otherwise pass both the +// missing-root and the no-scalar guard while leaving that property completely +// uncovered, which is the defect class the whole mechanism exists to fix. +func compositeScalarKind(fieldType string, aliasBase map[string]string) string { + if goScalarKind(fieldType, aliasBase) != "" { + return "" + } + bare := normalizeTypeRef(fieldType) + if bare == fieldType { + return "" + } + return goScalarKind(bare, aliasBase) +} + // numericEnumBases maps each emitted numeric-enum type name to its underlying // Go base type, so a field typed as one of them is still recognised as a // number. A string enum is absent: its wire form is already a JSON string. @@ -82,19 +150,23 @@ func numericEnumBases(types []GoType) map[string]string { return bases } -// lenientScalarSeed walks the schema graph from each named root and returns the -// Go type names its subtree reaches. Called with the doc after every schema -// pass has run, so hoisted inline objects are present under the names they -// will be emitted with. +// lenientScalarSeeds walks the schema graph from each named root and returns, +// per root, the Go type names its subtree reaches. Called with the doc after +// every schema pass has run, so hoisted inline objects are present under the +// names they will be emitted with. +// +// The result is keyed by root rather than merged because the no-scalar guard +// is a claim about one root: merging first makes a root that has lost every +// scalar indistinguishable from one whose sibling still has some. // // A root that names no schema in this spec is an error rather than a skip: the // key exists to carry a wire fact about a specific store, and a root that has // been renamed or withdrawn upstream silently drops the tolerance from every // type under it. -func lenientScalarSeed(doc *openapi3.T, roots []string) (map[string]bool, error) { - seed := make(map[string]bool) +func lenientScalarSeeds(doc *openapi3.T, roots []string) (map[string]map[string]bool, error) { + seeds := make(map[string]map[string]bool) if len(roots) == 0 { - return seed, nil + return seeds, nil } if doc.Components == nil || doc.Components.Schemas == nil { return nil, fmt.Errorf("lenientScalarRoots names %s but the spec declares no component schemas", @@ -106,6 +178,7 @@ func lenientScalarSeed(doc *openapi3.T, roots []string) (map[string]bool, error) missing = append(missing, root) continue } + seed := make(map[string]bool) visited := make(map[string]bool) walk := newSchemaWalker(doc, func(name string) bool { if visited[name] { @@ -116,6 +189,7 @@ func lenientScalarSeed(doc *openapi3.T, roots []string) (map[string]bool, error) return true }) walk(doc.Components.Schemas[root]) + seeds[root] = seed } if len(missing) > 0 { return nil, fmt.Errorf("lenientScalarRoots names %d schema(s) this spec does not declare: %s\n\n"+ @@ -123,18 +197,67 @@ func lenientScalarSeed(doc *openapi3.T, roots []string) (map[string]bool, error) "nothing removes the tolerance from its whole subtree with no other signal", len(missing), strings.Join(missing, ", ")) } - return seed, nil + return seeds, nil } -// lenientScalarTypes closes the seed over the emitted types' own field -// references and returns, in emission order, every struct that both lies in -// the closure and declares at least one number or boolean. +// lenientRefs lists every emitted type name this type can hold a value of. +// +// A union's variant fields are rendered by the template rather than carried in +// Fields, so Fields alone stops dead at a discriminated or request-body union: +// SwUpdateConfiguration declares none, and a walk that reads only Fields never +// reaches SwUpdateLatestConfiguration's enforceAfterDays through it. That is +// the one place the schema seed and the Go-space closure disagree about what a +// type contains, and reading the variants back is what closes it. +func lenientRefs(t GoType) []string { + var refs []string + for _, f := range t.Fields { + refs = append(refs, normalizeTypeRef(f.Type)) + } + if t.Discriminator != nil { + for _, v := range t.Discriminator.Variants { + refs = append(refs, v.TypeName) + } + } + if t.Union != nil { + for _, v := range t.Union.Variants { + refs = append(refs, v.TypeName) + } + } + return refs +} + +// ownsUnmarshalJSON reports whether the generator already emits an +// UnmarshalJSON for this type elsewhere, which makes a second one a +// redeclaration the package cannot compile. +// +// Three shapes do: a discriminated union and a request-body union both get a +// dispatching decoder from template.go, and a raw-JSON schema gets one that +// preserves the payload. Excluding them is not a silent skip — the plan +// records each in OwnDecoder, and a type that also declares a coerced scalar +// fails generation outright, because there the tolerance is genuinely lost +// rather than merely unattributable. +func ownsUnmarshalJSON(t GoType) bool { + return t.Discriminator != nil || t.Union != nil || t.IsRawJSON +} + +// lenientScalarTypes closes one root's seed over the emitted types' own field +// references and returns, in emission order, every struct that needs a +// tolerant decoder. // // The closure is needed because the seed is derived from schema names while // the tolerance is emitted against Go types: a field whose type was hoisted // out of an inline object, or renamed on the way to Go, is reachable only by // following the emitted field types. -func lenientScalarTypes(types []GoType, seed map[string]bool) []lenientType { +// +// A type needs a decoder when a coerced scalar lies at or below it. Below +// matters as much as at: encoding/json hands a nested value straight to that +// type's UnmarshalJSON and returns whatever comes back, so without a decoder +// on the parent a child's failure arrives naming only the child's own type — +// and Deferrals declares four fields of the identical OptionalPeriodInDays +// type, so that error cannot say which field failed. The parent's decoder +// exists to put the field name back. +func lenientScalarTypes(types []GoType, seed map[string]bool) ([]lenientType, lenientDiagnostics, error) { + var diags lenientDiagnostics byName := make(map[string]GoType, len(types)) for _, t := range types { byName[t.Name] = t @@ -154,8 +277,7 @@ func lenientScalarTypes(types []GoType, seed map[string]bool) []lenientType { continue } reached[name] = true - for _, f := range byName[name].Fields { - ref := normalizeTypeRef(f.Type) + for _, ref := range lenientRefs(byName[name]) { if _, ok := byName[ref]; ok && !reached[ref] { queue = append(queue, ref) } @@ -163,11 +285,7 @@ func lenientScalarTypes(types []GoType, seed map[string]bool) []lenientType { } aliasBase := numericEnumBases(types) - var out []lenientType - for _, t := range types { - if !reached[t.Name] || len(t.Fields) == 0 { - continue - } + keysOf := func(t GoType) []lenientKey { var keys []lenientKey for _, f := range t.Fields { kind := goScalarKind(f.Type, aliasBase) @@ -180,13 +298,160 @@ func lenientScalarTypes(types []GoType, seed map[string]bool) []lenientType { } keys = append(keys, lenientKey{JSON: name, Kind: kind}) } - if len(keys) == 0 { + slices.SortFunc(keys, func(a, b lenientKey) int { return strings.Compare(a.JSON, b.JSON) }) + return keys + } + + // reachesScalar memoises "a coerced scalar lies at or below this type", + // following field references inside the reached set only. A type that + // carries its own UnmarshalJSON still propagates: attribution cannot pass + // through it, but its ancestors are still worth a decoder. + state := make(map[string]int) // 0 unknown, 1 in progress, 2 false, 3 true + var reachesScalar func(name string) bool + reachesScalar = func(name string) bool { + switch state[name] { + case 1, 2: + return false // a cycle answers false on the way back down + case 3: + return true + } + state[name] = 1 + t := byName[name] + if len(keysOf(t)) > 0 { + state[name] = 3 + return true + } + for _, ref := range lenientRefs(t) { + if ref == name || !reached[ref] { + continue + } + if _, ok := byName[ref]; !ok { + continue + } + if reachesScalar(ref) { + state[name] = 3 + return true + } + } + state[name] = 2 + return false + } + + var out []lenientType + var refused []string + for _, t := range types { + // Not gated on len(t.Fields): a union carries its variants in + // Discriminator or Union rather than in Fields, so a field count of + // zero would skip exactly the types the OwnDecoder report exists for. + // reachesScalar is the real filter, and it answers false for a type + // that holds nothing. + if !reached[t.Name] { + continue + } + keys := keysOf(t) + for _, f := range t.Fields { + if kind := compositeScalarKind(f.Type, aliasBase); kind != "" { + if name := jsonTagName(f.JSONTag); name != "" { + diags.SkippedComposites = append(diags.SkippedComposites, + fmt.Sprintf("%s.%s (%s)", t.Name, name, f.Type)) + } + } + } + if ownsUnmarshalJSON(t) { + if len(keys) > 0 { + refused = append(refused, t.Name) + continue + } + if reachesScalar(t.Name) { + diags.OwnDecoder = append(diags.OwnDecoder, t.Name) + } + continue + } + if !reachesScalar(t.Name) { continue } - slices.SortFunc(keys, func(a, b lenientKey) int { return strings.Compare(a.JSON, b.JSON) }) out = append(out, lenientType{Name: t.Name, Keys: keys}) } - return out + if len(refused) > 0 { + return nil, diags, fmt.Errorf("lenientScalarRoots reaches %d type(s) that already carry a generated "+ + "UnmarshalJSON and also declare a coerced scalar: %s\n\n"+ + "fix: a second UnmarshalJSON on the same type will not compile, so the tolerance cannot be "+ + "emitted here. Teach the discriminator or union template to coerce the scalar itself, or move "+ + "the scalar out from under the union", + len(refused), strings.Join(refused, ", ")) + } + return out, diags, nil +} + +// lenientUnionCases builds one case per path from a discriminated union down to +// a leaf that carries a coerced number, so the generated test decodes through +// the union's own dispatch rather than only into the leaf directly. +// +// A union hands the same bytes to the variant it selects, so the whole path +// renders as one flat object: every discriminator property on the way plus the +// leaf's scalar. Paths are bounded by depth rather than by a visited set, since +// two different values can legitimately route to the same variant type. +func lenientUnionCases(types []GoType, entries []lenientType) []lenientUnionCase { + byName := make(map[string]GoType, len(types)) + for _, t := range types { + byName[t.Name] = t + } + keyed := make(map[string]lenientKey) + for _, e := range entries { + for _, k := range e.Keys { + if k.Kind == jsonScalarKindNumber { + keyed[e.Name] = k + break + } + } + } + + var cases []lenientUnionCase + // root is the type the case decodes into, which stays the entry point all + // the way down: a nested chain is still one object handed to the outer + // union, and a case naming the inner union would decode past the very + // dispatch it exists to exercise. + var walk func(root, union GoType, path []string, discrim []lenientDiscrimValue, depth int) + walk = func(root, union GoType, path []string, discrim []lenientDiscrimValue, depth int) { + if union.Discriminator == nil || depth > 4 { + return + } + for _, v := range union.Discriminator.Variants { + if len(v.Values) == 0 { + continue + } + next := append(slices.Clone(discrim), lenientDiscrimValue{ + JSON: union.Discriminator.PropertyName, + Value: v.Values[0], + }) + nextPath := append(slices.Clone(path), v.Values[0]) + if key, ok := keyed[v.TypeName]; ok { + cases = append(cases, lenientUnionCase{ + Name: strings.Join(append([]string{root.Name}, nextPath...), "/"), + UnionType: root.Name, + Path: strings.Join(nextPath, "."), + Discrim: next, + ScalarJSON: key.JSON, + ScalarKind: key.Kind, + }) + continue + } + if inner, ok := byName[v.TypeName]; ok && inner.Discriminator != nil { + walk(root, inner, nextPath, next, depth+1) + } + } + } + // Every union is an entry point, inner ones included: decoding an inner + // union directly is a dispatch a caller can reach, so it gets its own case + // rather than only appearing as a leg of the outer chain. + for _, t := range types { + if t.Discriminator == nil { + continue + } + walk(t, t, nil, nil, 0) + } + slices.SortFunc(cases, func(a, b lenientUnionCase) int { return strings.Compare(a.Name, b.Name) }) + return cases } // jsonTagName returns the wire name from a struct tag body, dropping the @@ -205,36 +470,12 @@ func jsonTagName(tag string) string { return name } -// emitPkgLenientScalars writes lenient_scalars.go: the coercion helper plus one -// UnmarshalJSON per type in entries. A package with no entries gets no file, -// and a spec that asked for roots but produced none is an error — see -// validateLenientScalars. -func emitPkgLenientScalars(pkgDir, pkgName string, entries []lenientType) error { - if len(entries) == 0 { - return nil - } - var b strings.Builder - fmt.Fprintf(&b, `// Code generated by tools/generate; DO NOT EDIT. - -// Copyright Jamf Software LLC 2026 -// SPDX-License-Identifier: MIT - -// Tolerant decoders for the schemas config names in lenientScalarRoots: a -// store that serves back the JSON its writer sent, rather than re-serialising -// from its own model, answers whatever scalar encoding that writer used. +// lenientRuntimeSource is the package-independent half of lenient_scalars.go: +// the kind constants and the four decode helpers every generated method calls. // -// Emitted here rather than in types.go so the field types stay exactly what -// the spec declares — the tolerance is in the decode, not in the surface a -// consumer programs against — and so this file is the one place to read for -// what the coercion does and does not do. - -package %s - -import ( - "encoding/json" - "errors" -) - +// Kept out of the Fprintf that writes the header because it carries %s and %w +// verbs of its own, which a format string would consume. +const lenientRuntimeSource = ` // jsonScalarKind names the JSON scalar a property is declared as. type jsonScalarKind uint8 @@ -245,36 +486,110 @@ const ( jsonScalarBool ) -// unmarshalLenientScalars decodes data into v, accepting a JSON string -// wherever keys declares a number or a boolean. +// unmarshalLenient decodes data into v, accepting a JSON string wherever keys +// declares a number or a boolean, and naming the field a failure came from. // -// The strict decode is tried first and the rewrite only happens when it fails, -// so a conforming body costs one extra error check and nothing else. A nested -// value is fixed by its own type's method during that first attempt, which is -// why each type only has to describe its own keys. +// The strict decode is tried first, so a conforming body costs one error check +// and nothing else. A nested value has already been fixed by its own type's +// method during that attempt, which is why each type only has to describe its +// own keys. Note what that does not say about cost: the retry re-decodes the +// whole object, so a quoted value on this type's own key runs every composite +// child's decoder a second time. The alternative — decoding key by key on the +// success path — would tax every conforming body to save a cold one. // // Three properties worth relying on. Only the listed keys are considered, so a // string the spec declares as a string is never touched. Only a JSON string is // rewritten, and only when the text it carries is a valid JSON scalar of the // declared kind — so "abc" for an integer still fails the decode rather than // arriving as zero. And marshalling is untouched: the SDK keeps sending the -// numbers and booleans the spec declares. -func unmarshalLenientScalars(data []byte, v any, keys map[string]jsonScalarKind, name string) error { +// numbers and booleans the spec declares, which makes the tolerance +// one-directional. Decoding a quoted value and writing the struct back emits +// the bare scalar, so a round trip through these types rewrites the encoding +// the store was holding. +func unmarshalLenient(data []byte, v any, keys map[string]jsonScalarKind, name string) error { err := json.Unmarshal(data, v) if err == nil { return nil } - fixed, rewritten := unquoteJSONScalars(data, keys) - if rewritten { + // body is what the reported error describes, which is the rewritten + // document whenever a rewrite happened. Attributing against the original + // would blame the key the rewrite already fixed. + body := data + if fixed, rewritten := unquoteJSONScalars(data, keys); rewritten { // The second error is the one to report when it comes: the rewrite // has handled the encoding the first error described, so what is left // is a fault the coercion has nothing to do with. - err = json.Unmarshal(fixed, v) - if err == nil { + retryErr := json.Unmarshal(fixed, v) + if retryErr == nil { return nil } + err, body = retryErr, fixed } - return namedStructError(err, name) + return attributeFieldError(body, v, err, name) +} + +// attributeFieldError prefixes err with the field the failure came from. +// +// encoding/json hands a nested value straight to that type's UnmarshalJSON and +// returns whatever it gets back, so a child decoder's error arrives with no +// record of the field it travelled through. Deferrals declares four fields of +// the identical OptionalPeriodInDays type, so the error alone cannot say which +// one failed — and that path is how the decode bug this whole mechanism exists +// to fix was diagnosed. +// +// It runs only on the error path, and it decides nothing: it decodes each +// present key on its own into a fresh value of that field's type, and the +// first key that reproduces a failure is the one to name. A key whose probe +// succeeds is left alone, and a failure no probe reproduces is returned +// unattributed rather than guessed at. +func attributeFieldError(data []byte, v any, err error, name string) error { + err = namedStructError(err, name) + var obj map[string]json.RawMessage + if json.Unmarshal(data, &obj) != nil { + return err + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Struct { + return err + } + t := rv.Elem().Type() + for i := range t.NumField() { + f := t.Field(i) + if f.PkgPath != "" { + continue + } + key := jsonFieldName(f) + if key == "" { + continue + } + raw, present := obj[key] + if !present { + continue + } + probe := reflect.New(f.Type) + if fieldErr := json.Unmarshal(raw, probe.Interface()); fieldErr != nil { + return fmt.Errorf("%s.%s: %w", name, key, fieldErr) + } + } + return err +} + +// jsonFieldName returns the wire name a struct field travels under, following +// encoding/json's own rule for the dash: a tag of exactly "-" skips the field, +// while "-," names it "-". An absent tag leaves the field name. +func jsonFieldName(f reflect.StructField) string { + tag, ok := f.Tag.Lookup("json") + if !ok { + return f.Name + } + if tag == "-" { + return "" + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + return f.Name + } + return name } // namedStructError puts the real type name back into a decode error. @@ -283,6 +598,8 @@ func unmarshalLenientScalars(data []byte, v any, keys map[string]jsonScalarKind, // method and avoid recursing, and encoding/json reports the *Go* type it was // decoding — so without this a caller reads "json: cannot unmarshal string // into Go struct field lenient.Value", which names nothing they can look up. +// It restores the type name only; the field path is what attributeFieldError +// puts back. func namedStructError(err error, name string) error { var typeErr *json.UnmarshalTypeError if errors.As(err, &typeErr) && typeErr.Struct == "lenient" { @@ -337,9 +654,14 @@ func unquoteJSONScalars(data []byte, keys map[string]jsonScalarKind) ([]byte, bo // // The number branch re-emits the text verbatim rather than parsing and // reformatting it, so a value wider than float64 keeps every digit and a -// decimal keeps its exact spelling; json.Valid plus the leading-byte check is -// what decides it is a JSON number token, which rules out the forms Go's own -// parsers accept and JSON does not (Inf, NaN, hex floats, a leading +). +// decimal keeps its exact spelling. Two checks decide it, and both are +// load-bearing: json.Valid rules out the forms Go's own parsers accept and +// JSON does not (Inf, NaN, hex floats, a leading +, 01), and the leading-byte +// check rules out the values that are valid JSON but are not numbers (null, +// true, false, an array, an object). Without the second, a quoted "null" +// would be rewritten to bare null, which decodes into a non-pointer field as +// a silent no-op and leaves the zero value — the one outcome this whole +// mechanism must never produce. func jsonScalarLiteral(s string, kind jsonScalarKind) (string, bool) { switch kind { case jsonScalarNumber: @@ -357,21 +679,70 @@ func jsonScalarLiteral(s string, kind jsonScalarKind) (string, bool) { } return "", false } +` + +// emitPkgLenientScalars writes lenient_scalars.go: the coercion helpers plus +// one UnmarshalJSON per type in entries. A package with no entries gets no +// file, and a spec that asked for roots but produced none is an error — see +// validateLenientScalars. +func emitPkgLenientScalars(pkgDir, pkgName string, entries []lenientType) error { + if len(entries) == 0 { + return nil + } + var b strings.Builder + fmt.Fprintf(&b, `// Code generated by tools/generate; DO NOT EDIT. + +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +// Tolerant decoders for the schemas config names in lenientScalarRoots: a +// store that serves back the JSON its writer sent, rather than re-serialising +// from its own model, answers whatever scalar encoding that writer used. +// +// Emitted here rather than in types.go so the field types stay exactly what +// the spec declares — the tolerance is in the decode, not in the surface a +// consumer programs against — and so this file is the one place to read for +// what the coercion does and does not do. +// +// The tolerance is one-directional. Marshalling is untouched, so decoding a +// quoted scalar and writing the struct back sends the bare value the spec +// declares, and the store then holds that encoding instead. + +package %s + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) `, pkgName) + b.WriteString(lenientRuntimeSource) for _, e := range entries { - fmt.Fprintf(&b, "\n// lenientScalars%s names the scalars %s declares, for its UnmarshalJSON.\nvar lenientScalars%s = map[string]jsonScalarKind{\n", e.Name, e.Name, e.Name) - for _, k := range e.Keys { - fmt.Fprintf(&b, "\t%q: %s,\n", k.JSON, k.Kind) + fmt.Fprintf(&b, "\n// lenientScalars%s names the scalars %s declares, for its UnmarshalJSON.\n", e.Name, e.Name) + if len(e.Keys) == 0 { + fmt.Fprintf(&b, "// It declares none of its own: the decoder exists to name the field a\n"+ + "// child decoder failed on.\nvar lenientScalars%s = map[string]jsonScalarKind{}\n", e.Name) + } else { + fmt.Fprintf(&b, "var lenientScalars%s = map[string]jsonScalarKind{\n", e.Name) + for _, k := range e.Keys { + fmt.Fprintf(&b, "\t%q: %s,\n", k.JSON, k.Kind) + } + b.WriteString("}\n") } - b.WriteString("}\n") fmt.Fprintf(&b, ` -// UnmarshalJSON decodes s, accepting a JSON string for any of its numbers and -// booleans. See unmarshalLenientScalars for what is and is not coerced. +// UnmarshalJSON decodes s, accepting a JSON string for any number or boolean +// it declares, because the store this schema comes from serves back the +// scalar encoding its writer used. A failure names the field it came from. +// +// Marshalling is unaffected, so writing s back sends the bare number or +// boolean the spec declares rather than the encoding it was read as. func (s *%s) UnmarshalJSON(data []byte) error { type lenient %s var v lenient - if err := unmarshalLenientScalars(data, &v, lenientScalars%s, %q); err != nil { + if err := unmarshalLenient(data, &v, lenientScalars%s, %q); err != nil { return err } *s = %s(v) @@ -392,27 +763,98 @@ func (s *%s) UnmarshalJSON(data []byte) error { return nil } -// validateLenientScalars refuses a spec that asked for tolerance and got none. +// validateLenientScalars refuses a root that asked for tolerance and got none, +// and reports every gap the plan left. // -// A root resolves but reaches no number or boolean when upstream has moved the -// scalars out from under it, and the entry is then a claim about a store that -// no longer needs it. Failing here is what deletes the config entry, the same -// way a redundant scopeTypes override fails. -func validateLenientScalars(pkgContext string, roots []string, entries []lenientType) error { - if len(roots) == 0 || len(entries) > 0 { - return nil +// The check is per root, not per package: a root resolves but reaches no +// number or boolean when upstream has moved the scalars out from under it, and +// with the roots merged first a sibling root's entries would hide that. The +// entry is then a claim about a store that no longer needs it, and failing +// here is what deletes it — the same way a redundant scopeTypes override +// fails. +func validateLenientScalars(pkgContext string, perRoot map[string][]lenientType, diags lenientDiagnostics) error { + var dead []string + for _, root := range slices.Sorted(maps.Keys(perRoot)) { + if len(perRoot[root]) == 0 { + dead = append(dead, root) + } + } + if len(dead) > 0 { + return fmt.Errorf("%s: lenientScalarRoots names %s but its subtree reaches no number or boolean field\n\n"+ + "fix: delete the entry — every scalar it covered has gone, so the tolerance has nothing left to do", + pkgContext, strings.Join(dead, ", ")) + } + for _, name := range diags.OwnDecoder { + log.Printf("lenient scalars: %s carries its own UnmarshalJSON, so a failure below it is not "+ + "attributed to a field", name) + } + for _, field := range diags.SkippedComposites { + log.Printf("lenient scalars: %s holds its scalars in a slice or map, which the coercion does not "+ + "reach into", field) + } + return nil +} + +// lenientParentCase is one parent type paired with a child field that carries +// a coerced number, for the generated test that pins field attribution. +// +// It is the regression for the defect the parent decoders exist to fix: a +// failure inside the child used to arrive naming only the child's own type, +// which is indistinguishable across sibling fields of the same type. +type lenientParentCase struct { + Name string // parent type + ChildJSON string // the parent's key the child travels under + ChildKey string // the child's own coerced key +} + +// lenientParentCases pairs each emitted type with one child field whose type +// is itself emitted and declares a coerced number. +// +// One child per parent is enough: what is pinned is that the parent names the +// field at all, and a second field of the same shape adds a case without +// adding an assertion. Sibling fields sharing a type are exactly why the +// mechanism exists, so the pick is deterministic rather than arbitrary — the +// first field in the parent's declared order. +func lenientParentCases(types []GoType, entries []lenientType) []lenientParentCase { + byName := make(map[string]GoType, len(types)) + for _, t := range types { + byName[t.Name] = t } - return fmt.Errorf("%s: lenientScalarRoots names %s but its subtree reaches no number or boolean field\n\n"+ - "fix: delete the entry — every scalar it covered has gone, so the tolerance has nothing left to do", - pkgContext, strings.Join(roots, ", ")) + numberKey := make(map[string]string) + for _, e := range entries { + for _, k := range e.Keys { + if k.Kind == jsonScalarKindNumber { + numberKey[e.Name] = k.JSON + break + } + } + } + var cases []lenientParentCase + for _, e := range entries { + for _, f := range byName[e.Name].Fields { + ref := normalizeTypeRef(f.Type) + key, ok := numberKey[ref] + if !ok || ref == e.Name { + continue + } + jsonKey := jsonTagName(f.JSONTag) + if jsonKey == "" { + continue + } + cases = append(cases, lenientParentCase{Name: e.Name, ChildJSON: jsonKey, ChildKey: key}) + break + } + } + return cases } // lenientScalarsTestTemplate is the fixed body of lenient_scalars_test.go. // @BQ@ and @DQ@ stand in for a backtick and a double quote so the whole thing -// can live in one raw string; @PKG@ is the package name and @CASES@ the table -// rows. Written this way rather than through a format string because the -// generated source is dense in quotes of both kinds and a Sprintf verb in the -// middle of them is where the last attempt went wrong. +// can live in one raw string; @PKG@ is the package name, @CASES@ the per-type +// table rows, @UNIONCASES@ the union-dispatch rows and @PARENTCASES@ the +// attribution rows. Written this way rather than through a format string +// because the generated source is dense in quotes of both kinds and a Sprintf +// verb in the middle of them is where the last attempt went wrong. const lenientScalarsTestTemplate = `// Code generated by tools/generate; DO NOT EDIT. // Copyright Jamf Software LLC 2026 @@ -437,6 +879,25 @@ type lenientScalarCase struct { keys map[string]jsonScalarKind } +// lenientUnionCase is one path from a discriminated union down to a leaf that +// carries a coerced number, rendered as the flat object the union's own +// UnmarshalJSON dispatches on. +type lenientUnionCase struct { + name string + typ reflect.Type + discrim [][2]string + scalarJSON string +} + +// lenientParentCase is one parent type paired with a child field carrying a +// coerced number, for the attribution assertion. +type lenientParentCase struct { + name string + typ reflect.Type + childJSON string + childKey string +} + // body renders a JSON object setting every key in the case, quoting the values // when quoted is true. Keys are emitted in sorted order so a failure names the // same body every run. @@ -460,6 +921,22 @@ func (c lenientScalarCase) body(quoted bool) string { return "{" + strings.Join(parts, ",") + "}" } +// body renders the union path as one flat object: every discriminator on the +// way down plus the leaf's own scalar, which is what the dispatch actually +// receives since a union hands the same bytes to the variant it selects. +func (c lenientUnionCase) body(quoted bool) string { + parts := make([]string, 0, len(c.discrim)+1) + for _, d := range c.discrim { + parts = append(parts, fmt.Sprintf("%q:%q", d[0], d[1])) + } + lit := "1" + if quoted { + lit = @BQ@"1"@BQ@ + } + parts = append(parts, fmt.Sprintf("%q:%s", c.scalarJSON, lit)) + return "{" + strings.Join(parts, ",") + "}" +} + // TestLenientScalars_StringEncodingDecodesToTheSameValue is the regression for // a store that echoes its writer's JSON: every number and boolean under a // lenientScalarRoots root must decode from the quoted form to exactly what the @@ -484,6 +961,81 @@ func TestLenientScalars_StringEncodingDecodesToTheSameValue(t *testing.T) { } } +// TestLenientScalars_KeysAreRealFieldTags pins that every coerced key names a +// field the type actually declares. The equality test above cannot see this: +// a key matching no field is ignored by encoding/json on both the bare and the +// quoted side, so both decodes land on the same untouched zero value and the +// comparison passes with nothing coerced. +func TestLenientScalars_KeysAreRealFieldTags(t *testing.T) { + for _, c := range lenientScalarCases { + t.Run(c.name, func(t *testing.T) { + declared := make(map[string]bool) + for i := range c.typ.NumField() { + if name := jsonFieldName(c.typ.Field(i)); name != "" { + declared[name] = true + } + } + for key := range c.keys { + if !declared[key] { + t.Errorf("coerced key %q names no field of %s", key, c.name) + } + } + }) + } +} + +// TestLenientScalars_UnionDispatchReachesTheLenientLeaf decodes through a +// discriminated union's own UnmarshalJSON rather than into the leaf directly. +// Every other test here constructs the leaf type itself, so a discriminator +// template that stopped delegating to a variant's own UnmarshalJSON — by +// decoding into a value copy, say — would ship with the whole suite green. +func TestLenientScalars_UnionDispatchReachesTheLenientLeaf(t *testing.T) { + if len(lenientUnionCases) == 0 { + t.Skip("no discriminated union in this package reaches a coerced number") + } + for _, c := range lenientUnionCases { + t.Run(c.name, func(t *testing.T) { + bare := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(false)), bare.Interface()); err != nil { + t.Fatalf("decoding the bare form %s: %v", c.body(false), err) + } + quoted := reflect.New(c.typ) + if err := json.Unmarshal([]byte(c.body(true)), quoted.Interface()); err != nil { + t.Fatalf("decoding the string form %s through the union: %v", c.body(true), err) + } + if !reflect.DeepEqual(bare.Elem().Interface(), quoted.Elem().Interface()) { + t.Fatalf("the union dispatched the two encodings differently:\n bare: %#v\n quoted: %#v", + bare.Elem().Interface(), quoted.Elem().Interface()) + } + }) + } +} + +// TestLenientScalars_FailureNamesTheChildField is the regression for the +// diagnostic half. encoding/json returns a nested Unmarshaler's error verbatim, +// so before the parent decoders a failure inside a child arrived naming only +// the child's own type — and a parent declaring several fields of that one type +// left the caller unable to tell which field was at fault. +func TestLenientScalars_FailureNamesTheChildField(t *testing.T) { + if len(lenientParentCases) == 0 { + t.Skip("no emitted type has a child carrying a coerced number") + } + for _, c := range lenientParentCases { + t.Run(c.name+"/"+c.childJSON, func(t *testing.T) { + body := fmt.Sprintf(@BQ@{%q:{%q:"not-a-number"}}@BQ@, c.childJSON, c.childKey) + v := reflect.New(c.typ) + err := json.Unmarshal([]byte(body), v.Interface()) + if err == nil { + t.Fatalf("decoding %s should fail", body) + } + want := c.name + "." + c.childJSON + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not name the field %q: %v", want, err) + } + }) + } +} + // TestLenientScalars_NonScalarStringStillFails pins the boundary. The coercion // unquotes a string only when the text it carries is a valid JSON scalar of the // declared kind, so a genuinely wrong value has to keep failing the decode — @@ -507,6 +1059,32 @@ func TestLenientScalars_NonScalarStringStillFails(t *testing.T) { } } +// TestLenientScalars_ValidJSONNonNumberStillFails is the other half of that +// boundary, and the half json.Valid cannot carry. "null", "true" and the +// bracket forms are all valid JSON, so only the leading-byte check rejects +// them — and a quoted "null" rewritten to bare null decodes into a +// non-pointer field as a silent no-op, which is the one outcome the coercion +// must never produce. +func TestLenientScalars_ValidJSONNonNumberStillFails(t *testing.T) { + for _, c := range lenientScalarCases { + for name, kind := range c.keys { + if kind != jsonScalarNumber { + continue + } + for _, bad := range []string{"null", "true", "false", "[]", "{}"} { + t.Run(c.name+"/"+name+"/"+bad, func(t *testing.T) { + v := reflect.New(c.typ) + body := fmt.Sprintf(@BQ@{%q:%q}@BQ@, name, bad) + if err := json.Unmarshal([]byte(body), v.Interface()); err == nil { + t.Fatalf("decoding %s succeeded; %q is valid JSON but not a number", body, bad) + } + }) + } + break + } + } +} + // TestLenientScalars_UnlistedKeysAreUntouched pins that the rewrite is keyed on // the type's own declared scalars: a string the spec declares as a string must // survive verbatim, which is what stops the coercion mangling prose that @@ -536,7 +1114,7 @@ func TestLenientScalars_NonObjectBodyKeepsTheOriginalError(t *testing.T) { var target struct { Count int @BQ@json:"count"@BQ@ } - err := unmarshalLenientScalars([]byte(@BQ@["not","an","object"]@BQ@), &target, + err := unmarshalLenient([]byte(@BQ@["not","an","object"]@BQ@), &target, map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe") if err == nil { t.Fatal("decoding a JSON array into a struct should fail") @@ -546,6 +1124,31 @@ func TestLenientScalars_NonObjectBodyKeepsTheOriginalError(t *testing.T) { } } +// TestLenientScalars_ReportsThePostRewriteError pins which of the two errors a +// caller sees. The rewrite has already handled the encoding the first error +// described, so reporting that one blames a key the coercion fixed and hides +// the fault that is actually left. +func TestLenientScalars_ReportsThePostRewriteError(t *testing.T) { + type child struct { + N int @BQ@json:"n"@BQ@ + } + var target struct { + Count int @BQ@json:"count"@BQ@ + Child *child @BQ@json:"child"@BQ@ + } + err := unmarshalLenient([]byte(@BQ@{"count":"9","child":123}@BQ@), &target, + map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe") + if err == nil { + t.Fatal("decoding an integer into a struct field should fail") + } + if !strings.Contains(err.Error(), "child") { + t.Errorf("error = %v, want it to name child, the fault the rewrite did not fix", err) + } + if strings.Contains(err.Error(), "count") { + t.Errorf("error = %v, names count, which the rewrite already fixed", err) + } +} + // TestLenientScalars_WideNumberKeepsItsDigits pins that the number branch // re-emits the text rather than parsing and reformatting it, so a value beyond // float64's exact range is not silently rounded on the way through. @@ -554,7 +1157,7 @@ func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { Count int64 @BQ@json:"count"@BQ@ } const want = 9007199254740993 - if err := unmarshalLenientScalars([]byte(@BQ@{"count":"9007199254740993"}@BQ@), &target, + if err := unmarshalLenient([]byte(@BQ@{"count":"9007199254740993"}@BQ@), &target, map[string]jsonScalarKind{"count": jsonScalarNumber}, "probe"); err != nil { t.Fatalf("decoding: %v", err) } @@ -563,21 +1166,30 @@ func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { } } -// TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers pins the forms Go's -// own parsers accept and JSON does not. Every one of these would decode -// through strconv and none of them is a JSON number token. +// TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers pins both halves of +// the number check. The first group is what json.Valid rejects: forms Go's own +// parsers accept and JSON does not. The second is what only the leading-byte +// check rejects: text that is valid JSON but is not a number. func TestLenientScalars_JSONScalarLiteralRejectsNonJSONNumbers(t *testing.T) { for _, s := range []string{"", " ", "+1", "1_0", "0x10", "1e", "Inf", "NaN", "01", ".5", "1.2.3"} { if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); ok { t.Errorf("jsonScalarLiteral(%q) = %q, true; want rejected", s, lit) } } + for _, s := range []string{"null", "true", "false", "[]", "{}", @BQ@"5"@BQ@, "[1,2]"} { + if !json.Valid([]byte(s)) { + t.Fatalf("%q is meant to be valid JSON; the case has stopped testing the leading-byte check", s) + } + if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); ok { + t.Errorf("jsonScalarLiteral(%q) = %q, true; want rejected — valid JSON, not a number", s, lit) + } + } for _, s := range []string{"0", "-1", "1.5", "1e5", "-1.5e-3", "90"} { if lit, ok := jsonScalarLiteral(s, jsonScalarNumber); !ok || lit != s { t.Errorf("jsonScalarLiteral(%q) = %q, %v; want %q, true", s, lit, ok, s) } } - for _, s := range []string{"True", "TRUE", "1", "", "yes"} { + for _, s := range []string{"True", "TRUE", "1", "", "yes", "null"} { if lit, ok := jsonScalarLiteral(s, jsonScalarBool); ok { t.Errorf("jsonScalarLiteral(%q, bool) = %q, true; want rejected", s, lit) } @@ -610,28 +1222,56 @@ func TestLenientScalars_DecodeErrorNamesTheRealType(t *testing.T) { var lenientScalarCases = []lenientScalarCase{ @CASES@} + +var lenientUnionCases = []lenientUnionCase{ +@UNIONCASES@} + +var lenientParentCases = []lenientParentCase{ +@PARENTCASES@} ` // emitPkgLenientScalarsTest writes lenient_scalars_test.go: a table over every // type that got a tolerant decoder, asserting the string encoding decodes to -// the same value the bare scalar does, and that a string carrying something -// that is not a scalar of the declared kind still fails. +// the same value the bare scalar does, that a string carrying something that is +// not a scalar of the declared kind still fails, that a failure names the field +// it came from, and that a union's dispatch still reaches a lenient leaf. // -// The table is reflective rather than one hand-shaped case per type because -// what is being pinned is uniform: 43 near-identical literal fixtures would go -// stale the first time a spec moved a field, and equality against the +// The tables are reflective rather than one hand-shaped case per type because +// what is being pinned is uniform: dozens of near-identical literal fixtures +// would go stale the first time a spec moved a field, and equality against the // bare-scalar decode is a stronger assertion than any single expected value. -func emitPkgLenientScalarsTest(pkgDir, pkgName string, entries []lenientType) error { +func emitPkgLenientScalarsTest(pkgDir, pkgName string, entries []lenientType, + unions []lenientUnionCase, parents []lenientParentCase) error { if len(entries) == 0 { return nil } var cases strings.Builder for _, e := range entries { + if len(e.Keys) == 0 { + continue + } fmt.Fprintf(&cases, "\t{name: %q, typ: reflect.TypeOf(%s{}), keys: lenientScalars%s},\n", e.Name, e.Name, e.Name) } + var unionCases strings.Builder + for _, u := range unions { + fmt.Fprintf(&unionCases, "\t{name: %q, typ: reflect.TypeOf(%s{}), scalarJSON: %q, discrim: [][2]string{", + u.Name, u.UnionType, u.ScalarJSON) + for _, d := range u.Discrim { + fmt.Fprintf(&unionCases, "{%q, %q},", d.JSON, d.Value) + } + unionCases.WriteString("}},\n") + } + var parentCases strings.Builder + for _, p := range parents { + fmt.Fprintf(&parentCases, "\t{name: %q, typ: reflect.TypeOf(%s{}), childJSON: %q, childKey: %q},\n", + p.Name, p.Name, p.ChildJSON, p.ChildKey) + } + src := lenientScalarsTestTemplate src = strings.ReplaceAll(src, "@PKG@", pkgName) src = strings.ReplaceAll(src, "@CASES@", cases.String()) + src = strings.ReplaceAll(src, "@UNIONCASES@", unionCases.String()) + src = strings.ReplaceAll(src, "@PARENTCASES@", parentCases.String()) src = strings.ReplaceAll(src, "@BQ@", "`") outPath := filepath.Join(pkgDir, "lenient_scalars_test.go") @@ -645,3 +1285,46 @@ func emitPkgLenientScalarsTest(pkgDir, pkgName string, entries []lenientType) er log.Printf("wrote %s", outPath) return nil } + +// lenientScalarPlan resolves one package's whole lenient-scalar emission from +// the per-root seeds its specs accumulated. +// +// It is one call rather than four so the ordering and the deduplication live +// beside the rules they serve: entries follow the emitted type order whatever +// order the roots were walked in, and a type two roots both reach is emitted +// once. Validation stays per root — see validateLenientScalars. +func lenientScalarPlan(pkgContext string, structTypes []GoType, + seeds map[string]map[string]bool) ([]lenientType, []lenientUnionCase, []lenientParentCase, error) { + if len(seeds) == 0 { + return nil, nil, nil, nil + } + perRoot := make(map[string][]lenientType, len(seeds)) + byName := make(map[string]lenientType) + var diags lenientDiagnostics + for _, root := range slices.Sorted(maps.Keys(seeds)) { + entries, d, err := lenientScalarTypes(structTypes, seeds[root]) + if err != nil { + return nil, nil, nil, err + } + perRoot[root] = entries + diags.SkippedComposites = append(diags.SkippedComposites, d.SkippedComposites...) + diags.OwnDecoder = append(diags.OwnDecoder, d.OwnDecoder...) + for _, e := range entries { + byName[e.Name] = e + } + } + slices.Sort(diags.SkippedComposites) + diags.SkippedComposites = slices.Compact(diags.SkippedComposites) + slices.Sort(diags.OwnDecoder) + diags.OwnDecoder = slices.Compact(diags.OwnDecoder) + if err := validateLenientScalars(pkgContext, perRoot, diags); err != nil { + return nil, nil, nil, err + } + var entries []lenientType + for _, t := range structTypes { + if e, ok := byName[t.Name]; ok { + entries = append(entries, e) + } + } + return entries, lenientUnionCases(structTypes, entries), lenientParentCases(structTypes, entries), nil +} diff --git a/tools/generate/lenient_test.go b/tools/generate/lenient_test.go index 087a2dc7..b000f98f 100644 --- a/tools/generate/lenient_test.go +++ b/tools/generate/lenient_test.go @@ -100,10 +100,11 @@ func TestLenientScalarSeedFollowsAUnionToItsConfigurations(t *testing.T) { doc.Components.Schemas["PasscodeSettingsComponent"].Value.Properties["configuration"].Value = doc.Components.Schemas["PasscodeSettingsConfiguration"].Value - seed, err := lenientScalarSeed(doc, []string{"Component"}) + seeds, err := lenientScalarSeeds(doc, []string{"Component"}) if err != nil { - t.Fatalf("lenientScalarSeed: %v", err) + t.Fatalf("lenientScalarSeeds: %v", err) } + seed := seeds["Component"] for _, want := range []string{"PasscodeSettingsComponent", "PasscodeSettingsConfiguration", "MinimumLength"} { if !seed[want] { t.Errorf("seed is missing %s; got %v", want, sortedKeys(seed)) @@ -121,7 +122,7 @@ func TestLenientScalarSeedRefusesAnUnknownRoot(t *testing.T) { doc := &openapi3.T{Components: &openapi3.Components{Schemas: openapi3.Schemas{ "Component": {Value: openapi3.NewObjectSchema()}, }}} - _, err := lenientScalarSeed(doc, []string{"Component", "Renamed"}) + _, err := lenientScalarSeeds(doc, []string{"Component", "Renamed"}) if err == nil { t.Fatal("an unknown root should fail generation") } @@ -154,19 +155,27 @@ func TestLenientScalarTypesClosesOverFieldReferences(t *testing.T) { {Name: "Count", Type: "int", JSONTag: "count"}, }}, } - got := lenientScalarTypes(types, map[string]bool{"SoftwareUpdateSettingsConfiguration": true}) + got, _, err := lenientScalarTypes(types, map[string]bool{"SoftwareUpdateSettingsConfiguration": true}) + if err != nil { + t.Fatalf("lenientScalarTypes: %v", err) + } var names []string for _, e := range got { names = append(names, e.Name) } - // Deferrals declares no scalar of its own, so it carries the closure - // without getting a decoder. - want := []string{"SoftwareUpdateSettingsConfiguration", "OptionalPeriodInDays"} + // Deferrals declares no scalar of its own and still gets a decoder: a + // failure inside one of its children arrives naming only the child's own + // type, and Deferrals is where the field name lives. Elsewhere is outside + // the closure and gets nothing. + want := []string{"SoftwareUpdateSettingsConfiguration", "Deferrals", "OptionalPeriodInDays"} if strings.Join(names, ",") != strings.Join(want, ",") { t.Fatalf("types = %v, want %v", names, want) } - if keys := got[1].Keys; len(keys) != 2 || + if keys := got[1].Keys; len(keys) != 0 { + t.Errorf("Deferrals keys = %+v, want none of its own", keys) + } + if keys := got[2].Keys; len(keys) != 2 || keys[0].JSON != "Included" || keys[0].Kind != jsonScalarKindBool || keys[1].JSON != "Value" || keys[1].Kind != jsonScalarKindNumber { t.Errorf("OptionalPeriodInDays keys = %+v, want Included/bool then Value/number", keys) @@ -194,18 +203,209 @@ func TestJSONTagName(t *testing.T) { // A root that resolves but reaches no scalar is a claim about a store that no // longer needs it. Failing is what deletes the config entry. func TestValidateLenientScalars(t *testing.T) { - if err := validateLenientScalars("package blueprints", nil, nil); err != nil { + if err := validateLenientScalars("package blueprints", nil, lenientDiagnostics{}); err != nil { t.Errorf("no roots and no entries should pass: %v", err) } - if err := validateLenientScalars("package blueprints", []string{"Component"}, - []lenientType{{Name: "T"}}); err != nil { - t.Errorf("roots with entries should pass: %v", err) + live := map[string][]lenientType{"Component": {{Name: "T"}}} + if err := validateLenientScalars("package blueprints", live, lenientDiagnostics{}); err != nil { + t.Errorf("a root with entries should pass: %v", err) } - err := validateLenientScalars("package blueprints", []string{"Component"}, nil) + err := validateLenientScalars("package blueprints", + map[string][]lenientType{"Component": nil}, lenientDiagnostics{}) if err == nil { - t.Fatal("roots that reach no scalar should fail generation") + t.Fatal("a root that reaches no scalar should fail generation") } if !strings.Contains(err.Error(), "Component") { t.Errorf("error = %v, want it to name the root", err) } } + +// The guard is a claim about one root, so a sibling root that still has +// entries must not stand in for one that has lost every scalar. Aggregating +// first is what makes the second root's expiry unreachable. +func TestValidateLenientScalarsIsPerRoot(t *testing.T) { + err := validateLenientScalars("package blueprints", map[string][]lenientType{ + "Component": {{Name: "OptionalPeriodInDays"}}, + "Withdrawn": nil, + }, lenientDiagnostics{}) + if err == nil { + t.Fatal("a root that reaches no scalar should fail even when a sibling root has entries") + } + if !strings.Contains(err.Error(), "Withdrawn") { + t.Errorf("error = %v, want it to name the dead root", err) + } + if strings.Contains(err.Error(), "Component") { + t.Errorf("error = %v, want only the dead root named", err) + } +} + +// The coercion does not reach into a slice or a map, and the justification for +// that is an observation about writers rather than a guarantee. An observation +// needs a tripwire, so the plan reports every such field instead of dropping +// it silently. +func TestLenientScalarTypesReportsSkippedComposites(t *testing.T) { + types := []GoType{ + {Name: "Root", Fields: []GoField{ + {Name: "Count", Type: "int", JSONTag: "count"}, + {Name: "Values", Type: "[]int", JSONTag: "values"}, + {Name: "Flags", Type: "map[string]bool", JSONTag: "flags"}, + {Name: "Names", Type: "[]string", JSONTag: "names"}, + }}, + } + _, diags, err := lenientScalarTypes(types, map[string]bool{"Root": true}) + if err != nil { + t.Fatalf("lenientScalarTypes: %v", err) + } + joined := strings.Join(diags.SkippedComposites, " ") + for _, want := range []string{"Root.values ([]int)", "Root.flags (map[string]bool)"} { + if !strings.Contains(joined, want) { + t.Errorf("diagnostics = %v, want it to report %s", diags.SkippedComposites, want) + } + } + if strings.Contains(joined, "names") { + t.Errorf("diagnostics = %v, want no report for a slice of strings", diags.SkippedComposites) + } +} + +// A type the generator already emits an UnmarshalJSON for cannot also carry a +// lenient one: two methods on one type do not compile. A union in the middle +// of the closure is reported, because attribution stops there — and a union +// that declares a coerced scalar of its own fails generation, because there +// the tolerance itself is what would be lost. +func TestLenientScalarTypesAndTypesThatOwnAnUnmarshalJSON(t *testing.T) { + union := GoType{Name: "SwUpdateConfiguration", Discriminator: &GoDiscriminator{PropertyName: "enforcementType"}, + Fields: []GoField{ + {Name: "EnforcementType", Type: "string", JSONTag: "enforcementType"}, + {Name: "AUTOMATIC", Type: "*Leaf", JSONTag: "-"}, + }} + leaf := GoType{Name: "Leaf", Fields: []GoField{{Name: "Days", Type: "int", JSONTag: "enforceAfterDays"}}} + + entries, diags, err := lenientScalarTypes([]GoType{union, leaf}, + map[string]bool{"SwUpdateConfiguration": true}) + if err != nil { + t.Fatalf("lenientScalarTypes: %v", err) + } + for _, e := range entries { + if e.Name == "SwUpdateConfiguration" { + t.Fatal("a discriminated union must not get a second UnmarshalJSON") + } + } + if len(diags.OwnDecoder) != 1 || diags.OwnDecoder[0] != "SwUpdateConfiguration" { + t.Errorf("OwnDecoder = %v, want the union reported", diags.OwnDecoder) + } + + union.Fields = append(union.Fields, GoField{Name: "Retries", Type: "int", JSONTag: "retries"}) + _, _, err = lenientScalarTypes([]GoType{union, leaf}, map[string]bool{"SwUpdateConfiguration": true}) + if err == nil { + t.Fatal("a union declaring a coerced scalar should fail generation") + } + if !strings.Contains(err.Error(), "SwUpdateConfiguration") { + t.Errorf("error = %v, want it to name the type", err) + } +} + +// Every coerced key has to name a field the type declares. The generated +// equality test cannot see a wrong key: encoding/json ignores it on the bare +// and the quoted side alike, so both decodes land on the same zero value. +func TestLenientScalarTypesKeysAreRealFieldTags(t *testing.T) { + types := []GoType{ + {Name: "Root", Fields: []GoField{ + {Name: "Count", Type: "int", JSONTag: "count,omitempty"}, + {Name: "Hidden", Type: "int", JSONTag: "-"}, + {Name: "Dashed", Type: "int", JSONTag: "-,"}, + }}, + } + entries, _, err := lenientScalarTypes(types, map[string]bool{"Root": true}) + if err != nil { + t.Fatalf("lenientScalarTypes: %v", err) + } + declared := map[string]bool{"count": true, "-": true} + for _, e := range entries { + for _, k := range e.Keys { + if !declared[k.JSON] { + t.Errorf("%s: coerced key %q names no field tag", e.Name, k.JSON) + } + } + } + if len(entries) != 1 || len(entries[0].Keys) != 2 { + t.Fatalf("entries = %+v, want Root carrying count and the dashed key", entries) + } +} + +// A union hands the same bytes to the variant it selects, so the whole path +// renders as one flat object. The case exists because nothing else decodes +// through the dispatch: the per-type table builds each leaf directly. +func TestLenientUnionCasesFollowsANestedChain(t *testing.T) { + types := []GoType{ + {Name: "SwUpdateConfiguration", Discriminator: &GoDiscriminator{ + PropertyName: "enforcementType", + Variants: []GoDiscriminatorVariant{ + {Values: []string{"AUTOMATIC"}, TypeName: "SwUpdateAutomaticConfiguration", FieldName: "AUTOMATIC"}, + }, + }}, + {Name: "SwUpdateAutomaticConfiguration", Discriminator: &GoDiscriminator{ + PropertyName: "strategy", + Variants: []GoDiscriminatorVariant{ + {Values: []string{"LATEST"}, TypeName: "SwUpdateLatestConfiguration", FieldName: "LATEST"}, + }, + }}, + {Name: "SwUpdateLatestConfiguration", Fields: []GoField{ + {Name: "EnforceAfterDays", Type: "int", JSONTag: "enforceAfterDays"}, + }}, + } + entries := []lenientType{{Name: "SwUpdateLatestConfiguration", + Keys: []lenientKey{{JSON: "enforceAfterDays", Kind: jsonScalarKindNumber}}}} + + cases := lenientUnionCases(types, entries) + // Two entry points: the outer union, carrying both discriminators, and the + // inner one on its own, which a caller can also decode into directly. + if len(cases) != 2 { + t.Fatalf("cases = %+v, want the outer chain and the inner union", cases) + } + byUnion := make(map[string]lenientUnionCase, len(cases)) + for _, c := range cases { + byUnion[c.UnionType] = c + } + outer, ok := byUnion["SwUpdateConfiguration"] + if !ok { + t.Fatalf("cases = %+v, want one decoding into the outer union", cases) + } + if outer.ScalarJSON != "enforceAfterDays" { + t.Errorf("ScalarJSON = %q, want the leaf's coerced key", outer.ScalarJSON) + } + if len(outer.Discrim) != 2 || + outer.Discrim[0] != (lenientDiscrimValue{JSON: "enforcementType", Value: "AUTOMATIC"}) || + outer.Discrim[1] != (lenientDiscrimValue{JSON: "strategy", Value: "LATEST"}) { + t.Errorf("Discrim = %+v, want both discriminators on the path", outer.Discrim) + } + inner, ok := byUnion["SwUpdateAutomaticConfiguration"] + if !ok { + t.Fatalf("cases = %+v, want one decoding into the inner union", cases) + } + if len(inner.Discrim) != 1 || inner.Discrim[0].JSON != "strategy" { + t.Errorf("inner Discrim = %+v, want only its own discriminator", inner.Discrim) + } +} + +// The parent names the field a child decoder failed on, so the case has to +// pair a parent with a child that actually carries a coerced number. +func TestLenientParentCasesPairsAParentWithItsChild(t *testing.T) { + types := []GoType{ + {Name: "Deferrals", Fields: []GoField{ + {Name: "MajorPeriodInDays", Type: "*OptionalPeriodInDays", JSONTag: "MajorPeriodInDays,omitempty"}, + {Name: "MinorPeriodInDays", Type: "*OptionalPeriodInDays", JSONTag: "MinorPeriodInDays,omitempty"}, + }}, + {Name: "OptionalPeriodInDays", Fields: []GoField{{Name: "Value", Type: "*int", JSONTag: "Value,omitempty"}}}, + } + entries := []lenientType{ + {Name: "Deferrals"}, + {Name: "OptionalPeriodInDays", Keys: []lenientKey{{JSON: "Value", Kind: jsonScalarKindNumber}}}, + } + cases := lenientParentCases(types, entries) + if len(cases) != 1 { + t.Fatalf("cases = %+v, want one parent case", cases) + } + if cases[0].Name != "Deferrals" || cases[0].ChildJSON != "MajorPeriodInDays" || cases[0].ChildKey != "Value" { + t.Errorf("case = %+v, want Deferrals.MajorPeriodInDays/Value", cases[0]) + } +}