Skip to content

Commit c094c86

Browse files
Merge pull request #95 from danielgtaylor/fix-completion-urls
fix: improved shell completion for URLs
2 parents 4f24894 + 450d792 commit c094c86

File tree

2 files changed

+170
-39
lines changed

2 files changed

+170
-39
lines changed

cli/cli.go

+93-39
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path"
1313
"path/filepath"
14+
"regexp"
1415
"runtime"
1516
"runtime/debug"
1617
"strings"
@@ -100,54 +101,107 @@ func generic(method string, addr string, args []string) {
100101
MakeRequestAndFormat(req)
101102
}
102103

103-
func completeCurrentConfig(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
104-
possible := []string{}
105-
if currentConfig != nil {
106-
for _, cmd := range Root.Commands() {
107-
if cmd.Use == currentConfig.name {
108-
api, _ := Load(currentConfig.Base, cmd)
109-
for _, op := range api.Operations {
110-
template := op.URITemplate
111-
if strings.HasPrefix(toComplete, currentConfig.name) {
112-
template = strings.Replace(template, currentConfig.Base, currentConfig.name, 1)
113-
}
114-
possible = append(possible, template)
115-
}
104+
// templateVarRegex used to find/replace variables `/{foo}/bar/{baz}` in a
105+
// template string.
106+
var templateVarRegex = regexp.MustCompile(`\{.*?\}`)
107+
108+
// matchTemplate will see if a given URL matches a URL template, and if so,
109+
// returns the template with the variable parts replaced by the matched part.
110+
// If no match, returns the original template. Example:
111+
// Input URL: https://example.com/items/foo
112+
// Input tpl: https://example.com/items/{item-id}/tags/{tag-id}
113+
// Output : https://example.com/items/foo/tags/{tag-id}
114+
func matchTemplate(url, template string) string {
115+
urlParts := strings.Split(url, "/")
116+
tplParts := strings.Split(template, "/")
117+
for i, urlPart := range urlParts {
118+
if len(tplParts) < i+1 {
119+
break
120+
}
121+
122+
tplPart := tplParts[i]
123+
124+
if strings.Contains(tplPart, "{") {
125+
matcher := regexp.MustCompile(templateVarRegex.ReplaceAllString(tplPart, ".*"))
126+
if matcher.MatchString(urlPart) && urlPart != "" {
127+
tplParts[i] = urlPart
116128
}
129+
} else if urlPart == tplPart {
130+
// This is an exact path match.
131+
continue
117132
}
118-
return possible, cobra.ShellCompDirectiveNoFileComp
133+
134+
// Give up, not a match!
135+
break
119136
}
120-
return []string{}, cobra.ShellCompDirectiveDefault
137+
138+
return strings.Join(tplParts, "/")
121139
}
122140

123-
func completeGenericCmd(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
124-
possible, directive := completeCurrentConfig(cmd, args, toComplete)
125-
if directive != cobra.ShellCompDirectiveDefault {
126-
return possible, directive
127-
}
141+
// completeCurrentConfig generates possible completions based on the currently
142+
// selected API configuration's known operation URL templates. Takes into
143+
// account short-names as well as the full URL.
144+
func completeCurrentConfig(cmd *cobra.Command, args []string, toComplete string, method string) ([]string, cobra.ShellCompDirective) {
145+
possible := []string{}
128146
if currentConfig != nil {
129147
for _, cmd := range Root.Commands() {
130148
if cmd.Use == currentConfig.name {
149+
// This is the matching command. Load the URL and check each operation.
131150
api, _ := Load(currentConfig.Base, cmd)
132151
for _, op := range api.Operations {
133-
template := op.URITemplate
152+
if op.Method != method {
153+
// We only care about operations which match the currently selected
154+
// HTTP method, otherwise it makes no sense to show it as an
155+
// option since it couldn't possibly work.
156+
continue
157+
}
158+
159+
// Handle short-name, missing https:// prefix.
160+
fixed := fixAddress(toComplete)
161+
162+
// Modify the template to fill in matched variables.
163+
template := matchTemplate(fixed, op.URITemplate)
134164
if strings.HasPrefix(toComplete, currentConfig.name) {
165+
// We were using a short-name, convert back to it! This is
166+
// friendlier than forcing the full URL on the user.
135167
template = strings.Replace(template, currentConfig.Base, currentConfig.name, 1)
168+
} else if !strings.HasPrefix(toComplete, "https://") {
169+
// Handle missing prefix.
170+
template = strings.TrimPrefix(template, "https://")
171+
}
172+
if strings.HasPrefix(template, toComplete) || strings.HasPrefix(template, fixed) {
173+
if op.Short != "" {
174+
// Cobra supports descriptions for each completion, so if
175+
// available we add it here.s
176+
template += "\t" + op.Short
177+
}
178+
possible = append(possible, template)
136179
}
137-
possible = append(possible, template)
138180
}
139181
}
140182
}
141183
return possible, cobra.ShellCompDirectiveNoFileComp
142184
}
185+
return []string{}, cobra.ShellCompDirectiveDefault
186+
}
143187

144-
if len(args) == 0 {
145-
for name := range configs {
146-
possible = append(possible, name)
188+
// completeGenericCmd shows possible completions for generic commands, for
189+
// example get/post/put/patch/delete/etc.
190+
func completeGenericCmd(method string, showAPIs bool) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
191+
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
192+
possible, directive := completeCurrentConfig(cmd, args, toComplete, method)
193+
if directive != cobra.ShellCompDirectiveDefault {
194+
return possible, directive
147195
}
148-
}
149196

150-
return possible, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
197+
if showAPIs && len(args) == 0 {
198+
for name := range configs {
199+
possible = append(possible, name)
200+
}
201+
}
202+
203+
return possible, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
204+
}
151205
}
152206

153207
// Init will set up the CLI.
@@ -204,7 +258,7 @@ func Init(name string, version string) {
204258
# Specify verb, header, and body shorthand
205259
$ %s post :8888/users -H authorization:abc123 name: Kari, role: admin`, name, name),
206260
Args: cobra.MinimumNArgs(1),
207-
ValidArgsFunction: completeCurrentConfig,
261+
ValidArgsFunction: completeGenericCmd(http.MethodGet, false),
208262
PersistentPreRun: func(cmd *cobra.Command, args []string) {
209263
settings := viper.AllSettings()
210264
LogDebug("Configuration: %v", settings)
@@ -223,7 +277,7 @@ func Init(name string, version string) {
223277
Short: "Head a URI",
224278
Long: "Perform an HTTP HEAD on the given URI",
225279
Args: cobra.MinimumNArgs(1),
226-
ValidArgsFunction: completeGenericCmd,
280+
ValidArgsFunction: completeGenericCmd(http.MethodHead, true),
227281
Run: func(cmd *cobra.Command, args []string) {
228282
generic(http.MethodHead, args[0], args[1:])
229283
},
@@ -235,7 +289,7 @@ func Init(name string, version string) {
235289
Short: "Options a URI",
236290
Long: "Perform an HTTP OPTIONS on the given URI",
237291
Args: cobra.MinimumNArgs(1),
238-
ValidArgsFunction: completeGenericCmd,
292+
ValidArgsFunction: completeGenericCmd(http.MethodOptions, true),
239293
Run: func(cmd *cobra.Command, args []string) {
240294
generic(http.MethodOptions, args[0], args[1:])
241295
},
@@ -247,7 +301,7 @@ func Init(name string, version string) {
247301
Short: "Get a URI",
248302
Long: "Perform an HTTP GET on the given URI",
249303
Args: cobra.MinimumNArgs(1),
250-
ValidArgsFunction: completeGenericCmd,
304+
ValidArgsFunction: completeGenericCmd(http.MethodGet, true),
251305
Run: func(cmd *cobra.Command, args []string) {
252306
generic(http.MethodGet, args[0], args[1:])
253307
},
@@ -259,7 +313,7 @@ func Init(name string, version string) {
259313
Short: "Post a URI",
260314
Long: "Perform an HTTP POST on the given URI",
261315
Args: cobra.MinimumNArgs(1),
262-
ValidArgsFunction: completeGenericCmd,
316+
ValidArgsFunction: completeGenericCmd(http.MethodPost, true),
263317
Run: func(cmd *cobra.Command, args []string) {
264318
generic(http.MethodPost, args[0], args[1:])
265319
},
@@ -271,7 +325,7 @@ func Init(name string, version string) {
271325
Short: "Put a URI",
272326
Long: "Perform an HTTP PUT on the given URI",
273327
Args: cobra.MinimumNArgs(1),
274-
ValidArgsFunction: completeGenericCmd,
328+
ValidArgsFunction: completeGenericCmd(http.MethodPut, true),
275329
Run: func(cmd *cobra.Command, args []string) {
276330
generic(http.MethodPut, args[0], args[1:])
277331
},
@@ -283,7 +337,7 @@ func Init(name string, version string) {
283337
Short: "Patch a URI",
284338
Long: "Perform an HTTP PATCH on the given URI",
285339
Args: cobra.MinimumNArgs(1),
286-
ValidArgsFunction: completeGenericCmd,
340+
ValidArgsFunction: completeGenericCmd(http.MethodPatch, true),
287341
Run: func(cmd *cobra.Command, args []string) {
288342
generic(http.MethodPatch, args[0], args[1:])
289343
},
@@ -295,7 +349,7 @@ func Init(name string, version string) {
295349
Short: "Delete a URI",
296350
Long: "Perform an HTTP DELETE on the given URI",
297351
Args: cobra.MinimumNArgs(1),
298-
ValidArgsFunction: completeGenericCmd,
352+
ValidArgsFunction: completeGenericCmd(http.MethodDelete, true),
299353
Run: func(cmd *cobra.Command, args []string) {
300354
generic(http.MethodDelete, args[0], args[1:])
301355
},
@@ -310,7 +364,7 @@ func Init(name string, version string) {
310364
Short: "Edit a resource by URI",
311365
Long: "Convenience function which combines a GET, edit, and PUT operation into one command",
312366
Args: cobra.MinimumNArgs(1),
313-
ValidArgsFunction: completeGenericCmd,
367+
ValidArgsFunction: completeGenericCmd(http.MethodGet, true),
314368
Run: func(cmd *cobra.Command, args []string) {
315369
switch *editFormat {
316370
case "json":
@@ -340,7 +394,7 @@ func Init(name string, version string) {
340394
# Example usage with curl
341395
$ curl https://my-apiexample.com/ -H "Authorization: $(%s auth-header my-api)"`, name, name, name),
342396
Args: cobra.ExactArgs(1),
343-
ValidArgsFunction: completeGenericCmd,
397+
ValidArgsFunction: completeGenericCmd(http.MethodGet, true),
344398
RunE: func(cmd *cobra.Command, args []string) error {
345399
addr := fixAddress(args[0])
346400
name, config := findAPI(addr)
@@ -376,7 +430,7 @@ func Init(name string, version string) {
376430
Short: "Get cert info",
377431
Long: "Get TLS certificate information including expiration date",
378432
Args: cobra.ExactArgs(1),
379-
ValidArgsFunction: completeGenericCmd,
433+
ValidArgsFunction: completeGenericCmd(http.MethodGet, true),
380434
Run: func(cmd *cobra.Command, args []string) {
381435
u, err := url.Parse(fixAddress(args[0]))
382436
if err != nil {
@@ -428,7 +482,7 @@ Not after (expires): %s (%s)
428482
Short: "Get link relations from the given URI, with optional filtering",
429483
Long: "Returns a list of resolved references to the link relations after making an HTTP GET request to the given URI. Additional arguments filter down the set of returned relationship names.",
430484
Args: cobra.MinimumNArgs(1),
431-
ValidArgsFunction: completeGenericCmd,
485+
ValidArgsFunction: completeGenericCmd(http.MethodGet, true),
432486
Run: func(cmd *cobra.Command, args []string) {
433487
req, _ := http.NewRequest(http.MethodGet, fixAddress(args[0]), nil)
434488
resp, err := GetParsedResponse(req)

cli/cli_test.go

+77
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,80 @@ func TestDuplicateAPIBase(t *testing.T) {
270270
run("--help")
271271
})
272272
}
273+
274+
func TestCompletion(t *testing.T) {
275+
defer gock.Off()
276+
277+
gock.New("https://api.example.com/").Reply(http.StatusNotFound)
278+
gock.New("https://api.example.com/openapi.json").Reply(http.StatusOK)
279+
280+
Init("Completion test", "1.0.0")
281+
Defaults()
282+
283+
configs["comptest"] = &APIConfig{
284+
name: "comptest",
285+
Base: "https://api.example.com",
286+
}
287+
288+
Root.AddCommand(&cobra.Command{
289+
Use: "comptest",
290+
})
291+
292+
AddLoader(&testLoader{
293+
API: API{
294+
Operations: []Operation{
295+
{
296+
Method: http.MethodGet,
297+
URITemplate: "https://api.example.com/users",
298+
},
299+
{
300+
Method: http.MethodGet,
301+
URITemplate: "https://api.example.com/users/{user-id}",
302+
},
303+
{
304+
Short: "List item tags",
305+
Method: http.MethodGet,
306+
URITemplate: "https://api.example.com/items/{item-id}/tags",
307+
},
308+
{
309+
Short: "Get tag details",
310+
Method: http.MethodGet,
311+
URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}",
312+
},
313+
{
314+
Method: http.MethodDelete,
315+
URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}",
316+
},
317+
},
318+
},
319+
})
320+
321+
// Force a cache-reload if needed.
322+
viper.Set("rsh-no-cache", true)
323+
Load("https://api.example.com/", &cobra.Command{})
324+
viper.Set("rsh-no-cache", false)
325+
326+
currentConfig = nil
327+
328+
// Show APIs
329+
possible, _ := completeGenericCmd(http.MethodGet, true)(nil, []string{}, "")
330+
assert.Equal(t, []string{
331+
"comptest",
332+
}, possible)
333+
334+
currentConfig = configs["comptest"]
335+
336+
// Short-name URL completion with variables filled in.
337+
possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "comptest/items/my-item")
338+
assert.Equal(t, []string{
339+
"comptest/items/my-item/tags\tList item tags",
340+
"comptest/items/my-item/tags/{tag-id}\tGet tag details",
341+
}, possible)
342+
343+
// URL without scheme
344+
possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "api.example.com/items/my-item")
345+
assert.Equal(t, []string{
346+
"api.example.com/items/my-item/tags\tList item tags",
347+
"api.example.com/items/my-item/tags/{tag-id}\tGet tag details",
348+
}, possible)
349+
}

0 commit comments

Comments
 (0)