Repository: go-openapi/strfmt
Pull request #113 (2023-12-27 06:29:23)

fix(uuid): UUID regexes to support all-or-none '-' separator
This PR changes the UUID validation to support either
UUIDs with the expected number of separators or no separator at all.

Under the hood, UUID validation no longer relies on regular expressions
(exported regexp patterns are marked as deprecated) but on
github.com/google/uuid. This brings a significant performance
improvement on validation (~ 15-20 times faster).

Notice that some non-standard UUID schemes as well as UUID v6 and v7
now pass "IsUUID".

* contributes https://github.com/go-swagger/go-swagger/issues/2878

Signed-off-by: Frederic BIDON <fredbi@yahoo.com>

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/go-openapi/strfmt/pull/113?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`03a91f9`)](https://app.codecov.io/gh/go-openapi/strfmt/commit/03a91f956951420f9446b68ce82948a4e6c57647?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi) 81.48% compared to head [(`12fa10e`)](https://app.codecov.io/gh/go-openapi/strfmt/pull/113?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi) 81.48%.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master     #113   +/-   ##
=======================================
  Coverage   81.48%   81.48%           
=======================================
  Files          12       12           
  Lines        2031     2031           
=======================================
  Hits         1655     1655           
  Misses        297      297           
  Partials       79       79           
```

| [Flag](https://app.codecov.io/gh/go-openapi/strfmt/pull/113/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi) | Coverage Δ | |
|---|---|---|
| [oldstable](https://app.codecov.io/gh/go-openapi/strfmt/pull/113/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi) | `81.48% <ø> (ø)` | |
| [stable](https://app.codecov.io/gh/go-openapi/strfmt/pull/113/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi) | `81.48% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/go-openapi/strfmt/pull/113?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-openapi).

casualjim: wouldn't it be better to just try to parse the UUID at this point?
fredbi: > wouldn't it be better to just try to parse the UUID at this point?

Yes. I've looked at google's uuid Parsing and it sure is faster... The problem is that they support their own edge cases etc, so they do not recommend it for validation... So yes you're right. I've just leant toward the easy implementation path... 

I can give a shot to a more direct approach. I am just afraid that then people will ask for more of that kind of rewrite...
fredbi: @casualjim  if we are okay to support Microsoft's proprietary extension to UUID, the faster, direct way is to use google/uuid.Parse and check the UUID's type. go/nogo? :)
casualjim: I vote for google/uuid. Seems like it's the most used uuid library for golang anyway.
fredbi: @casualjim OK. I'll do that today, it is not hard. TTYL.
fredbi: works as expected ~ x15-20 faster:
````go test -bench . -run UUID -benchtime 10s
goos: linux
goarch: amd64
pkg: github.com/go-openapi/strfmt
cpu: AMD Ryzen 7 5800X 8-Core Processor             
BenchmarkIsUUID/IsUUID_-_google.uuid-16         	532049923	        22.62 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUID_-_regexp-16              	33907857	       396.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUIDv3_-_google.uuid-16       	422999581	        28.40 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUIDv3_-_regexp-16            	35107362	       385.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUIDv4_-_google.uuid-16       	432093879	        27.62 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUIDv4_-_regexp-16            	34144864	       373.8 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUIDv5_-_google.uuid-16       	435730776	        28.49 ns/op	       0 B/op	       0 allocs/op
BenchmarkIsUUID/IsUUIDv5_-_regexp-16            	32326554	       377.1 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	github.com/go-openapi/strfmt	112.502s
````

Further, it  gives us the ability to extend easily to uuid v6 and v7. But that story shall also be told...

Patch:
diff --git a/default.go b/default.go
index a89a4de..2813714 100644
--- a/default.go
+++ b/default.go
@@ -25,6 +25,7 @@ import (
 	"strings"
 
 	"github.com/asaskevich/govalidator"
+	"github.com/google/uuid"
 	"go.mongodb.org/mongo-driver/bson"
 )
 
