From c0aa19daba5a6597a366b43da39d220a46b97e24 Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Wed, 3 Dec 2025 15:54:11 -0500 Subject: [PATCH 1/5] useragent: add `FromSlice` function This function accepts a slice of any value and converts to a slice of `UserAgentProduct` structs. This functionality will be used by upstream clients which accept a list of raw string content to be appended to the User-Agent header, rather than a structured object with name, version, and comment sections explicitly defined. --- useragent/from.go | 49 +++++++++++++++++ useragent/from_test.go | 116 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 useragent/from.go create mode 100644 useragent/from_test.go diff --git a/useragent/from.go b/useragent/from.go new file mode 100644 index 00000000..ca6954d4 --- /dev/null +++ b/useragent/from.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package useragent + +import ( + "strings" + + "github.com/hashicorp/aws-sdk-go-base/v2/internal/config" + "github.com/hashicorp/aws-sdk-go-base/v2/internal/slices" +) + +// FromSlice applies the conversion defined in [fromString] to all elements +// of a slice +// +// Slices of types which cannot assert to a string, empty string values, and string +// values which do not match the expected `{product}/{version} ({comment})` +// pattern (where version and comment are optional) return a zero value struct. +func FromSlice[T any](sl []T) config.UserAgentProducts { + return slices.ApplyToAll(sl, func(v T) config.UserAgentProduct { + if s, ok := any(v).(string); ok && s != "" { + return fromString(s) + } + return config.UserAgentProduct{} + }) +} + +// fromString separates the provided string into the constituent parts +// expected by the UserAgentProduct struct +// +// Values which do not match the expected `{product}/{version} ({comment})` +// pattern, where version and comment are optional, return a zero value struct. +func fromString(s string) config.UserAgentProduct { + parts := strings.Split(s, "/") + switch len(parts) { + case 1: + return config.UserAgentProduct{Name: parts[0]} + case 2: //nolint: mnd + subparts := strings.Split(parts[1], "(") + if len(subparts) == 2 { //nolint: mnd + version := strings.TrimSpace(subparts[0]) + comment := strings.TrimSuffix(subparts[1], ")") + return config.UserAgentProduct{Name: parts[0], Version: version, Comment: comment} + } + return config.UserAgentProduct{Name: parts[0], Version: parts[1]} + } + + return config.UserAgentProduct{} +} diff --git a/useragent/from_test.go b/useragent/from_test.go new file mode 100644 index 00000000..d703c3c8 --- /dev/null +++ b/useragent/from_test.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package useragent + +import ( + "reflect" + "testing" + + "github.com/hashicorp/aws-sdk-go-base/v2/internal/config" +) + +func TestFromSlice(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s []any + want config.UserAgentProducts + }{ + { + "nil", + nil, + config.UserAgentProducts{}, + }, + { + "non-string element", + []any{false}, + config.UserAgentProducts{config.UserAgentProduct{}}, + }, + { + "valid string", + []any{"my-product/v1.2.3"}, + config.UserAgentProducts{ + config.UserAgentProduct{ + Name: "my-product", + Version: "v1.2.3", + }, + }, + }, + { + "valid and invalid string", + []any{"my-product/v1.2.3", "foo/bar/baz/qux"}, + config.UserAgentProducts{ + config.UserAgentProduct{ + Name: "my-product", + Version: "v1.2.3", + }, + config.UserAgentProduct{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FromSlice(tt.s) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FromSlice() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func Test_fromString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + want config.UserAgentProduct + }{ + { + "empty", + "", + config.UserAgentProduct{}, + }, + { + "name only", + "my-product", + config.UserAgentProduct{ + Name: "my-product", + }, + }, + { + "name and version", + "my-product/v1.2.3", + config.UserAgentProduct{ + Name: "my-product", + Version: "v1.2.3", + }, + }, + { + "name, version, and comment", + "my-product/v1.2.3 (a comment)", + config.UserAgentProduct{ + Name: "my-product", + Version: "v1.2.3", + Comment: "a comment", + }, + }, + { + "all the slash", + "foo/bar/baz/qux", + config.UserAgentProduct{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := fromString(tt.s) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("fromString() = %+v, want %+v", got, tt.want) + } + }) + } +} From 234c90f7e0e8468e520b41483c102818d0281ee8 Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Mon, 8 Dec 2025 10:03:23 -0500 Subject: [PATCH 2/5] useragent: skip empty string check --- useragent/from.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/useragent/from.go b/useragent/from.go index ca6954d4..e6b83d0d 100644 --- a/useragent/from.go +++ b/useragent/from.go @@ -18,7 +18,7 @@ import ( // pattern (where version and comment are optional) return a zero value struct. func FromSlice[T any](sl []T) config.UserAgentProducts { return slices.ApplyToAll(sl, func(v T) config.UserAgentProduct { - if s, ok := any(v).(string); ok && s != "" { + if s, ok := any(v).(string); ok { return fromString(s) } return config.UserAgentProduct{} From ac02a7c1c97fb125f4502628fd86e658547712a4 Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Mon, 8 Dec 2025 10:03:50 -0500 Subject: [PATCH 3/5] useragent(test): additional malformed comment cases --- useragent/from_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/useragent/from_test.go b/useragent/from_test.go index d703c3c8..287ab751 100644 --- a/useragent/from_test.go +++ b/useragent/from_test.go @@ -98,6 +98,25 @@ func Test_fromString(t *testing.T) { Comment: "a comment", }, }, + { + "comment malformed closing", + "my-product/v1.2.3 (a comment", + config.UserAgentProduct{ + Name: "my-product", + Version: "v1.2.3", + Comment: "a comment", + }, + }, + { + "comment missing parenthesis", + "my-product/v1.2.3 a comment", + // This is a known edge case, but the processed output will render identical + // to the original input despite the version and comment merging. + config.UserAgentProduct{ + Name: "my-product", + Version: "v1.2.3 a comment", + }, + }, { "all the slash", "foo/bar/baz/qux", From 5a24a5b55414789a767d608b55b43f0d61ec825d Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Mon, 8 Dec 2025 10:11:49 -0500 Subject: [PATCH 4/5] useragent: move all processing to `fromAny` --- useragent/from.go | 40 ++++++++++++++++------------------------ useragent/from_test.go | 18 ++++++++++++++---- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/useragent/from.go b/useragent/from.go index e6b83d0d..95b3d5b3 100644 --- a/useragent/from.go +++ b/useragent/from.go @@ -10,39 +10,31 @@ import ( "github.com/hashicorp/aws-sdk-go-base/v2/internal/slices" ) -// FromSlice applies the conversion defined in [fromString] to all elements +// FromSlice applies the conversion defined in [fromAny] to all elements // of a slice // // Slices of types which cannot assert to a string, empty string values, and string // values which do not match the expected `{product}/{version} ({comment})` // pattern (where version and comment are optional) return a zero value struct. func FromSlice[T any](sl []T) config.UserAgentProducts { - return slices.ApplyToAll(sl, func(v T) config.UserAgentProduct { - if s, ok := any(v).(string); ok { - return fromString(s) - } - return config.UserAgentProduct{} - }) + return slices.ApplyToAll(sl, func(v T) config.UserAgentProduct { return fromAny(v) }) } -// fromString separates the provided string into the constituent parts -// expected by the UserAgentProduct struct -// -// Values which do not match the expected `{product}/{version} ({comment})` -// pattern, where version and comment are optional, return a zero value struct. -func fromString(s string) config.UserAgentProduct { - parts := strings.Split(s, "/") - switch len(parts) { - case 1: - return config.UserAgentProduct{Name: parts[0]} - case 2: //nolint: mnd - subparts := strings.Split(parts[1], "(") - if len(subparts) == 2 { //nolint: mnd - version := strings.TrimSpace(subparts[0]) - comment := strings.TrimSuffix(subparts[1], ")") - return config.UserAgentProduct{Name: parts[0], Version: version, Comment: comment} +func fromAny(v any) config.UserAgentProduct { + if s, ok := v.(string); ok { + parts := strings.Split(s, "/") + switch len(parts) { + case 1: + return config.UserAgentProduct{Name: parts[0]} + case 2: //nolint: mnd + subparts := strings.Split(parts[1], "(") + if len(subparts) == 2 { //nolint: mnd + version := strings.TrimSpace(subparts[0]) + comment := strings.TrimSuffix(subparts[1], ")") + return config.UserAgentProduct{Name: parts[0], Version: version, Comment: comment} + } + return config.UserAgentProduct{Name: parts[0], Version: parts[1]} } - return config.UserAgentProduct{Name: parts[0], Version: parts[1]} } return config.UserAgentProduct{} diff --git a/useragent/from_test.go b/useragent/from_test.go index 287ab751..c3e5e556 100644 --- a/useragent/from_test.go +++ b/useragent/from_test.go @@ -61,14 +61,24 @@ func TestFromSlice(t *testing.T) { } } -func Test_fromString(t *testing.T) { +func Test_fromAny(t *testing.T) { t.Parallel() tests := []struct { name string - s string + v any want config.UserAgentProduct }{ + { + "nil", + nil, + config.UserAgentProduct{}, + }, + { + "non-string", + 1, + config.UserAgentProduct{}, + }, { "empty", "", @@ -126,9 +136,9 @@ func Test_fromString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := fromString(tt.s) + got := fromAny(tt.v) if !reflect.DeepEqual(got, tt.want) { - t.Errorf("fromString() = %+v, want %+v", got, tt.want) + t.Errorf("fromAny() = %+v, want %+v", got, tt.want) } }) } From e509f3fdc29086b0bb3262e0e886537cd61505d7 Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Mon, 8 Dec 2025 10:16:15 -0500 Subject: [PATCH 5/5] useragent: tweak `from` signature --- useragent/from.go | 6 +++--- useragent/from_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/useragent/from.go b/useragent/from.go index 95b3d5b3..e0a45347 100644 --- a/useragent/from.go +++ b/useragent/from.go @@ -17,11 +17,11 @@ import ( // values which do not match the expected `{product}/{version} ({comment})` // pattern (where version and comment are optional) return a zero value struct. func FromSlice[T any](sl []T) config.UserAgentProducts { - return slices.ApplyToAll(sl, func(v T) config.UserAgentProduct { return fromAny(v) }) + return slices.ApplyToAll(sl, func(v T) config.UserAgentProduct { return from(v) }) } -func fromAny(v any) config.UserAgentProduct { - if s, ok := v.(string); ok { +func from[T any](v T) config.UserAgentProduct { + if s, ok := any(v).(string); ok { parts := strings.Split(s, "/") switch len(parts) { case 1: diff --git a/useragent/from_test.go b/useragent/from_test.go index c3e5e556..e2215936 100644 --- a/useragent/from_test.go +++ b/useragent/from_test.go @@ -61,7 +61,7 @@ func TestFromSlice(t *testing.T) { } } -func Test_fromAny(t *testing.T) { +func Test_from(t *testing.T) { t.Parallel() tests := []struct { @@ -136,9 +136,9 @@ func Test_fromAny(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := fromAny(tt.v) + got := from(tt.v) if !reflect.DeepEqual(got, tt.want) { - t.Errorf("fromAny() = %+v, want %+v", got, tt.want) + t.Errorf("from() = %+v, want %+v", got, tt.want) } }) }