diff --git a/pkg/validation/strfmt/default_test.go b/pkg/validation/strfmt/default_test.go index b31bf0db1..0b718da14 100644 --- a/pkg/validation/strfmt/default_test.go +++ b/pkg/validation/strfmt/default_test.go @@ -317,6 +317,10 @@ type testableFormat interface { } func testStringFormat(t *testing.T, what testableFormat, format, with string, validSamples, invalidSamples []string) { + testStringFormatWithRegistry(t, Default, what, format, with, validSamples, invalidSamples) +} + +func testStringFormatWithRegistry(t *testing.T, registry Registry, what testableFormat, format, with string, validSamples, invalidSamples []string) { // text encoding interface b := []byte(with) err := what.UnmarshalText(b) @@ -347,24 +351,39 @@ func testStringFormat(t *testing.T, what testableFormat, format, with string, va assert.Equalf(t, bj, b, "[%s]MarshalJSON: expected %v and %v to be value equal as []byte", format, string(b), with) // validation with Registry - for _, valid := range append(validSamples, with) { - testValid(t, format, valid) - } - - for _, invalid := range invalidSamples { - testInvalid(t, format, invalid) - } + t.Run("valid", func(t *testing.T) { + for _, valid := range append(validSamples, with) { + t.Run(valid, func(t *testing.T) { + testValidWithRegistry(t, registry, format, valid) + }) + } + }) + t.Run("invalid", func(t *testing.T) { + for _, invalid := range invalidSamples { + t.Run(invalid, func(t *testing.T) { + testInvalidWithRegistry(t, registry, format, invalid) + }) + } + }) } func testValid(t *testing.T, name, value string) { - ok := Default.Validates(name, value) + testValidWithRegistry(t, Default, name, value) +} + +func testValidWithRegistry(t *testing.T, registry Registry, name, value string) { + ok := registry.Validates(name, value) if !ok { t.Errorf("expected %q of type %s to be valid", value, name) } } func testInvalid(t *testing.T, name, value string) { - ok := Default.Validates(name, value) + testInvalidWithRegistry(t, Default, name, value) +} + +func testInvalidWithRegistry(t *testing.T, registry Registry, name, value string) { + ok := registry.Validates(name, value) if ok { t.Errorf("expected %q of type %s to be invalid", value, name) } diff --git a/pkg/validation/strfmt/format.go b/pkg/validation/strfmt/format.go index c85067a26..e2723c913 100644 --- a/pkg/validation/strfmt/format.go +++ b/pkg/validation/strfmt/format.go @@ -16,6 +16,8 @@ package strfmt import ( "encoding" + "encoding/json" + "fmt" "reflect" "strings" "sync" @@ -231,3 +233,40 @@ func (f *defaultFormats) Parse(name, data string) (interface{}, error) { } return nil, errors.InvalidTypeName(name) } + +// scan provides a generic implementation of sql.Scanner interface's Scan function for basic string formats. +func scan[T ~string](r *T, raw interface{}) error { + switch v := raw.(type) { + case []byte: + *r = T(v) + case string: + *r = T(v) + default: + return fmt.Errorf("cannot sql.Scan() strfmt.StringFormat from: %#v", v) + } + + return nil +} + +// unmarshalJSON provides a generic implementation of json.Unmarshaler interface's UnmarshalJSON function for basic string formats. +func unmarshalJSON[T ~string](r *T, data []byte) error { + if string(data) == jsonNull { + return nil + } + var ustr string + if err := json.Unmarshal(data, &ustr); err != nil { + return err + } + *r = T(ustr) + return nil +} + +// deepCopy provides a generic implementation of DeepCopy for basic string formats. +func deepCopy[T ~string](r *T) *T { + if r == nil { + return nil + } + out := new(T) + *out = *r + return out +} diff --git a/pkg/validation/strfmt/kubernetes-extensions.go b/pkg/validation/strfmt/kubernetes-extensions.go new file mode 100644 index 000000000..b04ba9adb --- /dev/null +++ b/pkg/validation/strfmt/kubernetes-extensions.go @@ -0,0 +1,153 @@ +// Copyright 2024 go-swagger maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package strfmt + +import ( + "encoding/json" + "regexp" +) + +// KubernetesExtensions is the formats registry for JSON Schema formats +// extensions defined by the Kubernetes project. +var KubernetesExtensions = NewSeededFormats(nil, nil) + +func init() { + // register formats in the KubernetesExtensions registry: + // - k8s.io/short-name + // - k8s.io/long-name + shortName := ShortName("") + KubernetesExtensions.Add("k8s.io/short-name", &shortName, IsShortName) + + longName := LongName("") + KubernetesExtensions.Add("k8s.io/long-name", &longName, IsLongName) +} + +// ShortName is a name, up to 63 characters long, composed of alphanumeric +// characters and dashes, which cannot begin or end with a dash. +// +// ShortName almost conforms to the definition of a label in DNS (RFC 1123), +// except that uppercase letters are not allowed. +// +// xref: https://github.com/kubernetes/kubernetes/issues/71140 +// +// swagger:strfmt k8s.io/short-name +type ShortName string + +func (r ShortName) MarshalText() ([]byte, error) { + return []byte(string(r)), nil +} + +func (r *ShortName) UnmarshalText(data []byte) error { // validation is performed later on + *r = ShortName(data) + return nil +} + +func (r *ShortName) Scan(raw interface{}) error { + return scan[ShortName](r, raw) +} + +func (r ShortName) String() string { + return string(r) +} + +func (r ShortName) MarshalJSON() ([]byte, error) { + return json.Marshal(string(r)) +} + +func (r *ShortName) UnmarshalJSON(data []byte) error { + return unmarshalJSON(r, data) +} + +func (r *ShortName) DeepCopyInto(out *ShortName) { + *out = *r +} + +func (r *ShortName) DeepCopy() *ShortName { + return deepCopy(r) +} + +const shortNameFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?" + +// ShortNameMaxLength is a label's max length in DNS (RFC 1123) +const ShortNameMaxLength int = 63 + +var shortNameRegexp = regexp.MustCompile("^" + shortNameFmt + "$") + +// IsShortName checks if a string is a valid ShortName. +func IsShortName(value string) bool { + return len(value) <= ShortNameMaxLength && + shortNameRegexp.MatchString(value) +} + +// LongName is a name, up to 253 characters long, composed of dot-separated +// segments; each segment uses only alphanumerics and dashes (no +// leading/trailing). +// +// LongName almost conforms to the definition of a subdomain in DNS (RFC 1123), +// except that uppercase letters are not allowed. and there is no max length of +// limit of 63 for each of the dot separated DNS Labels that make up the +// subdomain. +// +// xref: https://github.com/kubernetes/kubernetes/issues/71140 +// xref: https://github.com/kubernetes/kubernetes/issues/79351 +// +// swagger:strfmt k8s.io/long-name +type LongName string + +func (r LongName) MarshalText() ([]byte, error) { + return []byte(string(r)), nil +} + +func (r *LongName) UnmarshalText(data []byte) error { // validation is performed later on + *r = LongName(data) + return nil +} + +func (r *LongName) Scan(raw interface{}) error { + return scan[LongName](r, raw) +} + +func (r LongName) String() string { + return string(r) +} + +func (r LongName) MarshalJSON() ([]byte, error) { + return json.Marshal(string(r)) +} + +func (r *LongName) UnmarshalJSON(data []byte) error { + return unmarshalJSON(r, data) +} + +func (r *LongName) DeepCopyInto(out *LongName) { + *out = *r +} + +func (r *LongName) DeepCopy() *LongName { + return deepCopy(r) +} + +const longNameFmt string = shortNameFmt + "(\\." + shortNameFmt + ")*" + +// LongNameMaxLength is a subdomain's max length in DNS (RFC 1123) +const LongNameMaxLength int = 253 + +var longNameRegexp = regexp.MustCompile("^" + longNameFmt + "$") + +// IsLongName checks if a string is a valid LongName. +func IsLongName(value string) bool { + return len(value) <= LongNameMaxLength && + longNameRegexp.MatchString(value) +} diff --git a/pkg/validation/strfmt/kubernetes-extensions_test.go b/pkg/validation/strfmt/kubernetes-extensions_test.go new file mode 100644 index 000000000..95d4af4b1 --- /dev/null +++ b/pkg/validation/strfmt/kubernetes-extensions_test.go @@ -0,0 +1,77 @@ +// Copyright 2024 go-swagger maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package strfmt + +import ( + "strings" + "testing" +) + +var goodShortName = []string{ + "a", "ab", "abc", "a1", "a-1", "a--1--2--b", + "0", "01", "012", "1a", "1-a", "1--a--b--2", + strings.Repeat("a", 63), +} +var badShortName = []string{ + "", "A", "ABC", "aBc", "A1", "A-1", "1-A", + "-", "-a", "-1", + "_", "a_", "_a", "a_b", "1_", "_1", "1_2", + ".", "a.", ".a", "a.b", "1.", ".1", "1.2", + " ", "a ", " a", "a b", "1 ", " 1", "1 2", + strings.Repeat("a", 64), +} + +var prefixOnlyShortName = []string{ + "a-", "1-", +} + +func TestIsShortName(t *testing.T) { + v := ShortName("a") + testStringFormatWithRegistry(t, KubernetesExtensions, &v, "k8s.io/short-name", "a", goodShortName, append(badShortName, prefixOnlyShortName...)) +} + +var goodLongName = []string{ + "a", "ab", "abc", "a1", "a-1", "a--1--2--b", + "0", "01", "012", "1a", "1-a", "1--a--b--2", + "a.a", "ab.a", "abc.a", "a1.a", "a-1.a", "a--1--2--b.a", + "a.1", "ab.1", "abc.1", "a1.1", "a-1.1", "a--1--2--b.1", + "0.a", "01.a", "012.a", "1a.a", "1-a.a", "1--a--b--2", + "0.1", "01.1", "012.1", "1a.1", "1-a.1", "1--a--b--2.1", + "a.b.c.d.e", "aa.bb.cc.dd.ee", "1.2.3.4.5", "11.22.33.44.55", + strings.Repeat("a", 253), +} +var badLongName = []string{ + "", "A", "ABC", "aBc", "A1", "A-1", "1-A", + "-", "-a", "-1", + "_", "a_", "_a", "a_b", "1_", "_1", "1_2", + ".", "a.", ".a", "a..b", "1.", ".1", "1..2", + " ", "a ", " a", "a b", "1 ", " 1", "1 2", + "A.a", "aB.a", "ab.A", "A1.a", "a1.A", + "A.1", "aB.1", "A1.1", "1A.1", + "0.A", "01.A", "012.A", "1A.a", "1a.A", + "A.B.C.D.E", "AA.BB.CC.DD.EE", "a.B.c.d.e", "aa.bB.cc.dd.ee", + "a@b", "a,b", "a_b", "a;b", + "a:b", "a%b", "a?b", "a$b", + strings.Repeat("a", 254), +} + +var prefixOnlyLongName = []string{ + "a-", "1-", +} + +func TestFormatLongName(t *testing.T) { + v := LongName("a") + testStringFormatWithRegistry(t, KubernetesExtensions, &v, "k8s.io/long-name", "a", goodLongName, append(badLongName, prefixOnlyLongName...)) +}