Repository: danielgtaylor/restish
Pull request #208 (2023-08-01 23:52:56)

feat: API edit command; apis.json schema
Add a basic `restish api edit` command to edit the API config file now that it's in different locations depending on the operating system.

Also adds a schema for the `apis.json` config file, making it easier to edit in VSCode and other editors.


Patch:
diff --git a/cli/apiconfig.go b/cli/apiconfig.go
index a1c3e26..5ff2eca 100644
--- a/cli/apiconfig.go
+++ b/cli/apiconfig.go
@@ -4,10 +4,13 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"os/exec"
+	"path"
 	"path/filepath"
 	"sort"
 	"strings"
 
+	"github.com/google/shlex"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"golang.org/x/exp/maps"
@@ -101,6 +104,12 @@ func initAPIConfig() {
 		panic(err)
 	}
 
+	if apis.GetString("$schema") == "" {
+		// Attempt to update the config to add the schema for docs/validation.
+		apis.Set("$schema", "https://rest.sh/schemas/apis.json")
+		apis.WriteConfig()
+	}
+
 	// Register api init sub-command to register the API.
 	apiCommand = &cobra.Command{
 		GroupID: "generic",
@@ -152,6 +161,14 @@ func initAPIConfig() {
 		Run:     askInitAPIDefault,
 	})
 
