Skip to content

Commit a263c0d

Browse files
Merge pull request #14 from realmroot/codex/toolbox-auto-idempotency
feat(toolbox): automate idempotency keys
2 parents e03dc82 + 3e62a4b commit a263c0d

5 files changed

Lines changed: 131 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ the deployment URL and selects operation authority without exposing either to
154154
the caller. Absolute URLs remain available only for unregistered public HTTP
155155
targets.
156156

157+
When a published operation requires an `Idempotency-Key` header, Toolbox
158+
generates an RFC 8941 key automatically for both generated and generic
159+
resource-first commands. One logical invocation reuses that key across
160+
transient retries. Callers may still pass `--idempotency-key` on generated
161+
commands or `--header 'Idempotency-Key: "known-key"'` on generic commands when
162+
recovering a request whose outcome is unknown.
163+
157164
`platform` and `sync` are reserved Toolbox command names. `platform` always maps to the Resource Server whose published
158165
identifier is `realmroot`. Resource Server names also cannot collide with the
159166
generic HTTP verbs.

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.3
44

55
require (
66
github.com/oapi-codegen/runtime v1.6.0
7-
github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55
7+
github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0
88
github.com/spf13/cobra v1.10.2
99
)
1010

‎go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
110110
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
111111
github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55 h1:66B4rNBVWBvEtMQsT2q/Kdf0ihta/7gfTm7lmBwJLtA=
112112
github.com/saltbo/restish/v2 v2.3.1-0.20260828035857-92c8f86bdb55/go.mod h1:nB2f22CFu5X6R8Az9GDJ5+WTobImIs4l2cgczEUSWQY=
113+
github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0 h1:YbfmSZcD2VPZ8l4Ww8naxeB8BNYD6WmngoNuIkUCYOs=
114+
github.com/saltbo/restish/v2 v2.3.1-0.20260902155202-ca916d7113a0/go.mod h1:nB2f22CFu5X6R8Az9GDJ5+WTobImIs4l2cgczEUSWQY=
113115
github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0=
114116
github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0=
115117
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=

