Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions gob.go
Original file line number Diff line number Diff line change
@@ -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
}
142 changes: 142 additions & 0 deletions gob_test.go
Original file line number Diff line number Diff line change
@@ -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()))
}
Loading