Skip to content

Commit 0350c7e

Browse files
Merge pull request #80 from danielgtaylor/edit
feat: add edit convenience command
2 parents ad772bf + f31ee8d commit 0350c7e

File tree

8 files changed

+395
-53
lines changed

8 files changed

+395
-53
lines changed

cli/cli.go

+28-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/spf13/cobra"
2222
"github.com/spf13/pflag"
2323
"github.com/spf13/viper"
24+
"gopkg.in/yaml.v2"
2425
)
2526

2627
// Root command (entrypoint) of the CLI.
@@ -56,10 +57,10 @@ Aliases:
5657
Examples:
5758
{{.Example}}{{end}}{{if (not .Parent)}}{{if (gt (len .Commands) 9)}}
5859
59-
Available API Commands:{{range .Commands}}{{if (not (or (eq .Name "help") (eq .Name "get") (eq .Name "put") (eq .Name "post") (eq .Name "patch") (eq .Name "delete") (eq .Name "head") (eq .Name "options") (eq .Name "cert") (eq .Name "api") (eq .Name "links")))}}
60+
Available API Commands:{{range .Commands}}{{if (not (or (eq .Name "help") (eq .Name "get") (eq .Name "put") (eq .Name "post") (eq .Name "patch") (eq .Name "delete") (eq .Name "head") (eq .Name "options") (eq .Name "cert") (eq .Name "api") (eq .Name "links") (eq .Name "edit") (eq .Name "completion")))}}
6061
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
6162
62-
Generic Commands:{{range .Commands}}{{if (or (eq .Name "help") (eq .Name "get") (eq .Name "put") (eq .Name "post") (eq .Name "patch") (eq .Name "delete") (eq .Name "head") (eq .Name "options") (eq .Name "cert") (eq .Name "api") (eq .Name "links"))}}
63+
Generic Commands:{{range .Commands}}{{if (or (eq .Name "help") (eq .Name "get") (eq .Name "put") (eq .Name "post") (eq .Name "patch") (eq .Name "delete") (eq .Name "head") (eq .Name "options") (eq .Name "cert") (eq .Name "api") (eq .Name "links") (eq .Name "edit") (eq .Name "completion"))}}
6364
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{else}}{{if .HasAvailableSubCommands}}
6465
6566
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
@@ -225,6 +226,30 @@ func Init(name string, version string) {
225226
}
226227
Root.AddCommand(delete)
227228