‎internal/cli/cli.go‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,7 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
910910
if err != nil {
911911
return err
912912
}
913+
var genericOperation *restish.OperationInspection
913914
if server, ok := selectedResourceServer(servers, args); ok {
914915
profile := "default"
915916
inspection, inspectErr := runtime.InspectAPI(ctx, server.CommandName, profile)
@@ -949,6 +950,7 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
949950
if !operationSelected {
950951
return fmt.Errorf("%s %s does not match one published operation for Resource Server %q", strings.ToUpper(args[0]), args[1], server.CommandName)
951952
}
953+
genericOperation = &operation
952954
if operationRequiresAuthority(operation) {
953955
details, detailsErr := client.AuthorizationDetails(ctx, server)
954956
if detailsErr != nil {
@@ -972,19 +974,63 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
972974
}
973975
}
974976
argvArgs := append([]string(nil), args...)
977+
if genericOperation != nil {
978+
argvArgs, err = prepareGenericIdempotency(*genericOperation, argvArgs)
979+
if err != nil {
980+
return err
981+
}
982+
}
975983
if !hasRuntimeFlag(argvArgs, "--rsh-print") {
976984
argvArgs = append(argvArgs, "--rsh-print", "b")
977985
}
978986
if a.json {
979987
argvArgs = append(argvArgs, "--rsh-output-format", "json")
980988
}
981989
argv := append([]string{"realmroot toolbox"}, argvArgs...)
982-
if err := runtime.Run(argv); err != nil {
990+
runOptions := restish.RunOptions{}
991+
if genericOperation != nil {
992+
runOptions.IdempotencyProtected = genericOperation.RequiresIdempotencyKey
993+
}
994+
if err := runtime.RunWithOptions(argv, runOptions); err != nil {
983995
return toolboxRuntimeError{cause: err}
984996
}
985997
return nil
986998
}
987999

1000+
func prepareGenericIdempotency(operation restish.OperationInspection, args []string) ([]string, error) {
1001+
prepared := append([]string(nil), args...)
1002+
if !operation.RequiresIdempotencyKey {
1003+
return prepared, nil
1004+
}
1005+
if !hasHeader(prepared, "Idempotency-Key") {
1006+
key, err := restish.NewIdempotencyKey()
1007+
if err != nil {
1008+
return nil, err
1009+
}
1010+
prepared = append(prepared, "--rsh-header", "Idempotency-Key: "+key)
1011+
}
1012+
return prepared, nil
1013+
}
1014+
1015+
func hasHeader(args []string, name string) bool {
1016+
for index, argument := range args {
1017+
var header string
1018+
switch {
1019+
case argument == "--rsh-header" && index+1 < len(args):
1020+
header = args[index+1]
1021+
case strings.HasPrefix(argument, "--rsh-header="):
1022+
header = strings.TrimPrefix(argument, "--rsh-header=")
1023+
default:
1024+
continue
1025+
}
1026+
headerName, _, found := strings.Cut(header, ":")
1027+
if found && strings.EqualFold(strings.TrimSpace(headerName), name) {
1028+
return true
1029+
}
1030+
}
1031+
return false
1032+
}
1033+
9881034
func hasRuntimeFlag(args []string, name string) bool {
9891035
for _, argument := range args {
9901036
if argument == name || strings.HasPrefix(argument, name+"=") {
@@ -1015,7 +1061,8 @@ func (a *App) newRestishRuntime(service *agent.Service, config *restish.Config)
10151061
return a.newRestishRuntimeWithCommandSurface(service, config, restish.CommandSurface{
10161062
HTTPMethods: []string{"get", "head", "post", "put", "patch", "delete"}, RegisteredAPIs: true, HideSupportCommands: true,
10171063
MetadataRefreshTimeout: 30 * time.Second, IgnoreUserConfig: true, DisablePlugins: true, HideInternalFlags: true,
1018-
CompactOperationHelp: true,
1064+
CompactOperationHelp: true,
1065+
AutomaticIdempotencyKeys: true,
10191066
})
10201067
}
10211068

‎internal/cli/cli_test.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http/httptest"
1010
"os"
1111
"path/filepath"
12+
"regexp"
1213
"slices"
1314
"strings"
1415
"testing"
@@ -20,6 +21,77 @@ import (
2021
restish "github.com/saltbo/restish/v2"
2122
)
2223

24+
func TestPrepareGenericIdempotencyLeavesUnprotectedOperationUnchanged(t *testing.T) {
25+
args := []string{"post", "https://api.example.com/tasks", "title:example"}
26+
27+
prepared, err := prepareGenericIdempotency(restish.OperationInspection{}, args)
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
if !slices.Equal(prepared, args) {
32+
t.Fatalf("prepared args = %#v, want unchanged %#v", prepared, args)
33+
}
34+
}
35+
36+
func TestPrepareGenericIdempotencyInjectsRFC8941Key(t *testing.T) {
37+
args := []string{"post", "https://api.example.com/tasks", "title:example"}
38+
39+
prepared, err := prepareGenericIdempotency(restish.OperationInspection{RequiresIdempotencyKey: true}, args)
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
if !slices.Equal(prepared[:len(args)], args) {
44+
t.Fatalf("prepared prefix = %#v, want original args %#v", prepared[:len(args)], args)
45+
}
46+
if len(prepared) != len(args)+2 || prepared[len(args)] != "--rsh-header" {
47+
t.Fatalf("prepared args = %#v, want generated header only", prepared)
48+
}
49+
header := strings.TrimPrefix(prepared[len(args)+1], "Idempotency-Key: ")
50+
if !regexp.MustCompile(`^"[0-9a-f]{32}"$`).MatchString(header) {
51+
t.Fatalf("generated Idempotency-Key = %q, want RFC 8941 quoted 128-bit hex string", header)
52+
}
53+
}
54+
55+
func TestPrepareGenericIdempotencyPreservesExistingHeaderWithoutDuplication(t *testing.T) {
56+
for _, test := range []struct {
57+
name string
58+
args []string
59+
}{
60+
{name: "separate", args: []string{"post", "https://api.example.com/tasks", "--rsh-header", `iDeMpOtEnCy-KeY: "caller-key"`}},
61+
{name: "equals", args: []string{"post", "https://api.example.com/tasks", `--rsh-header=IDEMPOTENCY-KEY: "caller-key"`}},
62+
} {
63+
t.Run(test.name, func(t *testing.T) {
64+
prepared, err := prepareGenericIdempotency(restish.OperationInspection{RequiresIdempotencyKey: true}, test.args)
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
if !slices.Equal(prepared[:len(test.args)], test.args) {
69+
t.Fatalf("prepared prefix = %#v, want existing header preserved %#v", prepared[:len(test.args)], test.args)
70+
}
71+
if !slices.Equal(prepared, test.args) {
72+
t.Fatalf("prepared args = %#v, want existing header unchanged %#v", prepared, test.args)
73+
}
74+
})
75+
}
76+
}
77+
78+
func TestPrepareGenericIdempotencyGeneratesOneKeyPerLogicalCall(t *testing.T) {
79+
operation := restish.OperationInspection{RequiresIdempotencyKey: true}
80+
args := []string{"post", "https://api.example.com/tasks"}
81+
82+
first, err := prepareGenericIdempotency(operation, args)
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
second, err := prepareGenericIdempotency(operation, args)
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if first[len(args)+1] == second[len(args)+1] {
91+
t.Fatalf("independent logical calls reused Idempotency-Key %q", first[len(args)+1])
92+
}
93+
}
94+
2395
func TestConfigureRestishPathsUsesVersionedCache(t *testing.T) {
2496
t.Setenv("RSH_CONFIG_DIR", "")
2597
t.Setenv("RSH_CACHE_DIR", "")

0 commit comments

Comments
 (0)