Repository: danielgtaylor/restish
Pull request #80 (2022-03-20 22:30:32)

feat: add edit convenience command
This adds support for a new convenience command `edit` which is similar to doing a `GET`, modifying the response, then submitting the modified version via `PUT`. It supports conditional request headers with ETags and last modified times.


Patch:
diff --git a/cli/cli.go b/cli/cli.go
index 55a8dc6..9f54b9d 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -21,6 +21,7 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
+	"gopkg.in/yaml.v2"
 )
 
 // Root command (entrypoint) of the CLI.
@@ -56,10 +57,10 @@ Aliases:
 Examples:
 {{.Example}}{{end}}{{if (not .Parent)}}{{if (gt (len .Commands) 9)}}
 
-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")))}}
+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")))}}
   {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
 
-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"))}}
+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"))}}
   {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{else}}{{if .HasAvailableSubCommands}}
 
 Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
@@ -225,6 +226,30 @@ func Init(name string, version string) {
 	}
 	Root.AddCommand(delete)
 
+	var interactive *bool
+	var noPrompt *bool
+	var editFormat *string
+	edit := &cobra.Command{
+		Use:   "edit uri [-i] [body...]",
+		Short: "Edit a resource by URI",
+		Long:  "Convenience function which combines a GET, edit, and PUT operation into one command",
+		Args:  cobra.MinimumNArgs(1),
+		Run: func(cmd *cobra.Command, args []string) {
+			switch *editFormat {
+			case "json":
+				edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, func(v interface{}) ([]byte, error) {
+					return json.MarshalIndent(v, "", "  ")
+				}, json.Unmarshal, ".json")
+			case "yaml":
+				edit(args[0], args[1:], *interactive, *noPrompt, os.Exit, yaml.Marshal, yaml.Unmarshal, ".yaml")
+			}
+		},
+	}
+	interactive = edit.Flags().BoolP("rsh-interactive", "i", false, "Open an interactive editor")
+	noPrompt = edit.Flags().BoolP("rsh-yes", "y", false, "Disable prompt (answer yes automatically)")
+	editFormat = edit.Flags().StringP("rsh-edit-format", "e", "json", "Format to edit (default: json) [json, yaml]")
+	Root.AddCommand(edit)
+
 	cert := &cobra.Command{
 		Use:   "cert uri",
 		Short: "Get cert info",
@@ -485,7 +510,7 @@ func Run() {
 			apiName = args[2]
 		}
 
-		if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" {
+		if apiName != "help" && apiName != "head" && apiName != "options" && apiName != "get" && apiName != "post" && apiName != "put" && apiName != "patch" && apiName != "delete" && apiName != "api" && apiName != "links" && apiName != "edit" {
 			// Try to find the registered config for this API. If not found,
 			// there is no need to do anything since the normal flow will catch
 			// the command being missing and print help.
diff --git a/cli/edit.go b/cli/edit.go
new file mode 100644
index 0000000..555a8e8
--- /dev/null
+++ b/cli/edit.go
@@ -0,0 +1,210 @@
+package cli
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+
+	jmespath "github.com/danielgtaylor/go-jmespath-plus"
+	"github.com/danielgtaylor/shorthand"
+	"github.com/google/shlex"
+	"github.com/hexops/gotextdiff"
+	"github.com/hexops/gotextdiff/myers"
+	"github.com/hexops/gotextdiff/span"
+	"github.com/mattn/go-isatty"
+	"github.com/spf13/viper"
+)
+
+// emptyState implements a dummy fmt.State that passes through to a writer.
+type emptyState struct {
+	writer io.Writer
+}
+
+func (e *emptyState) Write(b []byte) (n int, err error) {
+	return e.writer.Write(b)
+}
+
+func (e *emptyState) Width() (wid int, ok bool) {
+	return 0, true
+}
+
+func (e *emptyState) Precision() (prec int, ok bool) {
+	return 0, true
+}
+
+func (e *emptyState) Flag(c int) bool {
+	return false
+}
+
+func panicOnErr(err error) {
+	if err != nil {
+		panic(err)
+	}
+}
+
+// getEditor tries to find the system default text editor command.
+func getEditor() string {
+	editor := os.Getenv("VISUAL")
+	if editor == "" {
+		editor = os.Getenv("EDITOR")
+	}
+
+	return editor
+}
+
+func edit(addr string, args []string, interactive, noPrompt bool, exitFunc func(int), editMarshal func(interface{}) ([]byte, error), editUnmarshal func([]byte, interface{}) error, ext string) {
+	if !interactive && len(args) == 0 {
+		fmt.Fprintln(os.Stderr, "No arguments passed to modify the resource. Use `-i` to enable interactive mode.")
+		exitFunc(1)
+		return
+	}
+
+	editor := getEditor()
+	if interactive && editor == "" {
+		fmt.Fprintln(os.Stderr, `Please set the VISUAL or EDITOR environment variable with your preferred editor. Examples:
+
+export VISUAL="code --wait"
+export EDITOR="vim"`)
+		exitFunc(1)
+		return
+	}
+
+	req, _ := http.NewRequest(http.MethodGet, fixAddress(addr), nil)
+	resp, err := GetParsedResponse(req)
+	panicOnErr(err)
+
+	if resp.Status >= 400 {
+		panicOnErr(Formatter.Format(resp))
+		exitFunc(1)
+		return
+	}
+
+	// Convert from CBOR or other formats which might allow map[any]any to the
+	// constraints of JSON (i.e. map[string]interface{}).
+	var data interface{} = resp.Map()
+	data = makeJSONSafe(data, false)
+
+	filter := viper.GetString("rsh-filter")
+	if filter == "" {
+		filter = "body"
+	}
+	filtered, err := jmespath.Search(filter, data)
+	panicOnErr(err)
+	data = filtered
+
+	if _, ok := data.(map[string]interface{}); !ok {
+		fmt.Fprintln(os.Stderr, "Resource didn't return an object.")
+		exitFunc(1)
+		return
+	}
+
+	// Save original representation for comparison later. We use JSON here for
+	// consistency and to avoid things like YAML encoding e.g. dates and strings
+	// differently.
+	orig, _ := json.MarshalIndent(data, "", "  ")
+
+	// If available, grab any headers that can be used for conditional updates
+	// so we don't overwrite changes made by other people while we edit.
+	etag := resp.Headers["Etag"]
+	lastModified := resp.Headers["Last-Modified"]
+
+	// TODO: remove read-only fields? This requires:
+	// 1. Figure out which operation the URL corresponds to.
+	// 2. Get and then analyse the response schema for that operation.
+	// 3. Remove corresponding fields from `data`.
+
+	var modified interface{} = data
+
+	if len(args) > 0 {
+		modified, err = shorthand.ParseAndBuild(req.URL.Path, strings.Join(args, " "), modified.(map[string]interface{}))
+		panicOnErr(err)
+	}
+
+	if interactive {
+		// Create temp file
+		tmp, err := os.CreateTemp("", "rsh-edit*"+ext)
+		panicOnErr(err)
+		defer os.Remove(tmp.Name())
+
+		// TODO: should we try and detect a `describedby` link relation and insert
+		// that as a `$schema` key into the document before editing? The schema
+		// itself may not allow the `$schema` key... hmm.
+
+		// Write the current body
+		marshalled, err := editMarshal(modified)
+		panicOnErr(err)
+		tmp.Write(marshalled)
+		tmp.Close()
+
+		// Open editor and wait for exit
+		parts, err := shlex.Split(editor)
+		panicOnErr(err)
+		name := parts[0]
+		args := append(parts[1:], tmp.Name())
+
+		cmd := exec.Command(name, args...)
+		cmd.Stdin = os.Stdin
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
+		panicOnErr(cmd.Run())
+
+		// Read file contents
+		b, err := os.ReadFile(tmp.Name())
+		panicOnErr(err)
+
+		panicOnErr(editUnmarshal(b, &modified))
+	}
+
+	modified = makeJSONSafe(modified, false)
+	mod, err := json.MarshalIndent(modified, "", "  ")
+	panicOnErr(err)
+	edits := myers.ComputeEdits(span.URIFromPath("original"), string(orig), string(mod))
+
+	if len(edits) == 0 {
+		fmt.Fprintln(os.Stderr, "No changes made.")
+		exitFunc(0)
+		return
+	} else {
+		sb := &strings.Builder{}
+		s := &emptyState{writer: sb}
+		unified := gotextdiff.ToUnified("original", "modified", string(orig), edits)
+		unified.Format(s, ' ')
+		diff := sb.String()
+		if tty {
+			d, _ := Highlight("diff", []byte(diff))
+			diff = string(d)
+		}
+		fmt.Println(diff)
+
+		if !noPrompt && isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) {
+			fmt.Printf("Continue? [Y/n] ")
+			tmp := []byte{0}
+			os.Stdin.Read(tmp)
+			if tmp[0] == 'n' {
+				exitFunc(0)
+				return
+			}
+		}
+	}
+
+	// TODO: support different submission formats, e.g. based on any given
+	// `Content-Type` header?
+	// TODO: content-encoding for large bodies?
+	// TODO: determine if a PATCH could be used instead?
+	b, _ := json.Marshal(modified)
+	req, _ = http.NewRequest(http.MethodPut, fixAddress(addr), bytes.NewReader(b))
+	req.Header.Set("Content-Type", "application/json")
+
+	if etag != "" {
+		req.Header.Set("If-Match", etag)
+	} else if lastModified != "" {
+		req.Header.Set("If-Unmodified-Since", lastModified)
+	}
+
+	MakeRequestAndFormat(req)
+}
diff --git a/cli/formatter.go b/cli/formatter.go
index 2307ad3..a8170fc 100644
--- a/cli/formatter.go
+++ b/cli/formatter.go
@@ -50,12 +50,13 @@ func init() {
 		chroma.NameFunction:  "#ff5f87",
 		chroma.NameNamespace: "#b2b2b2",
 
-		// Used for Markdown
+		// Used for Markdown & diffs
 		chroma.GenericHeading:    "#5fafd7",
 		chroma.GenericSubheading: "#5fafd7",
 		chroma.GenericEmph:       "italic #ffd7d7",
 		chroma.GenericStrong:     "bold #af87af",
-		chroma.GenericDeleted:    "#3a3a3a",
+		chroma.GenericDeleted:    "#ff5f87",
+		chroma.GenericInserted:   "#afd787",
 		chroma.NameAttribute:     "underline",
 	}))
 }
