From a3da0a5e4fbda5a1df03fdd7e69abb64f7e05cd7 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 1 Jul 2026 13:11:02 -0400 Subject: [PATCH] feat(overlay): scope overrides with match.method and match.path Allow overlay command overrides to target a specific HTTP operation when duplicate upstream operation IDs produce the same command name. Improve conflict errors with operationId, HTTP method/path, and command identity. Signed-off-by: samzong --- README.md | 2 ++ internal/codegen/render/render.go | 32 +++++++++++++++++++++++++- internal/codegen/render/render_test.go | 21 +++++++++++++++++ internal/overlay/overlay.go | 6 +++++ internal/overlay/overlay_test.go | 6 +++++ 5 files changed, 66 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f290d5f..6ee3c98 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,8 @@ Command `ignore: true` removes a generated command. Command `hidden: true` keeps it generated but hidden from normal help and catalog output unless `--include-hidden` is used. Parameter `hidden: true` is a legacy alias for `deprecated: true`; prefer `deprecated: true` for parameter overlays. +Use `match.method` and `match.path` to scope an override when duplicate upstream +operation IDs generate the same command name. Bulk pagination defaults fill matching command params that have no spec default. Explicit `commands..params..default` still wins when a command needs a different value. Parameter `required: true` marks an existing generated flag as diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index 23ce9b6..c4a9e0d 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -133,7 +133,7 @@ func validateCommandPaths(specs []runtime.CommandSpec) error { } cmdPath := group + " " + use if prev, ok := seen[cmdPath]; ok { - return fmt.Errorf("command path %q conflicts between %q and %q", cmdPath, commandIdentity(prev), commandIdentity(spec)) + return fmt.Errorf("command path %q conflicts between %s and %s", cmdPath, commandDebugIdentity(prev), commandDebugIdentity(spec)) } seen[cmdPath] = spec } @@ -158,6 +158,23 @@ func commandIdentity(spec runtime.CommandSpec) string { return spec.PathTpl } +func commandDebugIdentity(spec runtime.CommandSpec) string { + var parts []string + if spec.OperationID != "" { + parts = append(parts, fmt.Sprintf("operationId=%q", spec.OperationID)) + } + if spec.Method != "" || spec.PathTpl != "" { + parts = append(parts, fmt.Sprintf("http=%q", strings.TrimSpace(spec.Method+" "+spec.PathTpl))) + } + if spec.Group != "" || spec.Use != "" { + parts = append(parts, fmt.Sprintf("command=%q", strings.TrimSpace(spec.Group+" "+spec.Use))) + } + if len(parts) == 0 { + return fmt.Sprintf("%q", commandIdentity(spec)) + } + return strings.Join(parts, ", ") +} + func rootCommandName(use string) string { fields := strings.Fields(strings.ToLower(use)) if len(fields) == 0 { @@ -183,6 +200,9 @@ func MergeOverlayModule(specs []runtime.CommandSpec, mod overlay.Module) []runti var merged []runtime.CommandSpec for _, s := range specs { o, ok := mod.Commands[s.Use] + if ok && !overrideMatches(s, o) { + ok = false + } if ok && o.Ignore { continue } @@ -198,6 +218,16 @@ func MergeOverlayModule(specs []runtime.CommandSpec, mod overlay.Module) []runti return merged } +func overrideMatches(spec runtime.CommandSpec, override overlay.Override) bool { + if override.Match.Method != "" && !strings.EqualFold(override.Match.Method, spec.Method) { + return false + } + if override.Match.Path != "" && override.Match.Path != spec.PathTpl { + return false + } + return true +} + func cloneCommandSpec(spec runtime.CommandSpec) runtime.CommandSpec { cloned := spec cloned.Aliases = append([]string(nil), spec.Aliases...) diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index e90f83f..0967259 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -293,6 +293,27 @@ func TestMergeOverlay_UseRename(t *testing.T) { } } +func TestMergeOverlay_PathScopedRename(t *testing.T) { + specs := []runtime.CommandSpec{ + {Group: "GatewayService", Use: "create-service", OperationID: "GatewayService_CreateService", Method: "POST", PathTpl: "/apis/v1alpha1/services"}, + {Group: "GatewayService", Use: "create-service", OperationID: "GatewayService_CreateService", Method: "POST", PathTpl: "/apis/v1alpha2/services"}, + } + _, err := ResolveFlatCommandPath("namespaced", 1, specs) + if err == nil || !strings.Contains(err.Error(), "/apis/v1alpha1/services") || !strings.Contains(err.Error(), "/apis/v1alpha2/services") { + t.Fatalf("conflict error = %v", err) + } + + merged := MergeOverlay(specs, map[string]overlay.Override{ + "create-service": {Match: overlay.OperationMatch{Method: "POST", Path: "/apis/v1alpha2/services"}, Use: "create-service-v1alpha2"}, + }) + if merged[0].Use != "create-service" || merged[1].Use != "create-service-v1alpha2" { + t.Fatalf("uses = %q, %q", merged[0].Use, merged[1].Use) + } + if _, err := ResolveFlatCommandPath("namespaced", 1, merged); err != nil { + t.Fatalf("renamed command should not conflict: %v", err) + } +} + func TestMergeOverlayModule_BulkPaginationDefaults(t *testing.T) { specs := []runtime.CommandSpec{ { diff --git a/internal/overlay/overlay.go b/internal/overlay/overlay.go index 8c8b669..b85687f 100644 --- a/internal/overlay/overlay.go +++ b/internal/overlay/overlay.go @@ -11,6 +11,7 @@ import ( type Override struct { Use string `yaml:"use"` + Match OperationMatch `yaml:"match"` Aliases []string `yaml:"aliases"` Shortcuts []Shortcut `yaml:"shortcuts"` Short string `yaml:"short"` @@ -26,6 +27,11 @@ type Override struct { KnownErrors []KnownError `yaml:"known_errors"` } +type OperationMatch struct { + Method string `yaml:"method"` + Path string `yaml:"path"` +} + type Example struct { Summary string `yaml:"summary"` Command string `yaml:"command"` diff --git a/internal/overlay/overlay_test.go b/internal/overlay/overlay_test.go index 5e41a95..9579b89 100644 --- a/internal/overlay/overlay_test.go +++ b/internal/overlay/overlay_test.go @@ -97,6 +97,9 @@ func TestLoadDir_ParsesExtendedFields(t *testing.T) { pageSize: "20" commands: create-user: + match: + method: POST + path: /users group: "Identity" hidden: true notes: @@ -138,6 +141,9 @@ commands: if cu.Group != "Identity" { t.Errorf("group = %q, want Identity", cu.Group) } + if cu.Match.Method != "POST" || cu.Match.Path != "/users" { + t.Errorf("match = %#v", cu.Match) + } if cu.Hidden == nil || !*cu.Hidden { t.Errorf("hidden = %v, want true", cu.Hidden) }