diff --git a/CLAUDE.md b/CLAUDE.md index d4aa8712..eabd4516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1826,6 +1826,31 @@ 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 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. 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 + 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..4783392f 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 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 | @@ -802,6 +803,135 @@ 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 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. 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. 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 67 +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..91b4ddfd 100644 --- a/docs/WIRE-FACTS.md +++ b/docs/WIRE-FACTS.md @@ -1754,6 +1754,110 @@ 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 — 67 generated +`UnmarshalJSON` methods across the subtree, no change to any field type, +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 +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..7ebf3e68 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,154 @@ 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 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) + 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 — 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" + 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 { + 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 new file mode 100644 index 00000000..19968b78 --- /dev/null +++ b/jamfplatform/blueprints/lenient_scalars.go @@ -0,0 +1,1670 @@ +// 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 blueprints + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +// 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 +) + +// 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, 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, 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 + } + // 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. + retryErr := json.Unmarshal(fixed, v) + if retryErr == nil { + return nil + } + err, body = retryErr, fixed + } + 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. +// +// 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. +// 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" { + 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. 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: + 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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := 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 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 := 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 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 := unmarshalLenient(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 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 := 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 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 := unmarshalLenient(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 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 := 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 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 := 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 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 := 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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := 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 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 := unmarshalLenient(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 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 := 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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := 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 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 := unmarshalLenient(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 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 := 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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := unmarshalLenient(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 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 := 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 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 := 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 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 := 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 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 := 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, + "KeyboardSuggestions": jsonScalarBool, + "MathNotes": jsonScalarBool, +} + +// 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 := unmarshalLenient(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 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 := 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 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 := unmarshalLenient(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 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 := 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 new file mode 100644 index 00000000..b37cb1af --- /dev/null +++ b/jamfplatform/blueprints/lenient_scalars_test.go @@ -0,0 +1,425 @@ +// 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 +} + +// 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. +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, ",") + "}" +} + +// 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 +// 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_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 — +// 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_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 +// 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 := 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") + } + if !strings.Contains(err.Error(), "array") { + t.Errorf("error = %v, want the original decode error naming the array", err) + } +} + +// 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. +func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { + var target struct { + Count int64 `json:"count"` + } + const want = 9007199254740993 + if err := unmarshalLenient([]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 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", "null"} { + 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}, +} + +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 a14f3d31..59c63e43 100644 --- a/tools/generate/config.go +++ b/tools/generate/config.go @@ -298,6 +298,52 @@ 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. + // + // 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, // 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..ac4dde88 100644 --- a/tools/generate/emit.go +++ b/tools/generate/emit.go @@ -456,6 +456,7 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) var allSpecs []specWithMethods pkgEmitted := make(map[string]bool) var allTypes []GoType + lenientSeeds := make(map[string]map[string]bool) for _, ls := range specs { doc, err := loadSpec(ls.specPath, allowedOpsSet(ls.spec)) @@ -518,6 +519,22 @@ 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. 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 root, seed := range seeds { + if lenientSeeds[root] == nil { + lenientSeeds[root] = make(map[string]bool) + } + for name := range seed { + lenientSeeds[root][name] = true + } + } for _, t := range types { pkgEmitted[t.Name] = true } @@ -569,6 +586,17 @@ func processPackage(root string, cfg Config, pkgName string, specs []loadedSpec) if err := emitUnionRoundTripTest(pkgDir, goPkgName, structTypes); err != nil { return err } + 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, lenientUnions, lenientParents); 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 +661,7 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, pkgEmitted := make(map[string]bool) var allTypes []GoType pkgFormat := "" + lenientSeeds := make(map[string]map[string]bool) for _, ls := range specs { doc, err := loadSpec(ls.specPath, nil) @@ -678,6 +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) } + seeds, err := lenientScalarSeeds(doc, ls.spec.LenientScalarRoots) + if err != nil { + return fmt.Errorf("%s: %w", ls.spec.File, err) + } + for root, seed := range seeds { + if lenientSeeds[root] == nil { + lenientSeeds[root] = make(map[string]bool) + } + for name := range seed { + lenientSeeds[root][name] = true + } + } // 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 +746,15 @@ func processPackageTypesOnly(root string, cfg Config, pkgDir, goPkgName string, if err := emitTypesOnlyTest(pkgDir, goPkgName, allTypes); err != nil { return err } - return 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, lenientUnions, lenientParents) } // 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..16dc57fa --- /dev/null +++ b/tools/generate/lenient.go @@ -0,0 +1,1330 @@ +// Copyright Jamf Software LLC 2026 +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "log" + "maps" + "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. +// +// 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 +} + +// 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 +} + +// 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. +// +// 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. 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. + 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 "" +} + +// 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. +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 +} + +// 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 lenientScalarSeeds(doc *openapi3.T, roots []string) (map[string]map[string]bool, error) { + seeds := make(map[string]map[string]bool) + if len(roots) == 0 { + 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", + strings.Join(roots, ", ")) + } + var missing []string + for _, root := range roots { + if _, ok := doc.Components.Schemas[root]; !ok { + 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] { + return false + } + visited[name] = true + seed[goTypeName(name)] = true + 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"+ + "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 seeds, nil +} + +// 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. +// +// 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 + } + 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 _, ref := range lenientRefs(byName[name]) { + if _, ok := byName[ref]; ok && !reached[ref] { + queue = append(queue, ref) + } + } + } + + aliasBase := numericEnumBases(types) + keysOf := func(t GoType) []lenientKey { + 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}) + } + 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 + } + out = append(out, lenientType{Name: t.Name, Keys: keys}) + } + 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 +// 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 +} + +// lenientRuntimeSource is the package-independent half of lenient_scalars.go: +// the kind constants and the four decode helpers every generated method calls. +// +// 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 + +const ( + // jsonScalarNumber: the spec declares an integer or a number. + jsonScalarNumber jsonScalarKind = iota + 1 + // jsonScalarBool: the spec declares a boolean. + jsonScalarBool +) + +// 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, 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, 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 + } + // 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. + retryErr := json.Unmarshal(fixed, v) + if retryErr == nil { + return nil + } + err, body = retryErr, fixed + } + 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. +// +// 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. +// 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" { + 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. 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: + 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 +} +` + +// 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.\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") + } + fmt.Fprintf(&b, ` +// 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 := unmarshalLenient(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 root that asked for tolerance and got none, +// and reports every gap the plan left. +// +// 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 + } + 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, @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 +// 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 +} + +// 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. +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, ",") + "}" +} + +// 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 +// 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_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 — +// 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_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 +// 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 := 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") + } + if !strings.Contains(err.Error(), "array") { + t.Errorf("error = %v, want the original decode error naming the array", err) + } +} + +// 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. +func TestLenientScalars_WideNumberKeepsItsDigits(t *testing.T) { + var target struct { + Count int64 @BQ@json:"count"@BQ@ + } + const want = 9007199254740993 + if err := unmarshalLenient([]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 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", "null"} { + 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@} + +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, 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 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, + 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") + 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 +} + +// 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 new file mode 100644 index 00000000..b000f98f --- /dev/null +++ b/tools/generate/lenient_test.go @@ -0,0 +1,411 @@ +// 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 + + seeds, err := lenientScalarSeeds(doc, []string{"Component"}) + if err != nil { + 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)) + } + } + 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 := lenientScalarSeeds(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, _, 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 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) != 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) + } +} + +// 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, lenientDiagnostics{}); err != nil { + t.Errorf("no roots and no 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", + map[string][]lenientType{"Component": nil}, lenientDiagnostics{}) + if err == nil { + 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]) + } +}