diff --git a/internal/anysdk/casing_alias_resolution_test.go b/internal/anysdk/casing_alias_resolution_test.go new file mode 100644 index 00000000..528bd44d --- /dev/null +++ b/internal/anysdk/casing_alias_resolution_test.go @@ -0,0 +1,175 @@ +package anysdk + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/stackql/any-sdk/pkg/casing" +) + +// Issue #131 (surface parity) and #119 (hyphenated wire names): every alias +// path accepts the wire spelling first and the snake alias second, and nothing +// changes for a method that declares no native casing. + +func aliasTestParam(name, in string, required bool) *openapi3.ParameterRef { + return &openapi3.ParameterRef{Value: &openapi3.Parameter{Name: name, In: in, Required: required}} +} + +func aliasTestOpStore(nativeCasing string, snakeFlag bool, params ...*openapi3.ParameterRef) *standardOpenAPIOperationStore { + op := &standardOpenAPIOperationStore{ + Provider: &standardProvider{StackQLConfig: &standardStackQLConfig{SnakeCaseAliases: snakeFlag}}, + OperationRef: &OperationRef{Value: &openapi3.Operation{Parameters: openapi3.Parameters(params)}}, + } + if nativeCasing != "" { + op.Request = &standardExpectedRequest{NativeCasing: nativeCasing} + } + return op +} + +func aliasTestBodySchema(propNames ...string) Schema { + props := openapi3.Schemas{} + for _, p := range propNames { + props[p] = stringProperty() + } + return newStandardSchema(&openapi3.Schema{Type: "object", Properties: props, Required: []string{propNames[0]}}, nil, "Body", "") +} + +func TestParameterAliasesResolveAcronymsAndHyphens(t *testing.T) { + op := aliasTestOpStore(casing.Camel, false, + aliasTestParam("IPProtocol", openapi3.ParameterInQuery, false), + aliasTestParam("BinaryId", openapi3.ParameterInPath, true), + aliasTestParam("openai-organization", openapi3.ParameterInHeader, false), + ) + for alias, wire := range map[string]string{ + "ip_protocol": "IPProtocol", + "binary_id": "BinaryId", + "openai_organization": "openai-organization", + "IPProtocol": "IPProtocol", + } { + p, ok := op.GetParameter(alias) + if !ok || p.GetName() != wire { + t.Errorf("GetParameter(%q): ok=%v name=%v, want %q", alias, ok, p, wire) + } + p, ok = op.GetOperationParameter(alias) + if !ok || p.GetName() != wire { + t.Errorf("GetOperationParameter(%q): ok=%v name=%v, want %q", alias, ok, p, wire) + } + } + if _, ok := op.parameterMatch(map[string]interface{}{"binary_id": "b"}); !ok { + t.Error("snake alias of an acronym-headed required parameter must satisfy routing") + } + if _, ok := op.GetParameter("binary_idz"); ok { + t.Error("an unresolvable key must not resolve") + } + aliases := op.GetParametersIncludingNativeCasing() + for _, want := range []string{"openai_organization", "openai-organization", "ip_protocol", "IPProtocol"} { + if _, ok := aliases[want]; !ok { + t.Errorf("alias set is missing %q", want) + } + } + plain := aliasTestOpStore("", false, aliasTestParam("BinaryId", openapi3.ParameterInPath, true)) + if _, ok := plain.GetParameter("binary_id"); ok { + t.Error("snake alias must not resolve absent nativeCasing") + } +} + +func TestRequestBodyAttributeSnakeAliasRevertsToWireKey(t *testing.T) { + op := aliasTestOpStore(casing.Camel, false) + op.Request = &standardExpectedRequest{NativeCasing: casing.Camel, BodyMediaType: "application/json", Schema: aliasTestBodySchema("storageClass", "name")} + for _, in := range []string{"data__storage_class", "data__storageClass"} { + got, err := op.revertRequestBodyAttributeRename(in) + if err != nil || got != "storageClass" { + t.Errorf("revert(%q) = %q, %v; want storageClass", in, got, err) + } + } + if got, _ := op.revertRequestBodyAttributeRename("data__unknown_key"); got != "unknown_key" { + t.Errorf("unknown key must pass through, got %q", got) + } + for _, key := range []string{"data__storage_class", "data__storageClass"} { + params, err := splitHTTPParameters(map[int]map[string]interface{}{0: {key: "NEARLINE"}}, op) + if err != nil || len(params) != 1 { + t.Fatalf("splitHTTPParameters(%q): %v", key, err) + } + if got := params[0].GetRequestBody()["storageClass"]; got != "NEARLINE" { + t.Errorf("body for %q = %v, want storageClass=NEARLINE", key, params[0].GetRequestBody()) + } + } + plain := aliasTestOpStore("", false) + plain.Request = &standardExpectedRequest{BodyMediaType: "application/json", Schema: aliasTestBodySchema("storageClass")} + if got, _ := plain.revertRequestBodyAttributeRename("data__storage_class"); got != "storage_class" { + t.Errorf("absent nativeCasing the key must be untouched, got %q", got) + } +} + +func TestPresentationIsSnakeOnlyUnderBothGates(t *testing.T) { + body := aliasTestBodySchema("storageClass") + cases := []struct { + name string + nativeCasing string + snakeFlag bool + wantParams string + wantBodyRename string + }{ + {"both gates", casing.Camel, true, "max_results, data__storage_class", "data__storage_class"}, + {"flag only", "", true, "maxResults, data__storageClass", "data__storageClass"}, + {"casing only", casing.Camel, false, "maxResults, data__storageClass", "data__storageClass"}, + {"neither", "", false, "maxResults, data__storageClass", "data__storageClass"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + op := aliasTestOpStore(tc.nativeCasing, tc.snakeFlag, aliasTestParam("maxResults", openapi3.ParameterInQuery, true)) + op.Request = &standardExpectedRequest{NativeCasing: tc.nativeCasing, BodyMediaType: "application/json", Schema: body} + if got := op.ToPresentationMap(false)[RequiredParams]; got != tc.wantParams { + t.Errorf("RequiredParams = %q, want %q", got, tc.wantParams) + } + if got, _ := op.RenameRequestBodyAttribute("storageClass"); got != tc.wantBodyRename { + t.Errorf("RenameRequestBodyAttribute = %q, want %q", got, tc.wantBodyRename) + } + // Resolution keeps the wire spelling regardless of presentation. + if _, ok := op.GetParameters()["data__storageClass"]; !ok { + t.Errorf("wire body key must stay resolvable: %v", wireKeysOf(op.GetParameters())) + } + }) + } +} + +func TestBodylessMethodHasNoRequestSchemaError(t *testing.T) { + op := aliasTestOpStore(casing.Camel, false) + s, err := op.GetRequestBodySchema() + if err != nil || s != nil { + t.Fatalf("metadata-only request block: schema=%v err=%v, want nil, nil", s, err) + } + paths, err := op.getRequestBodyStringifiedPaths() + if err == nil || len(paths) != 0 { + t.Errorf("stringified paths = %v, %v; want empty with an explicit error", paths, err) + } + if _, err := op.getRequestBodySchemaAttributeMatcher(""); err == nil { + t.Error("an attribute matcher needs a schema and must still error") + } + if s, err := aliasTestOpStore("", false).GetRequestBodySchema(); err != nil || s != nil { + t.Errorf("no request block: schema=%v err=%v, want nil, nil", s, err) + } +} + +func TestSchemaPropertyLookupAcceptsSnakeAliasUnderFlag(t *testing.T) { + s := snakeAliasedObjectSchema(true, "machineType", "IPProtocol") + for _, key := range []string{"machineType", "machine_type", "IPProtocol", "ip_protocol"} { + if _, ok := s.GetProperty(key); !ok { + t.Errorf("GetProperty(%q) must resolve under snake_case_aliases", key) + } + if s.FindByPath(key, nil) == nil { + t.Errorf("FindByPath(%q) must resolve under snake_case_aliases", key) + } + } + if _, ok := s.GetProperty("machine_typez"); ok { + t.Error("an unknown column must not resolve") + } + off := snakeAliasedObjectSchema(false, "machineType") + if _, ok := off.GetProperty("machine_type"); ok { + t.Error("snake alias must not resolve when the flag is off") + } + if off.FindByPath("machine_type", nil) != nil { + t.Error("FindByPath snake alias must not resolve when the flag is off") + } +} diff --git a/internal/anysdk/introspection.go b/internal/anysdk/introspection.go index c05dcd26..3b4a8d18 100644 --- a/internal/anysdk/introspection.go +++ b/internal/anysdk/introspection.go @@ -168,7 +168,7 @@ func collectInputs(m StandardOperationStore, extended bool) ([]IntrospectedField // behaviour diverges from SHOW METHODS. if bodySchema, bodyErr := m.GetRequestBodySchema(); bodyErr == nil && bodySchema != nil && len(bodyRequiredOverride) > 0 { for rawKey := range bodyRequiredOverride { - renamedKey, renameErr := m.RenameRequestBodyAttribute(rawKey) + renamedKey, renameErr := m.renameRequestBodyAttribute(rawKey) if renameErr != nil { continue } diff --git a/internal/anysdk/operation_store.go b/internal/anysdk/operation_store.go index 71929e7f..3c33a444 100644 --- a/internal/anysdk/operation_store.go +++ b/internal/anysdk/operation_store.go @@ -285,6 +285,9 @@ func (op *standardOpenAPIOperationStore) getRequestBodyStringifiedPaths() (map[s if schemaErr != nil { return rv, schemaErr } + if requestBodySchema == nil { + return rv, op.noRequestBodyError() + } for k, v := range requestBodySchema.getProperties() { if v == nil { continue @@ -741,6 +744,7 @@ func (op *standardOpenAPIOperationStore) parameterMatch(params map[string]interf } requiredParameters := NewParameterSuffixMap() optionalParameters := NewParameterSuffixMap() + var declared []string for k, v := range op.getRequiredParameters() { key := fmt.Sprintf("%s.%s", op.getName(), k) _, keyExists := requiredParameters.Get(key) @@ -748,6 +752,7 @@ func (op *standardOpenAPIOperationStore) parameterMatch(params map[string]interf return copiedParams, false } requiredParameters.Put(key, v) + declared = append(declared, k) } for k, vOpt := range op.getOptionalParameters() { key := fmt.Sprintf("%s.%s", op.getName(), k) @@ -756,6 +761,7 @@ func (op *standardOpenAPIOperationStore) parameterMatch(params map[string]interf return copiedParams, false } optionalParameters.Put(key, vOpt) + declared = append(declared, k) } nc := op.GetRequestNativeCasing() for k := range copiedParams { @@ -770,17 +776,9 @@ func (op *standardOpenAPIOperationStore) parameterMatch(params map[string]interf // Reverse-casing retry: a snake_case SQL key satisfies its wire-name // parameter when the method declares a native request casing // (mirrors GetParameter). Absent native casing this is a no-op. - if nc != "" { - if wireKey := casing.FromSnake(k, nc); wireKey != k { - if requiredParameters.Delete(wireKey) { - delete(copiedParams, k) - continue - } - if optionalParameters.Delete(wireKey) { - delete(copiedParams, k) - continue - } - } + if nc != "" && deleteAliasedParameter(k, nc, declared, requiredParameters, optionalParameters) { + delete(copiedParams, k) + continue } // log.Debugf("parameter '%s' unmatched for method '%s'\n", k, op.getName()) } @@ -791,6 +789,15 @@ func (op *standardOpenAPIOperationStore) parameterMatch(params map[string]interf return copiedParams, false } +func deleteAliasedParameter(k, nc string, declared []string, required, optional *ParameterSuffixMap) bool { + for _, wireKey := range wireKeyCandidates(k, nc, declared) { + if required.Delete(wireKey) || optional.Delete(wireKey) { + return true + } + } + return false +} + func (op *standardOpenAPIOperationStore) namespaceParameterMatch(params map[string]interface{}) (map[string]interface{}, bool) { copiedParams := make(map[string]interface{}) for k, v := range params { @@ -798,6 +805,7 @@ func (op *standardOpenAPIOperationStore) namespaceParameterMatch(params map[stri } requiredParameters := NewParameterSuffixMap() optionalParameters := NewParameterSuffixMap() + var declared []string for k, v := range op.getRequiredParameters() { key := fmt.Sprintf("%s.%s", op.getName(), k) _, keyExists := requiredParameters.Get(key) @@ -805,6 +813,7 @@ func (op *standardOpenAPIOperationStore) namespaceParameterMatch(params map[stri return copiedParams, false } requiredParameters.Put(key, v) + declared = append(declared, k) } for k, vOpt := range op.getOptionalParameters() { key := fmt.Sprintf("%s.%s", op.getName(), k) @@ -813,6 +822,7 @@ func (op *standardOpenAPIOperationStore) namespaceParameterMatch(params map[stri return copiedParams, false } optionalParameters.Put(key, vOpt) + declared = append(declared, k) } nc := op.GetRequestNativeCasing() for k := range copiedParams { @@ -827,17 +837,9 @@ func (op *standardOpenAPIOperationStore) namespaceParameterMatch(params map[stri // Reverse-casing retry: a snake_case SQL key satisfies its wire-name // parameter when the method declares a native request casing // (mirrors GetParameter). Absent native casing this is a no-op. - if nc != "" { - if wireKey := casing.FromSnake(k, nc); wireKey != k { - if requiredParameters.Delete(wireKey) { - delete(copiedParams, k) - continue - } - if optionalParameters.Delete(wireKey) { - delete(copiedParams, k) - continue - } - } + if nc != "" && deleteAliasedParameter(k, nc, declared, requiredParameters, optionalParameters) { + delete(copiedParams, k) + continue } // log.Debugf("parameter '%s' unmatched for method '%s'\n", k, op.getName()) } @@ -988,6 +990,9 @@ func (m *standardOpenAPIOperationStore) getRequestBodyAttributes() (map[string]A if err != nil { return nil, err } + if s == nil { + return nil, m.noRequestBodyError() + } rv := make(map[string]Addressable) if s != nil { propz := s.getProperties() @@ -1012,6 +1017,9 @@ func (m *standardOpenAPIOperationStore) getRequestBodyAttributesNoRename() (map[ if err != nil { return nil, err } + if s == nil { + return nil, m.noRequestBodyError() + } rv := make(map[string]Addressable) if s != nil { propz := s.getProperties() @@ -1049,10 +1057,51 @@ func (m *standardOpenAPIOperationStore) getIndicatedRequestBodyAttributes(requir return rv, nil } +// RenameRequestBodyAttribute is the presentation form of a body key: the snake +// alias when the method declares a native casing and the provider opts in to +// snake_case_aliases, otherwise the wire key. func (m *standardOpenAPIOperationStore) RenameRequestBodyAttribute(k string) (string, error) { + if m.isSnakeCasePresentation() { + k = casing.ToSnake(k) + } return m.renameRequestBodyAttribute(k) } +func (m *standardOpenAPIOperationStore) isSnakeCasePresentation() bool { + if m.GetRequestNativeCasing() == "" { + return false + } + if m.Provider != nil { + return m.Provider.IsSnakeCaseAliasesEnabled() + } + if m.OpenAPIService != nil { + if prov := m.OpenAPIService.getProvider(); prov != nil { + return prov.IsSnakeCaseAliasesEnabled() + } + } + return false +} + +// wireRequestBodyAttribute maps a snake body key to its wire property when the +// method declares a native casing; wire keys pass through. +func (m *standardOpenAPIOperationStore) wireRequestBodyAttribute(key string) string { + if m.GetRequestNativeCasing() == "" { + return key + } + s, err := m.getRequestBodySchema() + if err != nil || s == nil { + return key + } + props := s.getProperties() + if _, isWire := props[key]; isWire { + return key + } + if wireKey, ok := wireKeyForAlias(key, wireKeysOf(props)); ok { + return wireKey + } + return key +} + func (m *standardOpenAPIOperationStore) renameRequestBodyAttribute(k string) (string, error) { paramTranslator, translatorInferErr := m.inferTranslator(m.getRequestBodyTranslateAlgorithmString()) if translatorInferErr != nil { @@ -1072,7 +1121,10 @@ func (m *standardOpenAPIOperationStore) revertRequestBodyAttributeRename(k strin return "", translatorInferErr } output, outputErr := paramTranslator.ReverseTranslate(k) - return output, outputErr + if outputErr != nil { + return output, outputErr + } + return m.wireRequestBodyAttribute(output), nil } func (m *standardOpenAPIOperationStore) getRequestBodyAttributeParentKey(algorithm string) (string, bool) { @@ -1097,6 +1149,9 @@ func (m *standardOpenAPIOperationStore) getRequestBodySchemaAttributeMatcher(pat if err != nil { return nil, err } + if schemaOfInterest == nil { + return nil, m.noRequestBodyError() + } if path != "" { schemaOfInterest = schemaOfInterest.FindByPath(path, map[string]bool{}) if schemaOfInterest == nil { @@ -1357,16 +1412,56 @@ func (m *standardOpenAPIOperationStore) GetRequestNativeCasing() string { return "" } +// snakeAliasOf is the snake_case alias of a wire key; a data__ body key keeps +// its prefix. +func snakeAliasOf(wireKey string) string { + if rest, ok := strings.CutPrefix(wireKey, requestBodyBaseKey); ok { + return requestBodyBaseKey + casing.ToSnake(rest) + } + return casing.ToSnake(wireKey) +} + +func wireKeysOf[T any](m map[string]T) []string { + rv := make([]string, 0, len(m)) + for k := range m { + rv = append(rv, k) + } + sort.Strings(rv) + return rv +} + +// wireKeyForAlias returns the declared wire key whose snake alias is key. +func wireKeyForAlias(key string, wireKeys []string) (string, bool) { + for _, wk := range wireKeys { + if wk != key && snakeAliasOf(wk) == key { + return wk, true + } + } + return "", false +} + +// wireKeyCandidates lists the wire spellings a snake key may resolve to: the +// declared key it aliases, then the mechanical FromSnake form. +func wireKeyCandidates(key, nativeCasing string, declared []string) []string { + var rv []string + if wk, ok := wireKeyForAlias(key, declared); ok { + rv = append(rv, wk) + } + if wk := casing.FromSnake(key, nativeCasing); wk != key { + rv = append(rv, wk) + } + return rv +} + func (m *standardOpenAPIOperationStore) GetParameter(paramKey string) (Addressable, bool) { params := m.GetParameters() if rv, ok := params[paramKey]; ok { return rv, true } - // Reverse-casing retry: when the method declares a native wire casing, convert - // the (snake_case) SQL key to that casing and retry. Absent native casing this - // is a no-op, preserving existing behaviour. + // Reverse-casing retry: a snake_case SQL key resolves to its wire-name + // parameter when the method declares a native casing; otherwise a no-op. if nc := m.GetRequestNativeCasing(); nc != "" { - if wireKey := casing.FromSnake(paramKey, nc); wireKey != paramKey { + for _, wireKey := range wireKeyCandidates(paramKey, nc, wireKeysOf(params)) { if rv, ok := params[wireKey]; ok { return rv, true } @@ -1393,7 +1488,7 @@ func (m *standardOpenAPIOperationStore) GetParametersIncludingNativeCasing() map retVal[k] = v } for wireKey, v := range base { - snakeKey := casing.ToSnake(wireKey) + snakeKey := snakeAliasOf(wireKey) if snakeKey == wireKey { continue } @@ -1437,8 +1532,12 @@ func (m *standardOpenAPIOperationStore) GetRequestBodyAttributesNoRename() (map[ func (m *standardOpenAPIOperationStore) ToPresentationMap(extended bool) map[string]interface{} { requiredParams := m.getRequiredNonBodyParameters() + snakePresentation := m.isSnakeCasePresentation() var requiredParamNames []string for s := range requiredParams { + if snakePresentation { + s = casing.ToSnake(s) + } requiredParamNames = append(requiredParamNames, s) } var requiredBodyParamNames []string @@ -1449,7 +1548,7 @@ func (m *standardOpenAPIOperationStore) ToPresentationMap(extended bool) map[str isRequiredFromMethodAnnotation = slices.Contains(m.Request.Required, k) } if v.IsRequired() || isRequiredFromMethodAnnotation { - renamedKey, renamedKeyErr := m.renameRequestBodyAttribute(k) + renamedKey, renamedKeyErr := m.RenameRequestBodyAttribute(k) if renamedKeyErr != nil { requiredBodyParamNames = append(requiredBodyParamNames, k) continue @@ -1507,13 +1606,27 @@ func (op *standardOpenAPIOperationStore) GetOperationParameter(key string) (Addr // carries the wire name, so HttpParameters.StoreParameter re-keys the // value to the wire form for request construction. if nc := op.GetRequestNativeCasing(); nc != "" { - if wireKey := casing.FromSnake(key, nc); wireKey != key { - return op.getOperationParameterByWireKey(wireKey) + for _, wireKey := range wireKeyCandidates(key, nc, op.operationParameterWireKeys()) { + if rv, ok := op.getOperationParameterByWireKey(wireKey); ok { + return rv, true + } } } return nil, false } +func (op *standardOpenAPIOperationStore) operationParameterWireKeys() []string { + rv := wireKeysOf(op.Parameters) + if op.OperationRef != nil && op.OperationRef.Value != nil { + for _, p := range op.OperationRef.Value.Parameters { + if p != nil && p.Value != nil { + rv = append(rv, p.Value.Name) + } + } + } + return rv +} + func (op *standardOpenAPIOperationStore) getOperationParameterByWireKey(key string) (Addressable, bool) { paramLocal, isParamLocal := op.Parameters[key] if isParamLocal { @@ -1808,11 +1921,17 @@ func (op *standardOpenAPIOperationStore) GetRequestBodySchema() (Schema, error) return op.getRequestBodySchema() } +// getRequestBodySchema returns nil, nil when the method declares no body; the +// body-attribute accessors keep reporting that as an error. func (op *standardOpenAPIOperationStore) getRequestBodySchema() (Schema, error) { if op.Request != nil && op.Request.Schema != nil { return op.Request.Schema, nil } - return nil, fmt.Errorf("no request body for operation = %s", op.GetName()) + return nil, nil +} + +func (op *standardOpenAPIOperationStore) noRequestBodyError() error { + return fmt.Errorf("no request body for operation = %s", op.GetName()) } func (op *standardOpenAPIOperationStore) GetRequestBodyRequiredProperties() ([]string, error) { diff --git a/internal/anysdk/schema.go b/internal/anysdk/schema.go index 3db91e87..9441804c 100644 --- a/internal/anysdk/schema.go +++ b/internal/anysdk/schema.go @@ -712,13 +712,16 @@ func (s *standardSchema) GetProperty(propertyKey string) (Schema, bool) { } func (s *standardSchema) getProperty(propertyKey string) (Schema, bool) { - var sc *openapi3.SchemaRef - var ok bool + props := s.Properties if s.hasPolymorphicProperties() { - polySchema := s.getFattnedPolymorphicSchema() - sc, ok = polySchema.getRawProperty(propertyKey) - } else { - sc, ok = s.Properties[propertyKey] + props = s.getFattnedPolymorphicSchema().getPropertiesOpenapi3() + } + sc, ok := props[propertyKey] + if !ok && s.isSnakeCaseAliasesEnabled() { + // A snake alias resolves to its wire property; wire keys match first. + if wireKey, aliasOK := wireKeyForAlias(propertyKey, wireKeysOf(props)); aliasOK { + sc, ok = props[wireKey] + } } if !ok { return nil, false @@ -1379,6 +1382,13 @@ func (s *standardSchema) FindByPath(path string, visited map[string]bool) Schema fs.setAlreadyExpanded(true) return fs.FindByPath(path, visited) } + if s.isSnakeCaseAliasesEnabled() { + if _, exact := s.Properties[path]; !exact { + if wireKey, ok := wireKeyForAlias(path, wireKeysOf(s.Properties)); ok { + path = wireKey + } + } + } for k, v := range s.Properties { if v.Ref != "" { isVis, ok := visited[v.Ref] diff --git a/pkg/casing/casing.go b/pkg/casing/casing.go index fe1d7452..6715432e 100644 --- a/pkg/casing/casing.go +++ b/pkg/casing/casing.go @@ -46,6 +46,16 @@ func ToSnake(name string) string { } func xform(name, sep string) string { + // Hyphenated names (HTTP headers) are transformed segment by segment. + if strings.Contains(name, "-") { + var parts []string + for _, seg := range strings.Split(name, "-") { + if seg != "" { + parts = append(parts, xform(seg, sep)) + } + } + return strings.Join(parts, sep) + } // If the separator is already present, botocore treats the name as final. if strings.Contains(name, sep) { return name diff --git a/pkg/casing/casing_test.go b/pkg/casing/casing_test.go index 9c8b8184..f37dfbe8 100644 --- a/pkg/casing/casing_test.go +++ b/pkg/casing/casing_test.go @@ -94,3 +94,28 @@ func TestIsKnownCasing(t *testing.T) { t.Errorf("IsKnownCasing(\"bogus\") = true, want false") } } + +// Hyphenated wire names (HTTP headers) split on '-' as well as on case +// boundaries, so they gain a usable snake alias (issue #119). +func TestToSnakeHyphenated(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"openai-organization", "openai_organization"}, + {"OpenAI-Organization", "open_ai_organization"}, + {"X-Amz-Date", "x_amz_date"}, + {"Content-MD5", "content_md5"}, + {"-leading-", "leading"}, + {"VPCId", "vpc_id"}, + {"training_file", "training_file"}, + } + for _, c := range cases { + if got := ToSnake(c.in); got != c.want { + t.Errorf("ToSnake(%q) = %q, want %q", c.in, got, c.want) + } + } + if got := FromSnake("openai_organization", Kebab); got != "openai-organization" { + t.Errorf("FromSnake kebab inverse = %q", got) + } +}