Repository: danielgtaylor/restish
Pull request #83 (2022-04-04 16:43:56)

feat: cache parsed/loaded APIs for faster access
This PR includes a number of improvements related to caching of APIs that **significantly** improve the startup speed of Restish. These changes include:

- Adding a new binary cache of parsed API & operation info with a 24 hour default expiry
- Move the Markdown syntax highlighting from the API loader into the help display template. For a large API like Github's, rendering several hundred Markdown descriptions was extremely slow and 99.9% of the time they were never used. Now it gets rendered if/when needed via `--help`.

In practice using the Github v3 OpenAPI we see a massive speed up (like 5% of the original time taken!):

```sh
# Previous version
$ time restish-old github --help
restish-old github --help  1.85s user 0.10s system 135% cpu 1.439 total

# New version
$ time restish github --help
restish github --help  0.05s user 0.02s system 84% cpu 0.082 total
```

This has serious implications for the usability of dynamic shell completions where you expect the tool to respond almost instantly!


Patch:
diff --git a/cli/api.go b/cli/api.go
index e31d00e..646b6b3 100644
--- a/cli/api.go
+++ b/cli/api.go
@@ -7,9 +7,11 @@ import (
 	"net/http"
 	"net/url"
 	"os"
+	"path"
 	"strings"
 	"time"
 
+	"github.com/fxamacker/cbor/v2"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 )
@@ -53,12 +55,7 @@ func AddLoader(loader Loader) {
 	loaders = append(loaders, loader)
 }
 