229+
var interactive *bool
230+
var noPrompt *bool
231+
var editFormat *string
232+
edit := &cobra.Command{
233+
Use: "edit uri [-i] [body...]",
234+
Short: "Edit a resource by URI",
235+
Long: "Convenience function which combines a GET, edit, and PUT operation into one command",
236+
Args: cobra.MinimumNArgs(1),
237+
Run: func(cmd *cobra.Command, args []string) {
238+
switch *editFormat {
239+
case "json":
240+
edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, func(v interface{}) ([]byte, error) {
241+
return json.MarshalIndent(v, "", " ")
242+
}, json.Unmarshal, ".json")
243+
case "yaml":
244+
edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, yaml.Marshal, yaml.Unmarshal, ".yaml")
245+
}
246+
},
247+
}
248+
interactive = edit.Flags().BoolP("rsh-interactive", "i", false, "Open an interactive editor")
249+
noPrompt = edit.Flags().BoolP("rsh-yes", "y", false, "Disable prompt (answer yes automatically)")
250+
editFormat = edit.Flags().StringP("rsh-edit-format", "e", "json", "Format to edit (default: json) [json, yaml]")
251+
Root.AddCommand(edit)
252+
228253
cert := &cobra.Command{
229254
Use: "cert uri",
230255
Short: "Get cert info",
@@ -485,7 +510,7 @@ func Run() {
485510
apiName = args[2]
486511
}
487512

488-
if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" {
513+
if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" {
489514
// Try to find the registered config for this API. If not found,
490515
// there is no need to do anything since the normal flow will catch
491516
// the command being missing and print help.

cli/edit.go

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"strings"
12+
13+
jmespath "github.com/danielgtaylor/go-jmespath-plus"
14+
"github.com/danielgtaylor/shorthand"
15+
"github.com/google/shlex"
16+
"github.com/hexops/gotextdiff"
17+
"github.com/hexops/gotextdiff/myers"
18+
"github.com/hexops/gotextdiff/span"
19+
"github.com/mattn/go-isatty"
20+
"github.com/spf13/viper"
21+
)
22+
23+
// emptyState implements a dummy fmt.State that passes through to a writer.
24+
type emptyState struct {
25+
writer io.Writer
26+
}
27+
28+
func (e *emptyState) Write(b []byte) (n int, err error) {
29+
return e.writer.Write(b)
30+
}
31+
32+
func (e *emptyState) Width() (wid int, ok bool) {
33+
return 0, true
34+
}
35+
36+
func (e *emptyState) Precision() (prec int, ok bool) {
37+
return 0, true
38+
}
39+
40+
func (e *emptyState) Flag(c int) bool {
41+
return false
42+
}
43+
44+
func panicOnErr(err error) {
45+
if err != nil {
46+
panic(err)
47+
}
48+
}
49+
50+
// getEditor tries to find the system default text editor command.
51+
func getEditor() string {
52+
editor := os.Getenv("VISUAL")
53+
if editor == "" {
54+
editor = os.Getenv("EDITOR")
55+
}
56+
57+
return editor
58+
}
59+
60+
func edit(addr string, args []string, interactive, noPrompt bool, exitFunc func(int), editMarshal func(interface{}) ([]byte, error), editUnmarshal func([]byte, interface{}) error, ext string) {
61+
if !interactive && len(args) == 0 {
62+
fmt.Fprintln(os.Stderr, "No arguments passed to modify the resource. Use `-i` to enable interactive mode.")
63+
exitFunc(1)
64+
return
65+
}
66+
67+
editor := getEditor()
68+
if interactive && editor == "" {
69+
fmt.Fprintln(os.Stderr, `Please set the VISUAL or EDITOR environment variable with your preferred editor. Examples:
70+
71+
export VISUAL="code --wait"
72+
export EDITOR="vim"`)
73+
exitFunc(1)
74+
return
75+
}
76+
77+
req, _ := http.NewRequest(http.MethodGet, fixAddress(addr), nil)
78+
resp, err := GetParsedResponse(req)
79+
panicOnErr(err)
80+
81+
if resp.Status >= 400 {
82+
panicOnErr(Formatter.Format(resp))
83+
exitFunc(1)
84+
return
85+
}
86+
87+
// Convert from CBOR or other formats which might allow map[any]any to the
88+
// constraints of JSON (i.e. map[string]interface{}).
89+
var data interface{} = resp.Map()
90+
data = makeJSONSafe(data, false)
91+
92+
filter := viper.GetString("rsh-filter")
93+
if filter == "" {
94+
filter = "body"
95+
}
96+
filtered, err := jmespath.Search(filter, data)
97+
panicOnErr(err)
98+
data = filtered
99+
100+
if _, ok := data.(map[string]interface{}); !ok {
101+
fmt.Fprintln(os.Stderr, "Resource didn't return an object.")
102+
exitFunc(1)
103+
return
104+
}
105+
106+
// Save original representation for comparison later. We use JSON here for
107+
// consistency and to avoid things like YAML encoding e.g. dates and strings
108+
// differently.
109+
orig, _ := json.MarshalIndent(data, "", " ")
110+
111+
// If available, grab any headers that can be used for conditional updates
112+
// so we don't overwrite changes made by other people while we edit.
113+
etag := resp.Headers["Etag"]
114+
lastModified := resp.Headers["Last-Modified"]
115+
116+
// TODO: remove read-only fields? This requires:
117+
// 1. Figure out which operation the URL corresponds to.
118+
// 2. Get and then analyse the response schema for that operation.
119+
// 3. Remove corresponding fields from `data`.
120+
121+
var modified interface{} = data
122+
123+
if len(args) > 0 {
124+
modified, err = shorthand.ParseAndBuild(req.URL.Path, strings.Join(args, " "), modified.(map[string]interface{}))
125+
panicOnErr(err)
126+
}
127+
128+
if interactive {
129+
// Create temp file
130+
tmp, err := os.CreateTemp("", "rsh-edit*"+ext)
131+
panicOnErr(err)
132+
defer os.Remove(tmp.Name())
133+
134+
// TODO: should we try and detect a `describedby` link relation and insert
135+
// that as a `$schema` key into the document before editing? The schema
136+
// itself may not allow the `$schema` key... hmm.
137+
138+
// Write the current body
139+
marshalled, err := editMarshal(modified)
140+
panicOnErr(err)
141+
tmp.Write(marshalled)
142+
tmp.Close()
143+
144+
// Open editor and wait for exit
145+
parts, err := shlex.Split(editor)
146+
panicOnErr(err)
147+
name := parts[0]
148+
args := append(parts[1:], tmp.Name())
149+
150+
cmd := exec.Command(name, args...)
151+
cmd.Stdin = os.Stdin
152+
cmd.Stdout = os.Stdout
153+
cmd.Stderr = os.Stderr
154+
panicOnErr(cmd.Run())
155+
156+
// Read file contents
157+
b, err := os.ReadFile(tmp.Name())
158+
panicOnErr(err)
159+
160+
panicOnErr(editUnmarshal(b, &modified))
161+
}
162+
163+
modified = makeJSONSafe(modified, false)
164+
mod, err := json.MarshalIndent(modified, "", " ")
165+
panicOnErr(err)
166+
edits := myers.ComputeEdits(span.URIFromPath("original"), string(orig), string(mod))
167+
168+
if len(edits) == 0 {
169+
fmt.Fprintln(os.Stderr, "No changes made.")
170+
exitFunc(0)
171+
return
172+
} else {
173+
sb := &strings.Builder{}
174+
s := &emptyState{writer: sb}
175+
unified := gotextdiff.ToUnified("original", "modified", string(orig), edits)
176+
unified.Format(s, ' ')
177+
diff := sb.String()
178+
if tty {
179+
d, _ := Highlight("diff", []byte(diff))
180+
diff = string(d)
181+
}
182+
fmt.Println(diff)
183+
184+
if !noPrompt && isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) {
185+
fmt.Printf("Continue? [Y/n] ")
186+
tmp := []byte{0}
187+
os.Stdin.Read(tmp)
188+
if tmp[0] == 'n' {
189+
exitFunc(0)
190+
return
191+
}
192+
}
193+
}
194+
195+
// TODO: support different submission formats, e.g. based on any given
196+
// `Content-Type` header?
197+
// TODO: content-encoding for large bodies?
198+
// TODO: determine if a PATCH could be used instead?
199+
b, _ := json.Marshal(modified)
200+
req, _ = http.NewRequest(http.MethodPut, fixAddress(addr), bytes.NewReader(b))
201+
req.Header.Set("Content-Type", "application/json")
202+
203+
if etag != "" {
204+
req.Header.Set("If-Match", etag)
205+
} else if lastModified != "" {
206+
req.Header.Set("If-Unmodified-Since", lastModified)
207+
}
208+
209+
MakeRequestAndFormat(req)
210+
}