+	apiCommand.AddCommand(&cobra.Command{
+		Use:   "edit",
+		Short: "Edit APIs configuration",
+		Long:  "Edit the APIs configuration in your default editor.",
+		Args:  cobra.NoArgs,
+		Run:   func(cmd *cobra.Command, args []string) { editAPIs(os.Exit) },
+	})
+
 	apiCommand.AddCommand(&cobra.Command{
 		Use:   "show short-name",
 		Short: "Show API config",
@@ -188,7 +205,14 @@ func initAPIConfig() {
 
 	// Register API sub-commands
 	configs = apiConfigs{}
-	if err := apis.Unmarshal(&configs); err != nil {
+	tmp := viper.New()
+	for k, v := range apis.AllSettings() {
+		if k == "$schema" {
+			continue
+		}
+		tmp.Set(k, v)
+	}
+	if err := tmp.Unmarshal(&configs); err != nil {
 		panic(err)
 	}
 
@@ -247,3 +271,28 @@ func findAPI(uri string) (string, *APIConfig) {
 
 	return "", nil
 }
+
+func editAPIs(exitFunc func(int)) {
+	editor := getEditor()
+	if 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
+	}
+
+	parts, err := shlex.Split(editor)
+	panicOnErr(err)
+	name := parts[0]
+	args := append(parts[1:], path.Join(
+		getConfigDir(viper.GetString("app-name")), "apis.json",
+	))
+
+	c := exec.Command(name, args...)
+	c.Stdin = os.Stdin
+	c.Stdout = os.Stdout
+	c.Stderr = os.Stderr
+	panicOnErr(c.Run())
+}
diff --git a/cli/content.go b/cli/content.go
index 308aeb3..42f9d5e 100644
--- a/cli/content.go
+++ b/cli/content.go
@@ -251,13 +251,8 @@ func setTable(data []interface{}) ([]byte, error) {
 // available as a built-in Restish output option.
 type Gron struct{}
 
-// Detect if the content type is gron-able.
+// Detect if the content type is gron.
 func (t Gron) Detect(contentType string) bool {
-	first := strings.Split(contentType, ";")[0]
-	if first == "application/json" || strings.HasSuffix(first, "+json") {
-		return true
-	}
-
 	return false
 }
 
@@ -270,7 +265,7 @@ func (t Gron) Marshal(value interface{}) ([]byte, error) {
 
 // Unmarshal the value from a gron string.
 func (t Gron) Unmarshal(data []byte, value interface{}) error {
-	return json.Unmarshal(data, value)
+	return fmt.Errorf("unimplemented")
 }
 
 // JSON describes content types like `application/json` or
diff --git a/cli/gron.go b/cli/gron.go
index edc2b8a..c4673b8 100644
--- a/cli/gron.go
+++ b/cli/gron.go
@@ -1,7 +1,6 @@
 package cli
 
 import (
-	"bytes"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
@@ -176,18 +175,11 @@ func marshalGron(pb *PathBuffer, data any, isAnon bool, out []byte) ([]byte, err
 		}
 	default:
 		// This is a primitive type, just take the JSON representation.
-		// The default encoder escapes '<', '>', and '&' which we don't want
-		// since we are not a browser. Disable this with an encoder instance.
-		// See https://stackoverflow.com/a/28596225/164268
-		buf := &bytes.Buffer{}
-		enc := json.NewEncoder(buf)
-		enc.SetEscapeHTML(false)
-		if err := enc.Encode(makeJSONSafe(data)); err != nil {
+		v, err := json.Marshal(data)
+		if err != nil {
 			return nil, err
 		}
-		// Note: encoder adds it's own ending newline we need to strip out.
-		b := bytes.TrimSuffix(buf.Bytes(), []byte("\n"))
-		out = apnd(out, pb.Bytes(), " = ", b, ";\n")
+		out = apnd(out, pb.Bytes(), " = ", v, ";\n")
 	}
 
 	return out, nil
diff --git a/docs/guide.md b/docs/guide.md
index e7ea5ac..6763c90 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -4,39 +4,22 @@ This guide will help you to get started installing and using Restish.
 
 ## Installation
 
-You can install Restish via Homebrew, Nixpkgs, GitHub releases, or `go get`.
+You can install in one of three ways: Homebrew tap, GitHub release, or via `go get`.
 
-### Install via Homebrew
-
-If you have [Homebrew](https://brew.sh/), you can install Restish via the [official tap](https://github.com/danielgtaylor/homebrew-restish):
+If you have [Homebrew](https://brew.sh/) then install via the official tap:
 
 ```bash
+# Install directly through the Homebrew tap
 $ brew install danielgtaylor/restish/restish
 ```
 
-### Install via Nixpkgs
-
-If you use Nixpkgs, you can install the [Restish derivation](https://search.nixos.org/packages?channel=unstable&query=restish), e.g. using `nix-env`:
-
-```bash
-$ nix-env --install --attr nixpkgs.restish
-```
-
-### Install by downloading a GitHub release
-
-You can grab a [release](https://github.com/danielgtaylor/restish/releases) for your platform,
-and manually copy the executable to a location in your `$PATH` (e.g. `/usr/local/bin/restish`).
-
-### Install via `go get`
-
-If you have Go set up, you can install the [restish package](https://pkg.go.dev/github.com/danielgtaylor/restish):
+If you don't have Homebrew, you can grab a [release](https://github.com/danielgtaylor/restish/releases) for your platform and manually copy the executable to the right location (e.g. `/usr/local/bin/restish`), otherwise if you have Go installed:
 
 ```bash
+# Download / build / install
 $ go install github.com/danielgtaylor/restish@latest
 ```
 
-### Validate the installation
-
 You can confirm the installation worked by trying to run Restish:
 
 ```bash
diff --git a/docs/schemas/apis.json b/docs/schemas/apis.json
new file mode 100644
index 0000000..7c839af
--- /dev/null
+++ b/docs/schemas/apis.json
@@ -0,0 +1,109 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "type": "object",
+  "properties": {
+    "$schema": {
+      "type": "string",
+      "format": "uri",
+      "description": "The URL of the JSON Schema that describes the structure and semantics of the remainder of the document"
+    }
+  },
+  "additionalProperties": {
+    "type": "object",
+    "required": ["base"],
+    "properties": {
+      "base": {
+        "type": "string",
+        "format": "uri-reference",
+        "description": "The base URL of the API. This is used to try and fetch the OpenAPI spec as well as resolve relative references. If the base contains a path, OpenAPI operations are assumed relative to that base path."
+      },
+      "spec_files": {
+        "type": "array",
+        "description": "The local filename or remote URL of the OpenAPI spec file(s) to load for this API if autodetection cannot be used. If multiple files are specified, their operations will be merged together.",
+        "items": {
+          "type": "string"
+        }
+      },
+      "profiles": {
+        "type": "object",
+        "description": "A map of profile names (e.g. 'default') to profile information that can include headers, query params, auth, and custom TLS settings. A default profile is required.",
+        "required": ["default"],
+        "additionalProperties": {
+          "type": "object",
+          "properties": {
+            "base": {
+              "type": "string",
+              "format": "uri",
+              "description": "Override the base URL of the API"
+            },
+            "headers": {
+              "type": "object",
+              "description": "Header names and values to send on each request.",
+              "additionalProperties": {
+                "type": "string"
+              }
+            },
+            "query": {
+              "type": "object",
+              "description": "Query parameters to send on each request.",
+              "additionalProperties": {
+                "type": "string"
+              }
+            },
+            "auth": {
+              "type": "object",
+              "description": "Authentication & authorization setting for this API profile.",
+              "required": ["name"],
+              "properties": {
+                "name": {
+                  "description": "Authentication & authorization scheme name.",
+                  "anyOf": [
+                    {
+                      "enum": [
+                        "oauth-client-credentials",
+                        "oauth-authorization-code",
+                        "external-tool"
+                      ]
+                    },
+                    {
+                      "type": "string"
+                    }
+                  ]
+                },
+                "params": {
+                  "type": "object",
+                  "description": "Auth parameter names and values to send as additional values in the auth request. These are specific to each auth scheme name and implementation, and include things like the OAuth2 authorize / token URLs, client ID / secret, audience, etc. See https://rest.sh/#/configuration?id=api-auth.",
+                  "additionalProperties": {
+                    "type": "string"
+                  }
+                }
+              }
+            },
+            "tls": {
+              "type": "object",
+              "description": "Custom TLS (HTTPS) certificate verification settings.",
+              "properties": {
+                "insecure": {
+                  "type": "boolean",
+                  "description": "If true, do not verify TLS certificates when making requests to this API."
+                },
+                "cert": {
+                  "type": "string",
+                  "description": "The local filename of a TLS certificate."
+                },
+                "key": {
+                  "type": "string",
+                  "description": "The local filename of a TLS private key."
+                },
+                "ca_cert": {
+                  "type": "string",
+                  "description": "The local filename of a TLS certificate authority."
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/docs/shorthand.md b/docs/shorthand.md
index 925e659..a44bf34 100644
--- a/docs/shorthand.md
+++ b/docs/shorthand.md
@@ -282,7 +282,7 @@ $ j 'twitter: "@user"'
 
 ### Patch (partial update)
 
-Partial updates are supported on existing data, which can be used to implement HTTP `PATCH`, templating, and other similar features. The suggested content type for HTTP `PATCH` is `application/shorthand-patch`. This feature combines the best of both:
+Partial updates are supported on existing data, which can be used to implement HTTP `PATCH`, templating, and other similar features. The suggested content type for HTTP `PATCH` is `application/merge-patch+shorthand`. This feature combines the best of both:
 
 - [JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7386)
 - [JSON Patch](https://www.rfc-editor.org/rfc/rfc6902)


Test patch:
diff --git a/cli/apiconfig_test.go b/cli/apiconfig_test.go
index c76e0cb..be1b0c6 100644
--- a/cli/apiconfig_test.go
+++ b/cli/apiconfig_test.go
@@ -1,6 +1,7 @@
 package cli
 
 import (
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -22,3 +23,21 @@ func TestAPIShow(t *testing.T) {
 	captured := runNoReset("api show test")
 	assert.Equal(t, captured, "\x1b[38;5;247m{\x1b[0m\n  \x1b[38;5;74m\"base\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"https://api.example.com\"\x1b[0m\n\x1b[38;5;247m}\x1b[0m\n")
 }
+
+func TestEditAPIsMissingEditor(t *testing.T) {
+	os.Setenv("EDITOR", "")
+	os.Setenv("VISUAL", "")
+	exited := false
+	editAPIs(func(code int) {
+		exited = true
+	})
+	assert.True(t, exited)
+}
+
+func TestEditBadCommand(t *testing.T) {
+	os.Setenv("EDITOR", "bad-command")
+	os.Setenv("VISUAL", "")
+	assert.Panics(t, func() {
+		editAPIs(func(code int) {})
+	})
+}
diff --git a/cli/gron_test.go b/cli/gron_test.go
index 508683c..f88881c 100644
--- a/cli/gron_test.go
+++ b/cli/gron_test.go
@@ -26,7 +26,7 @@ func TestGronMarshal(t *testing.T) {
 		B: 42,
 		C: true,
 		D: D{[]map[string]any{
-			{"e": "world & i <3 restish"},
+			{"e": "world"},
 			{"f": []any{1, 2}, "g": time.Time{}, "h": []byte("foo")},
 			{"for": map[int]int{1: 2}},
 		}},
@@ -42,7 +42,7 @@ body.b = 42;
 body.c = true;
 body.d = [];
 body.d[0] = {};
-body.d[0].e = "world & i <3 restish";
+body.d[0].e = "world";
 body.d[1] = {};
 body.d[1].f = [];
 body.d[1].f[0] = 1;