-func load(root *cobra.Command, entrypoint, spec url.URL, resp *http.Response, name string, loader Loader) (API, error) {
-	api, err := loader.Load(entrypoint, spec, resp)
-	if err != nil {
-		return API{}, err
-	}
-
+func setupRootFromAPI(root *cobra.Command, api *API) {
 	if root.Short == "" {
 		root.Short = api.Short
 	}
@@ -70,13 +67,43 @@ func load(root *cobra.Command, entrypoint, spec url.URL, resp *http.Response, na
 	for _, op := range api.Operations {
 		root.AddCommand(op.command())
 	}
+}
+
+func load(root *cobra.Command, entrypoint, spec url.URL, resp *http.Response, name string, loader Loader) (API, error) {
+	api, err := loader.Load(entrypoint, spec, resp)
+	if err != nil {
+		return API{}, err
+	}
 
+	setupRootFromAPI(root, &api)
 	return api, nil
 }
 
+func cacheAPI(name string, api *API) {
+	if name == "" {
+		return
+	}
+
+	Cache.Set(name+".expires", time.Now().Add(24*time.Hour))
+	Cache.WriteConfig()
+
+	b, err := cbor.Marshal(api)
+	if err != nil {
+		LogError("Could not marshal API cache %s", err)
+	}
+	filename := path.Join(viper.GetString("config-directory"), name+".cbor")
+	if err := ioutil.WriteFile(filename, b, 0o600); err != nil {
+		LogError("Could not write API cache %s", err)
+	}
+}
+
 // Load will hydrate the command tree for an API, possibly refreshing the
 // API spec if the cache is out of date.
 func Load(entrypoint string, root *cobra.Command) (API, error) {
+	start := time.Now()
+	defer func() {
+		LogDebug("API loading took %s", time.Now().Sub(start))
+	}()
 	uris := []string{}
 
 	if !strings.HasSuffix(entrypoint, "/") {
@@ -92,6 +119,19 @@ func Load(entrypoint string, root *cobra.Command) (API, error) {
 	desc := API{}
 	found := false
 
+	// See if there is a cache we can quickly load.
+	expires := Cache.GetTime(name + ".expires")
+	if !viper.GetBool("rsh-no-cache") && !expires.IsZero() && expires.After(time.Now()) {
+		var cached API
+		filename := path.Join(viper.GetString("config-directory"), name+".cbor")
+		if data, err := ioutil.ReadFile(filename); err == nil {
+			if err := cbor.Unmarshal(data, &cached); err == nil {
+				setupRootFromAPI(root, &cached)
+				return cached, nil
+			}
+		}
+	}
+
 	fromFileOrUrl := func(uri string) ([]byte, error) {
 		uriLower := strings.ToLower(uri)
 		if strings.Index(uriLower, "http") == 0 {
@@ -128,7 +168,6 @@ func Load(entrypoint string, root *cobra.Command) (API, error) {
 					if err != nil {
 						return API{}, err
 					}
-					LogDebug("Loaded %s", filename)
 					desc.Merge(tmp)
 					break
 				}
@@ -136,6 +175,7 @@ func Load(entrypoint string, root *cobra.Command) (API, error) {
 		}
 
 		if found {
+			cacheAPI(name, &desc)
 			return desc, nil
 		}
 	}
@@ -215,7 +255,11 @@ func Load(entrypoint string, root *cobra.Command) (API, error) {
 			if l.Detect(resp) {
 				resp.Body = ioutil.NopCloser(bytes.NewReader(body))
 
-				return load(root, *uri, *resolved, resp, name, l)
+				api, err := load(root, *uri, *resolved, resp, name, l)
+				if err == nil {
+					cacheAPI(name, &api)
+				}
+				return api, err
 			}
 		}
 	}
diff --git a/cli/cli.go b/cli/cli.go
index 110aff6..b57d7e9 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -183,6 +183,17 @@ func Init(name string, version string) {
 
 	Formatter = NewDefaultFormatter(tty)
 
+	cobra.AddTemplateFunc("highlight", func(s string) string {
+		// Highlighting is expensive, so only do this when the user actually asks
+		// for help via this template func and a custom help template.
+		if tty {
+			if l, err := Highlight("markdown", []byte(s)); err == nil {
+				return string(l)
+			}
+		}
+		return s
+	})
+
 	Root = &cobra.Command{
 		Use:     filepath.Base(os.Args[0]),
 		Long:    "A generic client for REST-ish APIs <https://rest.sh/>",
@@ -203,6 +214,9 @@ func Init(name string, version string) {
 		},
 	}
 	Root.SetUsageTemplate(usageTemplate)
+	Root.SetHelpTemplate(`{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces | highlight}}
+
+{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`)
 
 	head := &cobra.Command{
 		Use:               "head uri",
diff --git a/cli/content.go b/cli/content.go
index 86d0046..f98d25e 100644
--- a/cli/content.go
+++ b/cli/content.go
@@ -8,7 +8,7 @@ import (
 
 	"github.com/amzn/ion-go/ion"
 	"github.com/fxamacker/cbor/v2"
-	"github.com/shamaton/msgpack"
+	"github.com/shamaton/msgpack/v2"
 	"gopkg.in/yaml.v2"
 )
 
@@ -216,12 +216,12 @@ func (m MsgPack) Detect(contentType string) bool {
 
 // Marshal the value to encoded YAML.
 func (m MsgPack) Marshal(value interface{}) ([]byte, error) {
-	return msgpack.Encode(value)
+	return msgpack.Marshal(value)
 }
 
 // Unmarshal the value from encoded YAML.
 func (m MsgPack) Unmarshal(data []byte, value interface{}) error {
-	return msgpack.Decode(data, value)
+	return msgpack.Unmarshal(data, value)
 }
 
 // Ion describes content types like `application/ion`.
diff --git a/cli/operation.go b/cli/operation.go
index c797100..595c534 100644
--- a/cli/operation.go
+++ b/cli/operation.go
@@ -44,11 +44,6 @@ func (o Operation) command() *cobra.Command {
 	}
 
 	long := o.Long
-	if tty {
-		if l, err := Highlight("markdown", []byte(o.Long)); err == nil {
-			long = string(l)
-		}
-	}
 
 	examples := ""
 	for _, ex := range o.Examples {
diff --git a/go.mod b/go.mod
index 32a2e8c..06acb04 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
 	github.com/danielgtaylor/go-jmespath-plus v0.0.0-20200228063638-e0b6f132acba
 	github.com/danielgtaylor/shorthand v1.1.0
 	github.com/eliukblau/pixterm v1.3.1
-	github.com/fxamacker/cbor/v2 v2.3.0
+	github.com/fxamacker/cbor/v2 v2.4.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
@@ -33,9 +33,7 @@ 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/shamaton/msgpack/v2 v2.1.0
 	github.com/spf13/cobra v1.3.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.0
@@ -46,10 +44,7 @@ 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-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 423ca8f..9394303 100644
--- a/go.sum
+++ b/go.sum
@@ -36,7 +36,6 @@ cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM7
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
-cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
@@ -67,8 +66,6 @@ github.com/alexeyco/simpletable v1.0.0 h1:ZQ+LvJ4bmoeHb+dclF64d0LX+7QAi7awsfCrpt
 github.com/alexeyco/simpletable v1.0.0/go.mod h1:VJWVTtGUnW7EKbMRH8cE13SigKGx/1fO2SeeOiGeBkk=
 github.com/amzn/ion-go v1.1.3 h1:gGhjtLY0GUNQXej5N2qHhoVWQBkgtoPDt1feYYFMfOc=
 github.com/amzn/ion-go v1.1.3/go.mod h1:7wQBWQ7PhPpZCr9PL+mtuIyNmyLjuV8qt2mrfxmvkA8=
-github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
-github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -116,10 +113,6 @@ github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3 h1:qDsADtCM9A
 github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3/go.mod h1:eFdYmNxcuLDrRNW0efVoxSaApmvGXfHZ9k2CT/RSUF0=
 github.com/danielgtaylor/go-jmespath-plus v0.0.0-20200228063638-e0b6f132acba h1:COT94fQgUPh7CG42x3RTfab3V9jK9x4i2GLpubq7eZM=
 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=
@@ -149,12 +142,10 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
 github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
-github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
-github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
+github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88=
+github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
 github.com/gbl08ma/httpcache v1.0.2 h1:c0KlIIUEPrauRjvKyzsogEDSweSLYHLNgjwICpKs3CM=
 github.com/gbl08ma/httpcache v1.0.2/go.mod h1:pUnTdMCYyeNuhfm10h0vkodXia9VNFb2tgBZ0Ee+5Fc=
-github.com/getkin/kin-openapi v0.80.0 h1:W/s5/DNnDCR8P+pYyafEWlGk4S7/AfQUWXgrRSSAzf8=
-github.com/getkin/kin-openapi v0.80.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
 github.com/getkin/kin-openapi v0.86.0 h1:2SAx349giYtfQPreHWtf55Nvn0uiosxQxRZqEKYmGdA=
 github.com/getkin/kin-openapi v0.86.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
 github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
@@ -256,19 +247,14 @@ github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pf
 github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gosimple/slug v1.11.0 h1:QkFeOkXIEDvvtIt++P7cUuO4G9PZVQEgLuYbYZzawMA=
-github.com/gosimple/slug v1.11.0/go.mod h1:MICb3w495l9KNdZm+Xn5b6T2Hn831f9DMxiJ1r+bAjw=
 github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc=
 github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
-github.com/gosimple/unidecode v1.0.0 h1:kPdvM+qy0tnk4/BrnkrbdJ82xe88xn7c9hcaipDz4dQ=
-github.com/gosimple/unidecode v1.0.0/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
 github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
 github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
 github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
-github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
 github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
 github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
@@ -364,8 +350,6 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
 github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
-github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
@@ -395,8 +379,6 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo=
-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=
@@ -446,13 +428,10 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-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/shamaton/msgpack/v2 v2.1.0 h1:9jJ2eGZw2Wa9KExPX3KaDDckVjgr4zhXGFCfWagUWqg=
+github.com/shamaton/msgpack/v2 v2.1.0/go.mod h1:aTUEmh31ziGX1Ml7wMPLVY0f4vT3CRsCvZRoSCs+VGg=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@@ -465,7 +444,6 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
 github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
 github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
 github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0=
 github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
@@ -474,8 +452,6 @@ github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
-github.com/spf13/viper v1.9.0 h1:yR6EXjTp0y0cLN8OZg1CRZmOBdI88UcGkhgyJhu6nZk=
-github.com/spf13/viper v1.9.0/go.mod h1:+i6ajR7OX2XaiBkrcZJFK21htRk7eDeLg7+O6bhUPP4=
 github.com/spf13/viper v1.10.0 h1:mXH0UwHS4D2HwWZa75im4xIQynLfblmWV7qcWpfv0yk=
 github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -533,8 +509,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
 golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -623,8 +597,6 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy
 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=
 golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9 h1:kmreh1vGI63l2FxOAYS3Yv6ATsi7lSTuwNSVbGfJV9I=
 golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -645,8 +617,6 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM=
-golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -726,12 +696,8 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc
 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=
@@ -974,8 +940,6 @@ gopkg.in/h2non/gentleman.v1 v1.0.4/go.mod h1:JYuHVdFzS4MKOXe0o+chKJ4hCe6tqKKw9XH
 gopkg.in/h2non/gock.v1 v1.0.16 h1:F11k+OafeuFENsjei5t2vMTSTs9L62AdyTe4E1cgdG8=
 gopkg.in/h2non/gock.v1 v1.0.16/go.mod h1:XVuDAssexPLwgxCLMvDTWNU5eqklsydR6I5phZ9oPB8=
 gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.63.2 h1:tGK/CyBg7SMzb60vP1M03vNZ3VDu3wGQJwn7Sxi9r3c=
-gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
 gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
diff --git a/openapi/openapi.go b/openapi/openapi.go
index e6321c9..c1a73cc 100644
--- a/openapi/openapi.go
+++ b/openapi/openapi.go
@@ -302,6 +302,9 @@ func openapiOperation(cmd *cobra.Command, method string, uriTemplate *url.URL, p
 
 		if len(resp.Content) > 0 {
 			for ct, typeInfo := range resp.Content {
+				if len(desc) > 0 && !strings.HasSuffix(desc, "\n") {
+					desc += "\n"
+				}
 				desc += "\n## Response " + code + " (" + ct + ")\n"
 				if resp.Description != nil && *resp.Description != "" {
 					desc += "\n" + *resp.Description + "\n"
@@ -312,6 +315,9 @@ func openapiOperation(cmd *cobra.Command, method string, uriTemplate *url.URL, p
 				}
 			}
 		} else {
+			if len(desc) > 0 && !strings.HasSuffix(desc, "\n") {
+				desc += "\n"
+			}
 			desc += "\n## Response " + code + "\n"
 			if resp.Description != nil && *resp.Description != "" {
 				desc += "\n" + *resp.Description + "\n"


Test patch:
diff --git a/cli/cli_test.go b/cli/cli_test.go
index ab3a767..c369e9a 100644
--- a/cli/cli_test.go
+++ b/cli/cli_test.go
@@ -5,7 +5,9 @@ import (
 	"os"
 	"strings"
 	"testing"
+	"time"
 
+	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 	"gopkg.in/h2non/gock.v1"
@@ -38,6 +40,7 @@ func runNoReset(cmd string) string {
 	capture := &strings.Builder{}
 	Stdout = capture
 	Stderr = capture
+	Root.SetOut(capture)
 	os.Args = strings.Split("restish "+cmd, " ")
 	Run()
 
@@ -157,3 +160,63 @@ func TestDefaultOutput(t *testing.T) {
 	captured := run("http://example.com/foo", true)
 	assert.Equal(t, "\x1b[38;5;204mHTTP\x1b[0m/\x1b[38;5;172m1.1\x1b[0m \x1b[38;5;172m200\x1b[0m \x1b[38;5;74mOK\x1b[0m\n\x1b[38;5;74mContent-Type\x1b[0m: application/json\n\n\x1b[38;5;247m{\x1b[0m\n  \x1b[38;5;74mhello\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"world\"\x1b[0m\x1b[38;5;247m\n}\x1b[0m\n", captured)
 }
+
+func TestHelp(t *testing.T) {
+	captured := run("--help", false)
+	assert.Contains(t, captured, "api")
+	assert.Contains(t, captured, "get")
+	assert.Contains(t, captured, "put")
+	assert.Contains(t, captured, "delete")
+	assert.Contains(t, captured, "edit")
+}
+
+func TestHelpHighlight(t *testing.T) {
+	captured := run("--help", true)
+	assert.Contains(t, captured, "api")
+	assert.Contains(t, captured, "get")
+	assert.Contains(t, captured, "put")
+	assert.Contains(t, captured, "delete")
+	assert.Contains(t, captured, "edit")
+}
+
+func TestLoadCache(t *testing.T) {
+	// Invalidate any existin cache.
+	Cache.Set("cache-test.expires", time.Now().Add(-24*time.Hour))
+	Cache.WriteConfig()
+	defer gock.Off()
+
+	// Only *one* set of remote requests should be made. After that it should be
+	// using the cache.
+	gock.New("https://example.com/").Reply(404)
+	gock.New("https://example.com/openapi.json").Reply(200).JSON(map[string]interface{}{
+		"openapi": "3.0.0",
+	})
+
+	reset(false)
+	configs["cache-test"] = &APIConfig{
+		name: "cache-test",
+		Base: "https://example.com",
+		Profiles: map[string]*APIProfile{
+			"default": {},
+		},
+	}
+	cmd := &cobra.Command{
+		Use: "cache-test",
+	}
+	Root.AddCommand(cmd)
+
+	AddLoader(&testLoader{
+		API: API{
+			Short:      "Cache Test API",
+			Operations: []Operation{},
+		},
+	})
+
+	// First run will generate the cache.
+	runNoReset("cache-test --help")
+
+	// These runs should *not* make any remote requests. If they do, then
+	// gock will panic as only one call is mocked above.
+	runNoReset("cache-test --help")
+	runNoReset("cache-test --help")
+}
diff --git a/cli/interactive_test.go b/cli/interactive_test.go
index 90830a1..4d3481d 100644
--- a/cli/interactive_test.go
+++ b/cli/interactive_test.go
@@ -37,6 +37,7 @@ func (a *mockAsker) askSelect(message string, options []string, def interface{},
 func TestInteractive(t *testing.T) {
 	// Remove existing config if present...
 	os.Remove(path.Join(userHomeDir(), ".test", "apis.json"))
+	os.Remove(path.Join(userHomeDir(), ".test", "cache.json"))
 
 	reset(false)
 
@@ -92,12 +93,14 @@ func (l *testLoader) Detect(resp *http.Response) bool {
 }
 
 func (l *testLoader) Load(entrypoint, spec url.URL, resp *http.Response) (API, error) {
+	LogInfo("Loading API %s", entrypoint.String())
 	return l.API, nil
 }
 
 func TestInteractiveAutoConfig(t *testing.T) {
 	// Remove existing config if present...
 	os.Remove(path.Join(userHomeDir(), ".test", "apis.json"))
+	os.Remove(path.Join(userHomeDir(), ".test", "cache.json"))
 
 	reset(false)
 	AddLoader(&testLoader{
diff --git a/cli/operation_test.go b/cli/operation_test.go
index 56d6c5e..e77df0d 100644
--- a/cli/operation_test.go
+++ b/cli/operation_test.go
@@ -50,12 +50,13 @@ func TestOperation(t *testing.T) {
 	cmd := op.command()
 
 	viper.Reset()
+	viper.Set("nocolor", true)
 	Init("test", "1.0.0")
 	Defaults()
-	viper.Set("nocolor", true)
 	capture := &strings.Builder{}
 	Stdout = capture
 	Stderr = capture
+	cmd.SetOut(Stdout)
 	cmd.Flags().Parse([]string{"--search=foo"})
 	cmd.Run(cmd, []string{"id1"})