cli/edit_test.go

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"os"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"gopkg.in/h2non/gock.v1"
11+
)
12+
13+
func TestEditSuccess(t *testing.T) {
14+
defer gock.Off()
15+
16+
gock.New("http://example.com").
17+
Get("/items/foo").
18+
Reply(http.StatusOK).
19+
SetHeader("Etag", "abc123").
20+
JSON(map[string]interface{}{
21+
"foo": 123,
22+
})
23+
24+
gock.New("http://example.com").
25+
Put("/items/foo").
26+
MatchHeader("If-Match", "abc123").
27+
BodyString(
28+
`{"foo": 123, "bar": 456}`,
29+
).
30+
Reply(http.StatusOK)
31+
32+
os.Setenv("VISUAL", "")
33+
os.Setenv("EDITOR", "true") // dummy to just return
34+
edit("http://example.com/items/foo", []string{"bar:456"}, true, true, func(int) {}, json.Marshal, json.Unmarshal, "json")
35+
}
36+
37+
func TestEditNonInteractiveArgsRequired(t *testing.T) {
38+
code := 999
39+
edit("http://example.com/items/foo", []string{}, false, true, func(c int) {
40+
code = c
41+
}, json.Marshal, json.Unmarshal, "json")
42+
43+
assert.Equal(t, 1, code)
44+
}
45+
46+
func TestEditInteractiveMissingEditor(t *testing.T) {
47+
os.Setenv("VISUAL", "")
48+
os.Setenv("EDITOR", "")
49+
code := 999
50+
edit("http://example.com/items/foo", []string{}, true, true, func(c int) {
51+
code = c
52+
}, json.Marshal, json.Unmarshal, "json")
53+
54+
assert.Equal(t, 1, code)
55+
}
56+
57+
func TestEditBadGet(t *testing.T) {
58+
defer gock.Off()
59+
60+
gock.New("http://example.com").
61+
Get("/items/foo").
62+
Reply(http.StatusInternalServerError)
63+
64+
code := 999
65+
edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) {
66+
code = c
67+
}, json.Marshal, json.Unmarshal, "json")
68+
69+
assert.Equal(t, 1, code)
70+
}
71+
72+
func TestEditNoChange(t *testing.T) {
73+
defer gock.Off()
74+
75+
gock.New("http://example.com").
76+
Get("/items/foo").
77+
Reply(http.StatusOK).
78+
SetHeader("Etag", "abc123").
79+
JSON(map[string]interface{}{
80+
"foo": 123,
81+
})
82+
83+
code := 999
84+
edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) {
85+
code = c
86+
}, json.Marshal, json.Unmarshal, "json")
87+
88+
assert.Equal(t, 0, code)
89+
}
90+
91+
func TestEditNotObject(t *testing.T) {
92+
defer gock.Off()
93+
94+
gock.New("http://example.com").
95+
Get("/items/foo").
96+
Reply(http.StatusOK).
97+
SetHeader("Etag", "abc123").
98+
JSON([]interface{}{
99+
123,
100+
})
101+
102+
code := 999
103+
edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) {
104+
code = c
105+
}, json.Marshal, json.Unmarshal, "json")
106+
107+
assert.Equal(t, 1, code)
108+
}

cli/formatter.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,13 @@ func init() {
5050
chroma.NameFunction: "#ff5f87",
5151
chroma.NameNamespace: "#b2b2b2",
5252

53-
// Used for Markdown
53+
// Used for Markdown & diffs
5454
chroma.GenericHeading: "#5fafd7",
5555
chroma.GenericSubheading: "#5fafd7",
5656
chroma.GenericEmph: "italic #ffd7d7",
5757
chroma.GenericStrong: "bold #af87af",
58-
chroma.GenericDeleted: "#3a3a3a",
58+
chroma.GenericDeleted: "#ff5f87",
59+
chroma.GenericInserted: "#afd787",
5960
chroma.NameAttribute: "underline",
6061
}))
6162
}

0 commit comments

Comments
 (0)