diff --git a/gob.go b/gob.go new file mode 100644 index 0000000..dad7492 --- /dev/null +++ b/gob.go @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "bytes" + "encoding/gob" +) + +// Optional numbers and gob. +// +// gob omits any struct field that holds the zero value for its type, and flattens a pointer to +// the value it points at. An optional number that is present and zero - "minimum": 0, which the +// JSON Schema meta-schema spells for every positiveInteger, or "maximum": 0 on a parameter - +// therefore travels as the zero value, is omitted, and comes back as a nil pointer. The bound is +// dropped and nothing reports it. +// +// [Schema], [Parameter], [Header] and [Items] are the types that carry those numbers, so each +// sends which of them were present-and-zero alongside the struct, and puts the zero back on the +// way in. The encoders sit on the outer types rather than on CommonValidations or SchemaProps +// because a method on an embedded type is promoted to the type embedding it: gob would then call +// it for the whole value and drop everything the embedded type does not hold. [Swagger] and +// [Operation] already carry their own encoders for the same reason. + +// zeroBounds records which optional numbers of a value were present and zero. +type zeroBounds uint16 + +const ( + zeroMaximum zeroBounds = 1 << iota + zeroMinimum + zeroMaxLength + zeroMinLength + zeroMaxItems + zeroMinItems + zeroMultipleOf + zeroMaxProperties + zeroMinProperties +) + +func floatBound(value *float64, bit zeroBounds) zeroBounds { + if value != nil && *value == 0 { + return bit + } + + return 0 +} + +func intBound(value *int64, bit zeroBounds) zeroBounds { + if value != nil && *value == 0 { + return bit + } + + return 0 +} + +func restoreFloat(value **float64, bounds, bit zeroBounds) { + if bounds&bit != 0 && *value == nil { + *value = new(float64) + } +} + +func restoreInt(value **int64, bounds, bit zeroBounds) { + if bounds&bit != 0 && *value == nil { + *value = new(int64) + } +} + +func commonBounds(v CommonValidations) zeroBounds { + return floatBound(v.Maximum, zeroMaximum) | + floatBound(v.Minimum, zeroMinimum) | + floatBound(v.MultipleOf, zeroMultipleOf) | + intBound(v.MaxLength, zeroMaxLength) | + intBound(v.MinLength, zeroMinLength) | + intBound(v.MaxItems, zeroMaxItems) | + intBound(v.MinItems, zeroMinItems) +} + +func restoreCommon(v *CommonValidations, bounds zeroBounds) { + restoreFloat(&v.Maximum, bounds, zeroMaximum) + restoreFloat(&v.Minimum, bounds, zeroMinimum) + restoreFloat(&v.MultipleOf, bounds, zeroMultipleOf) + restoreInt(&v.MaxLength, bounds, zeroMaxLength) + restoreInt(&v.MinLength, bounds, zeroMinLength) + restoreInt(&v.MaxItems, bounds, zeroMaxItems) + restoreInt(&v.MinItems, bounds, zeroMinItems) +} + +// GobEncode provides a gob encoder for Schema that keeps its zero-valued bounds. +func (s Schema) GobEncode() ([]byte, error) { + type plain Schema + + p := s.SchemaProps + bounds := floatBound(p.Maximum, zeroMaximum) | + floatBound(p.Minimum, zeroMinimum) | + floatBound(p.MultipleOf, zeroMultipleOf) | + intBound(p.MaxLength, zeroMaxLength) | + intBound(p.MinLength, zeroMinLength) | + intBound(p.MaxItems, zeroMaxItems) | + intBound(p.MinItems, zeroMinItems) | + intBound(p.MaxProperties, zeroMaxProperties) | + intBound(p.MinProperties, zeroMinProperties) + + var b bytes.Buffer + err := gob.NewEncoder(&b).Encode(struct { + Plain plain + Bounds zeroBounds + }{Plain: plain(s), Bounds: bounds}) + + return b.Bytes(), err +} + +// GobDecode provides a gob decoder for Schema that keeps its zero-valued bounds. +func (s *Schema) GobDecode(b []byte) error { + type plain Schema + + var raw struct { + Plain plain + Bounds zeroBounds + } + if err := gob.NewDecoder(bytes.NewBuffer(b)).Decode(&raw); err != nil { + return err + } + + *s = Schema(raw.Plain) + restoreFloat(&s.Maximum, raw.Bounds, zeroMaximum) + restoreFloat(&s.Minimum, raw.Bounds, zeroMinimum) + restoreFloat(&s.MultipleOf, raw.Bounds, zeroMultipleOf) + restoreInt(&s.MaxLength, raw.Bounds, zeroMaxLength) + restoreInt(&s.MinLength, raw.Bounds, zeroMinLength) + restoreInt(&s.MaxItems, raw.Bounds, zeroMaxItems) + restoreInt(&s.MinItems, raw.Bounds, zeroMinItems) + restoreInt(&s.MaxProperties, raw.Bounds, zeroMaxProperties) + restoreInt(&s.MinProperties, raw.Bounds, zeroMinProperties) + + return nil +} + +// GobEncode provides a gob encoder for Parameter that keeps its zero-valued bounds. +func (p Parameter) GobEncode() ([]byte, error) { + type plain Parameter + + var b bytes.Buffer + err := gob.NewEncoder(&b).Encode(struct { + Plain plain + Bounds zeroBounds + }{Plain: plain(p), Bounds: commonBounds(p.CommonValidations)}) + + return b.Bytes(), err +} + +// GobDecode provides a gob decoder for Parameter that keeps its zero-valued bounds. +func (p *Parameter) GobDecode(b []byte) error { + type plain Parameter + + var raw struct { + Plain plain + Bounds zeroBounds + } + if err := gob.NewDecoder(bytes.NewBuffer(b)).Decode(&raw); err != nil { + return err + } + + *p = Parameter(raw.Plain) + restoreCommon(&p.CommonValidations, raw.Bounds) + + return nil +} + +// GobEncode provides a gob encoder for Header that keeps its zero-valued bounds. +func (h Header) GobEncode() ([]byte, error) { + type plain Header + + var b bytes.Buffer + err := gob.NewEncoder(&b).Encode(struct { + Plain plain + Bounds zeroBounds + }{Plain: plain(h), Bounds: commonBounds(h.CommonValidations)}) + + return b.Bytes(), err +} + +// GobDecode provides a gob decoder for Header that keeps its zero-valued bounds. +func (h *Header) GobDecode(b []byte) error { + type plain Header + + var raw struct { + Plain plain + Bounds zeroBounds + } + if err := gob.NewDecoder(bytes.NewBuffer(b)).Decode(&raw); err != nil { + return err + } + + *h = Header(raw.Plain) + restoreCommon(&h.CommonValidations, raw.Bounds) + + return nil +} + +// GobEncode provides a gob encoder for Items that keeps its zero-valued bounds. +func (i Items) GobEncode() ([]byte, error) { + type plain Items + + var b bytes.Buffer + err := gob.NewEncoder(&b).Encode(struct { + Plain plain + Bounds zeroBounds + }{Plain: plain(i), Bounds: commonBounds(i.CommonValidations)}) + + return b.Bytes(), err +} + +// GobDecode provides a gob decoder for Items that keeps its zero-valued bounds. +func (i *Items) GobDecode(b []byte) error { + type plain Items + + var raw struct { + Plain plain + Bounds zeroBounds + } + if err := gob.NewDecoder(bytes.NewBuffer(b)).Decode(&raw); err != nil { + return err + } + + *i = Items(raw.Plain) + restoreCommon(&i.CommonValidations, raw.Bounds) + + return nil +} diff --git a/gob_test.go b/gob_test.go new file mode 100644 index 0000000..0dd8510 --- /dev/null +++ b/gob_test.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "reflect" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestGob_KeepsZeroValuedBounds pins the whole document against gob's zero-value elision. +// +// gob omits a struct field holding the zero value and flattens a pointer to what it points at, +// so an optional number that is present and zero used to come back nil. +func TestGob_KeepsZeroValuedBounds(t *testing.T) { + const doc = `{ + "swagger": "2.0", + "info": {"title": "zero-valued bounds", "version": "1.0.0"}, + "paths": { + "/x": { + "get": { + "parameters": [ + {"name": "q", "in": "query", "type": "integer", "maximum": 0, "minimum": 0, + "multipleOf": 0, "maxLength": 0, "minLength": 0}, + {"name": "a", "in": "query", "type": "array", "maxItems": 0, "minItems": 0, + "items": {"type": "integer", "maximum": 0, "minItems": 0}} + ], + "responses": { + "200": {"description": "ok", "headers": {"X-Count": {"type": "integer", "minimum": 0, "maxItems": 0}}} + } + } + } + }, + "definitions": { + "A": {"type": "object", "maxProperties": 0, "minProperties": 0, + "properties": {"p": {"type": "number", "minimum": 0, "maximum": 0, "maxLength": 0}}, + "items": {"type": "integer", "minItems": 0}} + } + }` + + original := new(Swagger) + require.NoError(t, json.Unmarshal([]byte(doc), original)) + + var buf bytes.Buffer + require.NoError(t, gob.NewEncoder(&buf).Encode(original)) + + decoded := new(Swagger) + require.NoError(t, gob.NewDecoder(&buf).Decode(decoded)) + + before, err := json.Marshal(original) + require.NoError(t, err) + after, err := json.Marshal(decoded) + require.NoError(t, err) + + assert.JSONEqT(t, string(before), string(after)) +} + +// TestGob_EveryOptionalNumberIsCarried walks the types that hold optional numbers and checks +// each of their pointer fields survives a gob round-trip when it points at zero. +// +// It reads the fields by reflection, so a new bound added to one of these types is covered +// without touching this test - and fails it until the encoder in gob.go carries it too. +func TestGob_EveryOptionalNumberIsCarried(t *testing.T) { + for _, subject := range []struct { + name string + value any + }{ + {"Schema", &Schema{}}, + {"Parameter", &Parameter{}}, + {"Header", &Header{}}, + {"Items", &Items{}}, + } { + t.Run(subject.name, func(t *testing.T) { + fields := optionalNumbers(reflect.ValueOf(subject.value).Elem()) + require.NotEmpty(t, fields, "expected optional numbers on %s", subject.name) + + for _, field := range fields { + t.Run(field, func(t *testing.T) { + value := reflect.New(reflect.TypeOf(subject.value).Elem()) + setZeroPointer(t, value.Elem(), field) + + var buf bytes.Buffer + require.NoError(t, gob.NewEncoder(&buf).EncodeValue(value)) + + back := reflect.New(reflect.TypeOf(subject.value).Elem()) + require.NoError(t, gob.NewDecoder(&buf).DecodeValue(back)) + + got := fieldByName(back.Elem(), field) + require.FalseT(t, got.IsNil(), "%s.%s was dropped by gob", subject.name, field) + assert.EqualT(t, float64(0), got.Elem().Convert(reflect.TypeFor[float64]()).Float()) + }) + } + }) + } +} + +// optionalNumbers returns the names of the *float64 and *int64 fields of a struct, embedded +// fields included. +func optionalNumbers(v reflect.Value) []string { + var names []string + var walk func(reflect.Value) + walk = func(sv reflect.Value) { + st := sv.Type() + for i := range st.NumField() { + f := st.Field(i) + if f.Anonymous && f.Type.Kind() == reflect.Struct { + walk(sv.Field(i)) + + continue + } + if f.Type.Kind() != reflect.Pointer { + continue + } + switch f.Type.Elem().Kind() { + case reflect.Float64, reflect.Int64: + names = append(names, f.Name) + default: + } + } + } + walk(v) + + return names +} + +func fieldByName(v reflect.Value, name string) reflect.Value { + return v.FieldByName(name) +} + +func setZeroPointer(t testing.TB, v reflect.Value, name string) { + t.Helper() + + field := v.FieldByName(name) + require.TrueT(t, field.IsValid(), "no field %q", name) + field.Set(reflect.New(field.Type().Elem())) +}