diff --git a/cli/input.go b/cli/input.go
index 6ad2280..0280966 100644
--- a/cli/input.go
+++ b/cli/input.go
@@ -3,8 +3,6 @@ package cli
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
-	"os"
 	"strings"
 
 	"github.com/danielgtaylor/shorthand"
@@ -16,63 +14,23 @@ import (
 func GetBody(mediaType string, args []string) (string, error) {
 	var body string
 
-	info, err := os.Stdin.Stat()
+	input, err := shorthand.GetInput(args)
 	if err != nil {
 		return "", err
 	}
-	if (info.Mode() & os.ModeCharDevice) == 0 {
-		// Data is available on stdin
-		input, err := ioutil.ReadAll(os.Stdin)
-		if err != nil {
-			return "", err
-		}
-
-		body = string(input)
-		LogDebug("Body from stdin is: %s", body)
-	}
-
-	if len(args) > 0 {
-		bodyInput := strings.Join(args, " ")
-		result, err := shorthand.ParseAndBuild("stdin", bodyInput)
-		if err != nil {
-			return "", err
-		}
 
+	if input != nil {
 		if strings.Contains(mediaType, "json") {
-			if body != "" {
-				// Have a body from stdin. Should be JSON, so let's merge.
-				var curBody map[string]interface{}
-				if err := json.Unmarshal([]byte(body), &curBody); err != nil {
-					return "", err
-				}
-
-				shorthand.DeepAssign(curBody, result)
-				result = curBody
-			}
-
-			marshalled, err := json.Marshal(result)
+			marshalled, err := json.Marshal(input)
 			if err != nil {
 				return "", err
 			}
-
 			body = string(marshalled)
 		} else if strings.Contains(mediaType, "yaml") {
-			if body != "" {
-				// Have a body from stdin. Should be YAML, so let's merge.
-				var curBody map[string]interface{}
-				if err := yaml.Unmarshal([]byte(body), &curBody); err != nil {
-					return "", err
-				}
-
-				shorthand.DeepAssign(curBody, result)
-				result = curBody
-			}
-
-			marshalled, err := yaml.Marshal(result)
+			marshalled, err := yaml.Marshal(input)
 			if err != nil {
 				return "", err
 			}
-
 			body = string(marshalled)
 		} else {
 			return "", fmt.Errorf("Not sure how to marshal %s", mediaType)
diff --git a/docs/guide.md b/docs/guide.md
index 2ed3f4b..2986f3d 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -104,6 +104,22 @@ $ restish example.com/users
 
 If you have persistent headers or query params you'd like to set, then consider registering the API endpoint with Restish rather than exporting environment variables. Read on to find out how.
 
+### Editing Resources
+
+If an API supports both a `GET` and a `PUT` for a resource, there is an `edit` convenience operation which allows you to edit the resource similar to how you might use a `PATCH` if the API were available.
+
+```bash
+# Modify a field on the command line via CLI shorthand
+$ restish edit example.com/users/12345 nickname: Daniel
+
+# Modify a resource interactively in your editor
+$ restish edit -i example.com/users/12345
+```
+
+To use interactive mode you must have the `VISUAL` or `EDITOR` environment variable set to an editor, for example `export VISUAL="code --wait"` for VSCode.
+
+Editing resources will make use of [conditional requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) if any relevant headers are found on the `GET` response.
+
 ## API Operation Commands
 
 APIs can be registered in order to provide API description auto-discovery with convenience commands and authentication. APIs are registered with a short nickname. For example the GitHub v3 API might be called `github` or the Digital Ocean API might be called `do`.
diff --git a/go.mod b/go.mod
index d873965..32a2e8c 100644
--- a/go.mod
+++ b/go.mod
@@ -12,14 +12,16 @@ require (
 	github.com/creack/pty v1.1.11 // indirect
 	github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3
 	github.com/danielgtaylor/go-jmespath-plus v0.0.0-20200228063638-e0b6f132acba
-	github.com/danielgtaylor/shorthand v1.0.0
+	github.com/danielgtaylor/shorthand v1.1.0
 	github.com/eliukblau/pixterm v1.3.1
 	github.com/fxamacker/cbor/v2 v2.3.0
 	github.com/gbl08ma/httpcache v1.0.2
 	github.com/getkin/kin-openapi v0.86.0
 	github.com/go-openapi/swag v0.19.15 // indirect
 	github.com/google/btree v1.0.1 // indirect
+	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/gosimple/slug v1.12.0
+	github.com/hexops/gotextdiff v1.0.3
 	github.com/hinshun/vt10x v0.0.0-20180809195222-d55458df857c // indirect
 	github.com/iancoleman/strcase v0.2.0
 	github.com/kr/pty v1.1.8 // indirect
@@ -31,6 +33,8 @@ require (
 	github.com/mattn/go-runewidth v0.0.13 // indirect
 	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
 	github.com/mitchellh/mapstructure v1.4.3
+	github.com/mna/pigeon v1.1.0 // indirect
+	github.com/sergi/go-diff v1.2.0
 	github.com/shamaton/msgpack v1.2.1
 	github.com/spf13/cobra v1.3.0
 	github.com/spf13/pflag v1.0.5
@@ -42,9 +46,10 @@ require (
 	golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
 	golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9 // indirect
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
-	golang.org/x/sys v0.0.0-20211214234402-4825e8c3871d // indirect
+	golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 	golang.org/x/text v0.3.7 // indirect
+	golang.org/x/tools v0.1.9 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/h2non/gock.v1 v1.0.16
 	gopkg.in/yaml.v2 v2.4.0
diff --git a/go.sum b/go.sum
index 3cede41..423ca8f 100644
--- a/go.sum
+++ b/go.sum
@@ -118,6 +118,10 @@ github.com/danielgtaylor/go-jmespath-plus v0.0.0-20200228063638-e0b6f132acba h1:
 github.com/danielgtaylor/go-jmespath-plus v0.0.0-20200228063638-e0b6f132acba/go.mod h1:A57wu2YKZM9dwidFjak6swlRJPKbg+TfHsFm0py9/i8=
 github.com/danielgtaylor/shorthand v1.0.0 h1:FToQW8Nw0e+nrU3ey2iDygrnvvp9xNb5fPELmIp7HnQ=
 github.com/danielgtaylor/shorthand v1.0.0/go.mod h1:DtrOS6XY0gj93/DstUNgFYtfUlsxx6eVijdXTkXTIws=
+github.com/danielgtaylor/shorthand v1.0.1 h1:yWeff269Ur1YGdyXbXU5vV6J8bv9T+Wik2EzcYkKSK4=
+github.com/danielgtaylor/shorthand v1.0.1/go.mod h1:DtrOS6XY0gj93/DstUNgFYtfUlsxx6eVijdXTkXTIws=
+github.com/danielgtaylor/shorthand v1.1.0 h1:ZCp6zUFPI6yJbbxy0y7Lks6Zbm6cic9h2Pzjhoahgz0=
+github.com/danielgtaylor/shorthand v1.1.0/go.mod h1:cGt46ErC5zbLmtuQIXcavU06e8gLQbp97oDPYVYny3I=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -243,6 +247,8 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
@@ -300,6 +306,8 @@ github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
 github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
 github.com/hinshun/vt10x v0.0.0-20180809195222-d55458df857c h1:kp3AxgXgDOmIJFR7bIwqFhwJ2qWar8tEQSE5XXhCfVk=
 github.com/hinshun/vt10x v0.0.0-20180809195222-d55458df857c/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
@@ -391,6 +399,7 @@ github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7p
 github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
 github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mna/pigeon v1.1.0/go.mod h1:rkFeDZ0gc+YbnrXPw0q2RlI0QRuKBBPu67fgYIyGRNg=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -440,6 +449,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
 github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE=
 github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shamaton/msgpack v1.2.1 h1:40cwW7YAEdOIxcxIsUkAxSMUyYWZUyNiazI5AyiBntI=
 github.com/shamaton/msgpack v1.2.1/go.mod h1:ibiaNQRTCUISAYkkyOpaSCEBiCAxXe6u6Mu1sQ6945U=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
@@ -492,6 +503,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
 go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
 go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
@@ -565,6 +577,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -609,6 +622,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
 golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211020060615-d418f374d309 h1:A0lJIi+hcTR6aajJH4YqKWwohY4aW9RO7oRMcdv+HKI=
 golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -711,12 +725,15 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 h1:2B5p2L5IfGiD7+b9BOoRMC6DgObAVZV+Fsp050NqXik=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211214234402-4825e8c3871d h1:1oIt9o40TWWI9FUaveVpUvBe13FNqBNVXy3ue2fcfkw=
 golang.org/x/sys v0.0.0-20211214234402-4825e8c3871d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@@ -748,6 +765,7 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw
 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190830223141-573d9926052a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -791,6 +809,7 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=


Test patch:
diff --git a/cli/edit_test.go b/cli/edit_test.go
new file mode 100644
index 0000000..59b5d41
--- /dev/null
+++ b/cli/edit_test.go
@@ -0,0 +1,108 @@
+package cli
+
+import (
+	"encoding/json"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func TestEditSuccess(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("http://example.com").
+		Get("/items/foo").
+		Reply(http.StatusOK).
+		SetHeader("Etag", "abc123").
+		JSON(map[string]interface{}{
+			"foo": 123,
+		})
+
+	gock.New("http://example.com").
+		Put("/items/foo").
+		MatchHeader("If-Match", "abc123").
+		BodyString(
+			`{"foo": 123, "bar": 456}`,
+		).
+		Reply(http.StatusOK)
+
+	os.Setenv("VISUAL", "")
+	os.Setenv("EDITOR", "true") // dummy to just return
+	edit("http://example.com/items/foo", []string{"bar:456"}, true, true, func(int) {}, json.Marshal, json.Unmarshal, "json")
+}
+
+func TestEditNonInteractiveArgsRequired(t *testing.T) {
+	code := 999
+	edit("http://example.com/items/foo", []string{}, false, true, func(c int) {
+		code = c
+	}, json.Marshal, json.Unmarshal, "json")
+
+	assert.Equal(t, 1, code)
+}
+
+func TestEditInteractiveMissingEditor(t *testing.T) {
+	os.Setenv("VISUAL", "")
+	os.Setenv("EDITOR", "")
+	code := 999
+	edit("http://example.com/items/foo", []string{}, true, true, func(c int) {
+		code = c
+	}, json.Marshal, json.Unmarshal, "json")
+
+	assert.Equal(t, 1, code)
+}
+
+func TestEditBadGet(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("http://example.com").
+		Get("/items/foo").
+		Reply(http.StatusInternalServerError)
+
+	code := 999
+	edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) {
+		code = c
+	}, json.Marshal, json.Unmarshal, "json")
+
+	assert.Equal(t, 1, code)
+}
+
+func TestEditNoChange(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("http://example.com").
+		Get("/items/foo").
+		Reply(http.StatusOK).
+		SetHeader("Etag", "abc123").
+		JSON(map[string]interface{}{
+			"foo": 123,
+		})
+
+	code := 999
+	edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) {
+		code = c
+	}, json.Marshal, json.Unmarshal, "json")
+
+	assert.Equal(t, 0, code)
+}
+
+func TestEditNotObject(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("http://example.com").
+		Get("/items/foo").
+		Reply(http.StatusOK).
+		SetHeader("Etag", "abc123").
+		JSON([]interface{}{
+			123,
+		})
+
+	code := 999
+	edit("http://example.com/items/foo", []string{"foo:123"}, false, true, func(c int) {
+		code = c
+	}, json.Marshal, json.Unmarshal, "json")
+
+	assert.Equal(t, 1, code)
+}