diff --git a/README.md b/README.md index fab702a..fbc2011 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,13 @@ the deployment URL and selects operation authority without exposing either to the caller. Absolute URLs remain available only for unregistered public HTTP targets. +When a published operation requires an `Idempotency-Key` header, Toolbox +generates an RFC 8941 key automatically for both generated and generic +resource-first commands. One logical invocation reuses that key across +transient retries. Callers may still pass `--idempotency-key` on generated +commands or `--header 'Idempotency-Key: "known-key"'` on generic commands when +recovering a request whose outcome is unknown. + `platform` and `sync` are reserved Toolbox command names. `platform` always maps to the Resource Server whose published identifier is `realmroot`. Resource Server names also cannot collide with the generic HTTP verbs. diff --git a/go.mod b/go.mod index 27489d7..e3f91f1 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.3 require ( github.com/oapi-codegen/runtime v1.6.0 - github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55 + github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0 github.com/spf13/cobra v1.10.2 ) diff --git a/go.sum b/go.sum index c30a90b..f737243 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55 h1:66B4rNBVWBvEtMQsT2q/Kdf0ihta/7gfTm7lmBwJLtA= github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55/go.mod h1:nB2f22CFu5X6R8Az9GDJ5+WTobImIs4l2cgczEUSWQY= +github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0 h1:YbfmSZcD2VPZ8l4Ww8naxeB8BNYD6WmngoNuIkUCYOs= +github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0/go.mod h1:nB2f22CFu5X6R8Az9GDJ5+WTobImIs4l2cgczEUSWQY= github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0= github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 73bea76..038abf5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -910,6 +910,7 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca if err != nil { return err } + var genericOperation *restish.OperationInspection if server, ok := selectedResourceServer(servers, args); ok { profile := "default" inspection, inspectErr := runtime.InspectAPI(ctx, server.CommandName, profile) @@ -949,6 +950,7 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca if !operationSelected { return fmt.Errorf("%s %s does not match one published operation for Resource Server %q", strings.ToUpper(args[0]), args[1], server.CommandName) } + genericOperation = &operation if operationRequiresAuthority(operation) { details, detailsErr := client.AuthorizationDetails(ctx, server) if detailsErr != nil { @@ -972,6 +974,12 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca } } argvArgs := append([]string(nil), args...) + if genericOperation != nil { + argvArgs, err = prepareGenericIdempotency(*genericOperation, argvArgs) + if err != nil { + return err + } + } if !hasRuntimeFlag(argvArgs, "--rsh-print") { argvArgs = append(argvArgs, "--rsh-print", "b") } @@ -979,12 +987,50 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca argvArgs = append(argvArgs, "--rsh-output-format", "json") } argv := append([]string{"realmroot toolbox"}, argvArgs...) - if err := runtime.Run(argv); err != nil { + runOptions := restish.RunOptions{} + if genericOperation != nil { + runOptions.IdempotencyProtected = genericOperation.RequiresIdempotencyKey + } + if err := runtime.RunWithOptions(argv, runOptions); err != nil { return toolboxRuntimeError{cause: err} } return nil } +func prepareGenericIdempotency(operation restish.OperationInspection, args []string) ([]string, error) { + prepared := append([]string(nil), args...) + if !operation.RequiresIdempotencyKey { + return prepared, nil + } + if !hasHeader(prepared, "Idempotency-Key") { + key, err := restish.NewIdempotencyKey() + if err != nil { + return nil, err + } + prepared = append(prepared, "--rsh-header", "Idempotency-Key: "+key) + } + return prepared, nil +} + +func hasHeader(args []string, name string) bool { + for index, argument := range args { + var header string + switch { + case argument == "--rsh-header" && index+1 < len(args): + header = args[index+1] + case strings.HasPrefix(argument, "--rsh-header="): + header = strings.TrimPrefix(argument, "--rsh-header=") + default: + continue + } + headerName, _, found := strings.Cut(header, ":") + if found && strings.EqualFold(strings.TrimSpace(headerName), name) { + return true + } + } + return false +} + func hasRuntimeFlag(args []string, name string) bool { for _, argument := range args { if argument == name || strings.HasPrefix(argument, name+"=") { @@ -1015,7 +1061,8 @@ func (a *App) newRestishRuntime(service *agent.Service, config *restish.Config) return a.newRestishRuntimeWithCommandSurface(service, config, restish.CommandSurface{ HTTPMethods: []string{"get", "head", "post", "put", "patch", "delete"}, RegisteredAPIs: true, HideSupportCommands: true, MetadataRefreshTimeout: 30 * time.Second, IgnoreUserConfig: true, DisablePlugins: true, HideInternalFlags: true, - CompactOperationHelp: true, + CompactOperationHelp: true, + AutomaticIdempotencyKeys: true, }) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c4b248c..c1ae7b9 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" "slices" "strings" "testing" @@ -20,6 +21,77 @@ import ( restish "github.com/saltbo/restish/v2" ) +func TestPrepareGenericIdempotencyLeavesUnprotectedOperationUnchanged(t *testing.T) { + args := []string{"post", "https://api.example.com/tasks", "title:example"} + + prepared, err := prepareGenericIdempotency(restish.OperationInspection{}, args) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(prepared, args) { + t.Fatalf("prepared args = %#v, want unchanged %#v", prepared, args) + } +} + +func TestPrepareGenericIdempotencyInjectsRFC8941Key(t *testing.T) { + args := []string{"post", "https://api.example.com/tasks", "title:example"} + + prepared, err := prepareGenericIdempotency(restish.OperationInspection{RequiresIdempotencyKey: true}, args) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(prepared[:len(args)], args) { + t.Fatalf("prepared prefix = %#v, want original args %#v", prepared[:len(args)], args) + } + if len(prepared) != len(args)+2 || prepared[len(args)] != "--rsh-header" { + t.Fatalf("prepared args = %#v, want generated header only", prepared) + } + header := strings.TrimPrefix(prepared[len(args)+1], "Idempotency-Key: ") + if !regexp.MustCompile(`^"[0-9a-f]{32}"$`).MatchString(header) { + t.Fatalf("generated Idempotency-Key = %q, want RFC 8941 quoted 128-bit hex string", header) + } +} + +func TestPrepareGenericIdempotencyPreservesExistingHeaderWithoutDuplication(t *testing.T) { + for _, test := range []struct { + name string + args []string + }{ + {name: "separate", args: []string{"post", "https://api.example.com/tasks", "--rsh-header", `iDeMpOtEnCy-KeY: "caller-key"`}}, + {name: "equals", args: []string{"post", "https://api.example.com/tasks", `--rsh-header=IDEMPOTENCY-KEY: "caller-key"`}}, + } { + t.Run(test.name, func(t *testing.T) { + prepared, err := prepareGenericIdempotency(restish.OperationInspection{RequiresIdempotencyKey: true}, test.args) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(prepared[:len(test.args)], test.args) { + t.Fatalf("prepared prefix = %#v, want existing header preserved %#v", prepared[:len(test.args)], test.args) + } + if !slices.Equal(prepared, test.args) { + t.Fatalf("prepared args = %#v, want existing header unchanged %#v", prepared, test.args) + } + }) + } +} + +func TestPrepareGenericIdempotencyGeneratesOneKeyPerLogicalCall(t *testing.T) { + operation := restish.OperationInspection{RequiresIdempotencyKey: true} + args := []string{"post", "https://api.example.com/tasks"} + + first, err := prepareGenericIdempotency(operation, args) + if err != nil { + t.Fatal(err) + } + second, err := prepareGenericIdempotency(operation, args) + if err != nil { + t.Fatal(err) + } + if first[len(args)+1] == second[len(args)+1] { + t.Fatalf("independent logical calls reused Idempotency-Key %q", first[len(args)+1]) + } +} + func TestConfigureRestishPathsUsesVersionedCache(t *testing.T) { t.Setenv("RSH_CONFIG_DIR", "") t.Setenv("RSH_CACHE_DIR", "")