@@ -57,24 +58,35 @@ const (
 	//   - long top-level domain names (e.g. example.london) are permitted
 	//   - symbol unicode points are permitted (e.g. emoji) (not for top-level domain)
 	HostnamePattern = `^([a-zA-Z0-9\p{S}\p{L}]((-?[a-zA-Z0-9\p{S}\p{L}]{0,62})?)|([a-zA-Z0-9\p{S}\p{L}](([a-zA-Z0-9-\p{S}\p{L}]{0,61}[a-zA-Z0-9\p{S}\p{L}])?)(\.)){1,}([a-zA-Z\p{L}]){2,63})$`
+
+	// json null type
+	jsonNull = "null"
+)
+
+const (
 	// UUIDPattern Regex for UUID that allows uppercase
-	UUIDPattern = `(?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$`
+	//
+	// Deprecated: strfmt no longer uses regular expressions to validate UUIDs.
+	UUIDPattern = `(?i)(^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$)|(^[0-9a-f]{32}$)`
+
 	// UUID3Pattern Regex for UUID3 that allows uppercase
-	UUID3Pattern = `(?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$`
+	//
+	// Deprecated: strfmt no longer uses regular expressions to validate UUIDs.
+	UUID3Pattern = `(?i)(^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$)|(^[0-9a-f]{12}3[0-9a-f]{3}?[0-9a-f]{16}$)`
+
 	// UUID4Pattern Regex for UUID4 that allows uppercase
-	UUID4Pattern = `(?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$`
+	//
+	// Deprecated: strfmt no longer uses regular expressions to validate UUIDs.
+	UUID4Pattern = `(?i)(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$)|(^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$)`
+
 	// UUID5Pattern Regex for UUID5 that allows uppercase
-	UUID5Pattern = `(?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$`
-	// json null type
-	jsonNull = "null"
+	//
+	// Deprecated: strfmt no longer uses regular expressions to validate UUIDs.
+	UUID5Pattern = `(?i)(^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$)|(^[0-9a-f]{12}5[0-9a-f]{3}[89ab][0-9a-f]{15}$)`
 )
 
 var (
 	rxHostname = regexp.MustCompile(HostnamePattern)
-	rxUUID     = regexp.MustCompile(UUIDPattern)
-	rxUUID3    = regexp.MustCompile(UUID3Pattern)
-	rxUUID4    = regexp.MustCompile(UUID4Pattern)
-	rxUUID5    = regexp.MustCompile(UUID5Pattern)
 )
 
 // IsHostname returns true when the string is a valid hostname
@@ -99,24 +111,28 @@ func IsHostname(str string) bool {
 	return valid
 }
 
-// IsUUID returns true is the string matches a UUID, upper case is allowed
+// IsUUID returns true is the string matches a UUID (in any version, including v6 and v7), upper case is allowed
 func IsUUID(str string) bool {
-	return rxUUID.MatchString(str)
+	_, err := uuid.Parse(str)
+	return err == nil
 }
 
-// IsUUID3 returns true is the string matches a UUID, upper case is allowed
+// IsUUID3 returns true is the string matches a UUID v3, upper case is allowed
 func IsUUID3(str string) bool {
-	return rxUUID3.MatchString(str)
+	id, err := uuid.Parse(str)
+	return err == nil && id.Version() == uuid.Version(3)
 }
 
-// IsUUID4 returns true is the string matches a UUID, upper case is allowed
+// IsUUID4 returns true is the string matches a UUID v4, upper case is allowed
 func IsUUID4(str string) bool {
-	return rxUUID4.MatchString(str)
+	id, err := uuid.Parse(str)
+	return err == nil && id.Version() == uuid.Version(4)
 }
 
-// IsUUID5 returns true is the string matches a UUID, upper case is allowed
+// IsUUID5 returns true is the string matches a UUID v5, upper case is allowed
 func IsUUID5(str string) bool {
-	return rxUUID5.MatchString(str)
+	id, err := uuid.Parse(str)
+	return err == nil && id.Version() == uuid.Version(5)
 }
 
 // IsEmail validates an email address.
