From a9c8b76eaa2a6dcd9eae814698c18bf1b3dc3aea Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Thu, 1 Oct 2026 23:17:46 +0000 Subject: [PATCH] query: support struct field name casing options Add support for converting struct field names to common casing conventions (snake_case, camelCase, kebab-case, PascalCase) via Options passed to Values or ValuesWithOptions, as well as per-field struct tag options (,snake, ,camel, ,kebab, ,pascal). Fixes #86 Signed-off-by: AdamMagued --- query/encode.go | 154 ++++++++++++++++++++++- query/encode_test.go | 282 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 431 insertions(+), 5 deletions(-) diff --git a/query/encode.go b/query/encode.go index ffb2097..6a377ec 100644 --- a/query/encode.go +++ b/query/encode.go @@ -27,6 +27,7 @@ import ( "strconv" "strings" "time" + "unicode" ) var timeType = reflect.TypeOf(time.Time{}) @@ -39,6 +40,36 @@ type Encoder interface { EncodeValues(key string, v *url.Values) error } +// Case defines the struct field name casing format used when encoding query parameters. +type Case int + +const ( + // CaseDefault uses the struct field's name as defined in the struct (usually PascalCase). + CaseDefault Case = iota + // CaseCamel converts struct field names to camelCase (e.g. "firstName", "userId"). + CaseCamel + // CaseSnake converts struct field names to snake_case (e.g. "first_name", "user_id"). + CaseSnake + // CasePascal converts struct field names to PascalCase (e.g. "FirstName", "UserId"). + CasePascal + // CaseKebab converts struct field names to kebab-case (e.g. "first-name", "user-id"). + CaseKebab +) + +// Uppercase constants for compatibility with Issue #86 specification. +const ( + CASE_DEFAULT = CaseDefault + CASE_CAMEL = CaseCamel + CASE_SNAKE = CaseSnake + CASE_PASCAL = CasePascal + CASE_KEBAB = CaseKebab +) + +// Options specifies optional configuration parameters for encoding query values. +type Options struct { + EncodingCase Case +} + // Values returns the url.Values encoding of v. // // Values expects to be passed a struct, and traverses it recursively using the @@ -122,7 +153,16 @@ type Encoder interface { // // Multiple fields that encode to the same URL parameter name will be included // as multiple URL values of the same name. -func Values(v interface{}) (url.Values, error) { +func Values(v interface{}, opts ...Options) (url.Values, error) { + var opt Options + if len(opts) > 0 { + opt = opts[0] + } + return ValuesWithOptions(v, opt) +} + +// ValuesWithOptions returns the url.Values encoding of v with the specified Options. +func ValuesWithOptions(v interface{}, opts Options) (url.Values, error) { values := make(url.Values) if v == nil { @@ -141,14 +181,14 @@ func Values(v interface{}) (url.Values, error) { return nil, fmt.Errorf("query: Values() expects struct input. Got %v", val.Kind()) } - err := reflectValue(values, val, "") + err := reflectValue(values, val, "", opts) return values, err } // reflectValue populates the values parameter from the struct fields in val. // Embedded structs are followed recursively (using the rules defined in the // Values function documentation) breadth-first. -func reflectValue(values url.Values, val reflect.Value, scope string) error { +func reflectValue(values url.Values, val reflect.Value, scope string, options Options) error { var embedded []reflect.Value typ := val.Type() @@ -176,6 +216,17 @@ func reflectValue(values url.Values, val reflect.Value, scope string) error { } name = sf.Name + if opts.Contains("snake") { + name = applyCase(name, CaseSnake) + } else if opts.Contains("camel") { + name = applyCase(name, CaseCamel) + } else if opts.Contains("kebab") { + name = applyCase(name, CaseKebab) + } else if opts.Contains("pascal") { + name = applyCase(name, CasePascal) + } else if options.EncodingCase != CaseDefault { + name = applyCase(name, options.EncodingCase) + } } if scope != "" { @@ -262,7 +313,7 @@ func reflectValue(values url.Values, val reflect.Value, scope string) error { } if sv.Kind() == reflect.Struct { - if err := reflectValue(values, sv, name); err != nil { + if err := reflectValue(values, sv, name, options); err != nil { return err } continue @@ -272,7 +323,7 @@ func reflectValue(values url.Values, val reflect.Value, scope string) error { } for _, f := range embedded { - if err := reflectValue(values, f, scope); err != nil { + if err := reflectValue(values, f, scope, options); err != nil { return err } } @@ -365,3 +416,96 @@ func (o tagOptions) Contains(option string) bool { } return false } + +// applyCase converts name to the requested Case format. +func applyCase(name string, c Case) string { + if c == CaseDefault || name == "" { + return name + } + return formatWords(splitWords(name), c) +} + +func splitWords(s string) []string { + var words []string + var word []rune + runes := []rune(s) + + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '_' || r == '-' || r == ' ' { + if len(word) > 0 { + words = append(words, string(word)) + word = word[:0] + } + continue + } + + if i > 0 { + prev := runes[i-1] + if unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)) { + if len(word) > 0 { + words = append(words, string(word)) + word = word[:0] + } + } else if unicode.IsUpper(prev) && unicode.IsUpper(r) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) { + if len(word) > 0 { + words = append(words, string(word)) + word = word[:0] + } + } + } + + word = append(word, r) + } + + if len(word) > 0 { + words = append(words, string(word)) + } + return words +} + +func formatWords(words []string, c Case) string { + if len(words) == 0 { + return "" + } + switch c { + case CaseSnake: + parts := make([]string, len(words)) + for i, w := range words { + parts[i] = strings.ToLower(w) + } + return strings.Join(parts, "_") + case CaseKebab: + parts := make([]string, len(words)) + for i, w := range words { + parts[i] = strings.ToLower(w) + } + return strings.Join(parts, "-") + case CaseCamel: + var b strings.Builder + for i, w := range words { + runes := []rune(w) + if i == 0 { + b.WriteString(strings.ToLower(w)) + } else { + b.WriteString(string(unicode.ToUpper(runes[0]))) + if len(runes) > 1 { + b.WriteString(strings.ToLower(string(runes[1:]))) + } + } + } + return b.String() + case CasePascal: + var b strings.Builder + for _, w := range words { + runes := []rune(w) + b.WriteString(string(unicode.ToUpper(runes[0]))) + if len(runes) > 1 { + b.WriteString(strings.ToLower(string(runes[1:]))) + } + } + return b.String() + default: + return strings.Join(words, "") + } +} diff --git a/query/encode_test.go b/query/encode_test.go index 241e4f1..c64c095 100644 --- a/query/encode_test.go +++ b/query/encode_test.go @@ -753,3 +753,285 @@ func TestParseTag(t *testing.T) { } } } + +func TestValues_EncodingCase(t *testing.T) { + type TestStruct struct { + FirstName string + UserID int + HTTPServer string + URL string + User2Name string + FieldX string + CustomName string `url:"custom_param"` + OmitEmpty string `url:",omitempty"` + } + + input := TestStruct{ + FirstName: "Alice", + UserID: 123, + HTTPServer: "nginx", + URL: "example.com", + User2Name: "bob", + FieldX: "x", + CustomName: "custom", + } + + // CaseDefault (and without options) + wantDefault := url.Values{ + "FirstName": {"Alice"}, + "UserID": {"123"}, + "HTTPServer": {"nginx"}, + "URL": {"example.com"}, + "User2Name": {"bob"}, + "FieldX": {"x"}, + "custom_param": {"custom"}, + } + testValue(t, input, wantDefault) + + vDefault, err := Values(input, Options{EncodingCase: CaseDefault}) + if err != nil { + t.Fatalf("Values with CaseDefault failed: %v", err) + } + if diff := cmp.Diff(wantDefault, vDefault); diff != "" { + t.Errorf("Values with CaseDefault mismatch:\n%s", diff) + } + + // CaseSnake and CASE_SNAKE + wantSnake := url.Values{ + "first_name": {"Alice"}, + "user_id": {"123"}, + "http_server": {"nginx"}, + "url": {"example.com"}, + "user2_name": {"bob"}, + "field_x": {"x"}, + "custom_param": {"custom"}, + } + vSnake, err := Values(input, Options{EncodingCase: CaseSnake}) + if err != nil { + t.Fatalf("Values with CaseSnake failed: %v", err) + } + if diff := cmp.Diff(wantSnake, vSnake); diff != "" { + t.Errorf("Values with CaseSnake mismatch:\n%s", diff) + } + vSnakeCompat, err := ValuesWithOptions(input, Options{EncodingCase: CASE_SNAKE}) + if err != nil { + t.Fatalf("ValuesWithOptions with CASE_SNAKE failed: %v", err) + } + if diff := cmp.Diff(wantSnake, vSnakeCompat); diff != "" { + t.Errorf("ValuesWithOptions with CASE_SNAKE mismatch:\n%s", diff) + } + + // CaseCamel and CASE_CAMEL + wantCamel := url.Values{ + "firstName": {"Alice"}, + "userId": {"123"}, + "httpServer": {"nginx"}, + "url": {"example.com"}, + "user2Name": {"bob"}, + "fieldX": {"x"}, + "custom_param": {"custom"}, + } + vCamel, err := Values(input, Options{EncodingCase: CaseCamel}) + if err != nil { + t.Fatalf("Values with CaseCamel failed: %v", err) + } + if diff := cmp.Diff(wantCamel, vCamel); diff != "" { + t.Errorf("Values with CaseCamel mismatch:\n%s", diff) + } + vCamelCompat, err := Values(input, Options{EncodingCase: CASE_CAMEL}) + if err != nil { + t.Fatalf("Values with CASE_CAMEL failed: %v", err) + } + if diff := cmp.Diff(wantCamel, vCamelCompat); diff != "" { + t.Errorf("Values with CASE_CAMEL mismatch:\n%s", diff) + } + + // CaseKebab and CASE_KEBAB + wantKebab := url.Values{ + "first-name": {"Alice"}, + "user-id": {"123"}, + "http-server": {"nginx"}, + "url": {"example.com"}, + "user2-name": {"bob"}, + "field-x": {"x"}, + "custom_param": {"custom"}, + } + vKebab, err := Values(input, Options{EncodingCase: CaseKebab}) + if err != nil { + t.Fatalf("Values with CaseKebab failed: %v", err) + } + if diff := cmp.Diff(wantKebab, vKebab); diff != "" { + t.Errorf("Values with CaseKebab mismatch:\n%s", diff) + } + vKebabCompat, err := Values(input, Options{EncodingCase: CASE_KEBAB}) + if err != nil { + t.Fatalf("Values with CASE_KEBAB failed: %v", err) + } + if diff := cmp.Diff(wantKebab, vKebabCompat); diff != "" { + t.Errorf("Values with CASE_KEBAB mismatch:\n%s", diff) + } + + // CasePascal and CASE_PASCAL + wantPascal := url.Values{ + "FirstName": {"Alice"}, + "UserId": {"123"}, + "HttpServer": {"nginx"}, + "Url": {"example.com"}, + "User2Name": {"bob"}, + "FieldX": {"x"}, + "custom_param": {"custom"}, + } + vPascal, err := Values(input, Options{EncodingCase: CasePascal}) + if err != nil { + t.Fatalf("Values with CasePascal failed: %v", err) + } + if diff := cmp.Diff(wantPascal, vPascal); diff != "" { + t.Errorf("Values with CasePascal mismatch:\n%s", diff) + } + vPascalCompat, err := Values(input, Options{EncodingCase: CASE_PASCAL}) + if err != nil { + t.Fatalf("Values with CASE_PASCAL failed: %v", err) + } + if diff := cmp.Diff(wantPascal, vPascalCompat); diff != "" { + t.Errorf("Values with CASE_PASCAL mismatch:\n%s", diff) + } + + // Unknown Case fallback + vUnknown, err := Values(input, Options{EncodingCase: Case(99)}) + if err != nil { + t.Fatalf("Values with unknown Case failed: %v", err) + } + if len(vUnknown) == 0 { + t.Errorf("Values with unknown Case returned empty") + } +} + +func TestValues_TagCasing(t *testing.T) { + type TaggedStruct struct { + FieldOne string `url:",snake"` + FieldTwo string `url:",camel"` + FieldThree string `url:",kebab"` + FieldFour string `url:",pascal"` + FieldFive string `url:"explicit_name,camel"` + } + + input := TaggedStruct{ + FieldOne: "one", + FieldTwo: "two", + FieldThree: "three", + FieldFour: "four", + FieldFive: "five", + } + + want := url.Values{ + "field_one": {"one"}, + "fieldTwo": {"two"}, + "field-three": {"three"}, + "FieldFour": {"four"}, + "explicit_name": {"five"}, + } + + testValue(t, input, want) +} + +func TestValues_NestedCasing(t *testing.T) { + type Address struct { + StreetName string + PostalCode int + } + type User struct { + HomeAddress Address + UserProfile struct { + DisplayName string + } + } + + input := User{ + HomeAddress: Address{ + StreetName: "Main St", + PostalCode: 12345, + }, + UserProfile: struct { + DisplayName string + }{ + DisplayName: "Alice", + }, + } + + wantSnake := url.Values{ + "home_address[street_name]": {"Main St"}, + "home_address[postal_code]": {"12345"}, + "user_profile[display_name]": {"Alice"}, + } + + vSnake, err := Values(input, Options{EncodingCase: CaseSnake}) + if err != nil { + t.Fatalf("Values nested with CaseSnake failed: %v", err) + } + if diff := cmp.Diff(wantSnake, vSnake); diff != "" { + t.Errorf("Values nested with CaseSnake mismatch:\n%s", diff) + } + + wantCamel := url.Values{ + "homeAddress[streetName]": {"Main St"}, + "homeAddress[postalCode]": {"12345"}, + "userProfile[displayName]": {"Alice"}, + } + + vCamel, err := Values(input, Options{EncodingCase: CaseCamel}) + if err != nil { + t.Fatalf("Values nested with CaseCamel failed: %v", err) + } + if diff := cmp.Diff(wantCamel, vCamel); diff != "" { + t.Errorf("Values nested with CaseCamel mismatch:\n%s", diff) + } +} + +func TestValues_EmbeddedCasing(t *testing.T) { + type Inner struct { + StreetName string + } + type Outer struct { + Inner + UserName string + } + + input := Outer{ + Inner: Inner{StreetName: "Main St"}, + UserName: "Alice", + } + + wantSnake := url.Values{ + "street_name": {"Main St"}, + "user_name": {"Alice"}, + } + + vSnake, err := Values(input, Options{EncodingCase: CaseSnake}) + if err != nil { + t.Fatalf("Values embedded with CaseSnake failed: %v", err) + } + if diff := cmp.Diff(wantSnake, vSnake); diff != "" { + t.Errorf("Values embedded with CaseSnake mismatch:\n%s", diff) + } +} + +func TestApplyCase_EdgeCases(t *testing.T) { + if got := applyCase("", CaseSnake); got != "" { + t.Errorf("applyCase(\"\") = %q, want \"\"", got) + } + if got := applyCase("___", CaseSnake); got != "" { + t.Errorf("applyCase(\"___\") = %q, want \"\"", got) + } + if got := applyCase("a_b-c d", CaseSnake); got != "a_b_c_d" { + t.Errorf("applyCase(\"a_b-c d\") = %q, want \"a_b_c_d\"", got) + } + if got := applyCase("A_B", CaseCamel); got != "aB" { + t.Errorf("applyCase(\"A_B\") = %q, want \"aB\"", got) + } + if got := applyCase("a_b", CasePascal); got != "AB" { + t.Errorf("applyCase(\"a_b\") = %q, want \"AB\"", got) + } + if got := formatWords(nil, CaseSnake); got != "" { + t.Errorf("formatWords(nil) = %q, want \"\"", got) + } +}