diff --git a/go.mod b/go.mod
index 595955a..c75e52f 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/go-openapi/strfmt
 require (
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
 	github.com/go-openapi/errors v0.21.0
-	github.com/google/uuid v1.4.0
+	github.com/google/uuid v1.5.0
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/oklog/ulid v1.3.1
 	github.com/stretchr/testify v1.8.4
diff --git a/go.sum b/go.sum
index 67e7613..60fcdf4 100644
--- a/go.sum
+++ b/go.sum
@@ -7,8 +7,8 @@ github.com/go-openapi/errors v0.21.0/go.mod h1:jxNTMUxRCKj65yb/okJGEtahVd7uvWnuW
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
-github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
+github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=


Test patch:
diff --git a/default_test.go b/default_test.go
index 7a20b0f..8b2e2f5 100644
--- a/default_test.go
+++ b/default_test.go
@@ -21,7 +21,9 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"io"
 	"reflect"
+	"regexp"
 	"strings"
 	"testing"
 
@@ -175,9 +177,26 @@ func TestFormatMAC(t *testing.T) {
 func TestFormatUUID3(t *testing.T) {
 	first3 := uuid.NewMD5(uuid.NameSpaceURL, []byte("somewhere.com"))
 	other3 := uuid.NewMD5(uuid.NameSpaceURL, []byte("somewhereelse.com"))
+	other4 := uuid.Must(uuid.NewRandom())
+	other5 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhereelse.com"))
 	uuid3 := UUID3(first3.String())
 	str := other3.String()
-	testStringFormat(t, &uuid3, "uuid3", str, []string{}, []string{"not-a-uuid"})
+	testStringFormat(t, &uuid3, "uuid3", str,
+		[]string{
+			other3.String(),
+			strings.ReplaceAll(other3.String(), "-", ""),
+		},
+		[]string{
+			"not-a-uuid",
+			other4.String(),
+			other5.String(),
+			strings.ReplaceAll(other4.String(), "-", ""),
+			strings.ReplaceAll(other5.String(), "-", ""),
+			strings.Replace(other3.String(), "-", "", 2),
+			strings.Replace(other4.String(), "-", "", 2),
+			strings.Replace(other5.String(), "-", "", 2),
+		},
+	)
 
 	// special case for zero UUID
 	var uuidZero UUID3
@@ -188,10 +207,27 @@ func TestFormatUUID3(t *testing.T) {
 
 func TestFormatUUID4(t *testing.T) {
 	first4 := uuid.Must(uuid.NewRandom())
+	other3 := uuid.NewMD5(uuid.NameSpaceURL, []byte("somewhere.com"))
 	other4 := uuid.Must(uuid.NewRandom())
+	other5 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhereelse.com"))
 	uuid4 := UUID4(first4.String())
 	str := other4.String()
-	testStringFormat(t, &uuid4, "uuid4", str, []string{}, []string{"not-a-uuid"})
+	testStringFormat(t, &uuid4, "uuid4", str,
+		[]string{
+			other4.String(),
+			strings.ReplaceAll(other4.String(), "-", ""),
+		},
+		[]string{
+			"not-a-uuid",
+			other3.String(),
+			other5.String(),
+			strings.ReplaceAll(other3.String(), "-", ""),
+			strings.ReplaceAll(other5.String(), "-", ""),
+			strings.Replace(other3.String(), "-", "", 2),
+			strings.Replace(other4.String(), "-", "", 2),
+			strings.Replace(other5.String(), "-", "", 2),
+		},
+	)
 
 	// special case for zero UUID
 	var uuidZero UUID4
@@ -202,10 +238,27 @@ func TestFormatUUID4(t *testing.T) {
 
 func TestFormatUUID5(t *testing.T) {
 	first5 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhere.com"))
+	other3 := uuid.NewMD5(uuid.NameSpaceURL, []byte("somewhere.com"))
+	other4 := uuid.Must(uuid.NewRandom())
 	other5 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhereelse.com"))
 	uuid5 := UUID5(first5.String())
 	str := other5.String()
-	testStringFormat(t, &uuid5, "uuid5", str, []string{}, []string{"not-a-uuid"})
+	testStringFormat(t, &uuid5, "uuid5", str,
+		[]string{
+			other5.String(),
+			strings.ReplaceAll(other5.String(), "-", ""),
+		},
+		[]string{
+			"not-a-uuid",
+			other3.String(),
+			other4.String(),
+			strings.ReplaceAll(other3.String(), "-", ""),
+			strings.ReplaceAll(other4.String(), "-", ""),
+			strings.Replace(other3.String(), "-", "", 2),
+			strings.Replace(other4.String(), "-", "", 2),
+			strings.Replace(other5.String(), "-", "", 2),
+		},
+	)
 
 	// special case for zero UUID
 	var uuidZero UUID5
@@ -216,10 +269,34 @@ func TestFormatUUID5(t *testing.T) {
 
 func TestFormatUUID(t *testing.T) {
 	first5 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhere.com"))
+	other3 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhereelse.com"))
+	other4 := uuid.Must(uuid.NewRandom())
 	other5 := uuid.NewSHA1(uuid.NameSpaceURL, []byte("somewhereelse.com"))
+	other6 := uuid.Must(uuid.NewV6())
+	other7 := uuid.Must(uuid.NewV7())
+	microsoft := "0" + other4.String() + "f"
+
 	uuid := UUID(first5.String())
 	str := other5.String()
-	testStringFormat(t, &uuid, "uuid", str, []string{}, []string{"not-a-uuid"})
+	testStringFormat(t, &uuid, "uuid", str,
+		[]string{
+			other3.String(),
+			other4.String(),
+			other5.String(),
+			strings.ReplaceAll(other3.String(), "-", ""),
+			strings.ReplaceAll(other4.String(), "-", ""),
+			strings.ReplaceAll(other5.String(), "-", ""),
+			other6.String(),
+			other7.String(),
+			microsoft,
+		},
+		[]string{
+			"not-a-uuid",
+			strings.Replace(other3.String(), "-", "", 2),
+			strings.Replace(other4.String(), "-", "", 2),
+			strings.Replace(other5.String(), "-", "", 2),
+		},
+	)
 
 	// special case for zero UUID
 	var uuidZero UUID
@@ -775,3 +852,48 @@ func TestDeepCopyPassword(t *testing.T) {
 	out3 := inNil.DeepCopy()
 	assert.Nil(t, out3)
 }
+
+func BenchmarkIsUUID(b *testing.B) {
+	const sampleSize = 100
+	rxUUID := regexp.MustCompile(UUIDPattern)
+	rxUUID3 := regexp.MustCompile(UUID3Pattern)
+	rxUUID4 := regexp.MustCompile(UUID4Pattern)
+	rxUUID5 := regexp.MustCompile(UUID5Pattern)
+
+	uuids := make([]string, 0, sampleSize)
+	uuid3s := make([]string, 0, sampleSize)
+	uuid4s := make([]string, 0, sampleSize)
+	uuid5s := make([]string, 0, sampleSize)
+
+	for i := 0; i < sampleSize; i++ {
+		seed := []byte(uuid.Must(uuid.NewRandom()).String())
+		uuids = append(uuids, uuid.Must(uuid.NewRandom()).String())
+		uuid3s = append(uuid3s, uuid.NewMD5(uuid.NameSpaceURL, seed).String())
+		uuid4s = append(uuid4s, uuid.Must(uuid.NewRandom()).String())
+		uuid5s = append(uuid5s, uuid.NewSHA1(uuid.NameSpaceURL, seed).String())
+	}
+
+	b.Run("IsUUID - google.uuid", benchmarkIs(uuids, IsUUID))
+	b.Run("IsUUID - regexp", benchmarkIs(uuids, func(id string) bool { return rxUUID.MatchString(id) }))
+
+	b.Run("IsUUIDv3 - google.uuid", benchmarkIs(uuid3s, IsUUID3))
+	b.Run("IsUUIDv3 - regexp", benchmarkIs(uuid3s, func(id string) bool { return rxUUID3.MatchString(id) }))
+
+	b.Run("IsUUIDv4 - google.uuid", benchmarkIs(uuid4s, IsUUID4))
+	b.Run("IsUUIDv4 - regexp", benchmarkIs(uuid4s, func(id string) bool { return rxUUID4.MatchString(id) }))
+
+	b.Run("IsUUIDv5 - google.uuid", benchmarkIs(uuid5s, IsUUID5))
+	b.Run("IsUUIDv5 - regexp", benchmarkIs(uuid5s, func(id string) bool { return rxUUID5.MatchString(id) }))
+}
+
+func benchmarkIs(input []string, fn func(string) bool) func(*testing.B) {
+	return func(b *testing.B) {
+		var isTrue bool
+		b.ReportAllocs()
+		b.ResetTimer()
+		for i := 0; i < b.N; i++ {
+			isTrue = fn(input[i%len(input)])
+		}
+		fmt.Fprintln(io.Discard, isTrue)
+	}
+}