Skip to content

Commit ba45f05

Browse files
Merge pull request #26 from danielgtaylor/autoconfig
feat: Autoconfiguration via x-cli-config
2 parents eec1617 + e27ed19 commit ba45f05

12 files changed

+433
-25
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Features include:
1616
- [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation
1717
- Supported formats
1818
- [OpenAPI 3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) and [JSON Schema](https://json-schema.org/)
19+
- Automatic configuration of API auth if advertised by the API
1920
- Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links
2021
- API endpoint-based auth built-in with support for profiles:
2122
- HTTP Basic

cli/api.go

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type API struct {
2121
Long string `json:"long,omitempty"`
2222
Operations []Operation `json:"operations,omitempty"`
2323
Auth []APIAuth `json:"auth,omitempty"`
24+
AutoConfig AutoConfig `json:"autoconfig,omitempty"`
2425
}
2526

2627
// Merge two APIs together. Takes the description if none is set and merges

cli/apiconfig.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ type APIAuth struct {
2121

2222
// APIProfile contains account-specific API information
2323
type APIProfile struct {
24-
Headers map[string]string `json:"headers"`
25-
Query map[string]string `json:"query"`
24+
Headers map[string]string `json:"headers,omitempty"`
25+
Query map[string]string `json:"query,omitempty"`
2626
Auth *APIAuth `json:"auth"`
2727
}
2828

@@ -76,7 +76,7 @@ func initAPIConfig() {
7676
Aliases: []string{"config"},
7777
Short: "Initialize an API",
7878
Long: "Initializes an API with a short interactive prompt session to set up the base URI and auth if needed.",
79-
Args: cobra.ExactArgs(1),
79+
Args: cobra.MinimumNArgs(1),
8080
Run: askInitAPIDefault,
8181
})
8282

cli/autoconfig.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package cli
2+
3+
// AutoConfigVar represents a variable given by the user when prompted during
4+
// auto-configuration setup of an API.
5+
type AutoConfigVar struct {
6+
Description string `json:"description,omitempty"`
7+
Example string `json:"example,omitempty"`
8+
Default interface{} `json:"default,omitempty"`
9+
Enum []interface{} `json:"enum,omitempty"`
10+
}
11+
12+
// AutoConfig holds an API's automatic configuration settings for the CLI. These
13+
// are advertised via OpenAPI extension and picked up by the CLI to make it
14+
// easier to get started using an API.
15+
type AutoConfig struct {
16+
Headers map[string]string `json:"headers,omitempty"`
17+
Prompt map[string]AutoConfigVar `json:"prompt,omitempty"`
18+
Auth APIAuth `json:"auth,omitempty"`
19+
}

cli/cli.go

+1
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ func Init(name string, version string) {
9999
contentTypes = []contentTypeEntry{}
100100
encodings = map[string]ContentEncoding{}
101101
linkParsers = []LinkParser{}
102+
loaders = []Loader{}
102103

103104
// Determine if we are using a TTY or colored output is forced-on.
104105
tty = false

cli/interactive.go

+88-20
Original file line numberDiff line numberDiff line change
@@ -72,33 +72,92 @@ func (a defaultAsker) askSelect(message string, options []string, def interface{
7272
func askBaseURI(a asker, config *APIConfig) {
7373
config.Base = a.askInput("Base URI", config.Base, true, "The entrypoint of the API, where Restish can look for an API description document and apply authentication.\nExample: https://api.example.com")
7474

75+
askLoadBaseAPI(a, config)
76+
}
77+
78+
func askLoadBaseAPI(a asker, config *APIConfig) {
79+
var auth APIAuth
80+
7581
dummy := &cobra.Command{}
7682
if api, err := Load(config.Base, dummy); err == nil {
7783
// Found an API, auto-load settings.
78-
if len(api.Auth) > 0 {
79-
auth := api.Auth[0]
8084

81-
if config.Profiles == nil {
82-
config.Profiles = map[string]*APIProfile{}
85+
if api.AutoConfig.Auth.Name != "" {
86+
// Found auto-configuration settings.
87+
fmt.Println("Found API auto-configuration, setting up default profile...")
88+
ac := api.AutoConfig
89+
responses := map[string]string{}
90+
91+
// Get inputs from the user.
92+
for name, v := range ac.Prompt {
93+
def := ""
94+
if v.Default != nil {
95+
def = fmt.Sprintf("%v", v.Default)
96+
}
97+
98+
if len(v.Enum) > 0 {
99+
enumStr := []string{}
100+
for val := range v.Enum {
101+
enumStr = append(enumStr, fmt.Sprintf("%v", val))
102+
}
103+
responses[name] = a.askSelect(name, enumStr, def, v.Description)
104+
} else {
105+
responses[name] = a.askInput(name, def, v.Default == nil, v.Description)
106+
}
83107
}
84108

85-
def := config.Profiles["default"]
109+
// Generate params from user inputs.
110+
params := map[string]string{}
111+
for name, resp := range responses {
112+
params[name] = resp
113+
}
86114

87-
if def == nil {
88-
def = &APIProfile{}
89-
config.Profiles["default"] = def
115+
for name, template := range ac.Auth.Params {
116+
rendered := template
117+
118+
// Render by replacing `{name}` with the value.
119+
for rn, rv := range responses {
120+
rendered = strings.ReplaceAll(rendered, "{"+rn+"}", rv)
121+
}
122+
123+
params[name] = rendered
90124
}
91125

92-
if def.Auth == nil {
93-
def.Auth = &APIAuth{}
126+
// Setup auth for the profile based on the rendered params.
127+
auth = APIAuth{
128+
Name: ac.Auth.Name,
129+
Params: params,
94130
}
131+
}
95132

96-
if def.Auth.Name == "" {
97-
def.Auth.Name = auth.Name
98-
def.Auth.Params = map[string]string{}
99-
for k, v := range auth.Params {
100-
def.Auth.Params[k] = v
101-
}
133+
if auth.Name == "" && len(api.Auth) > 0 {
134+
// No auto-configuration present or successful, so fall back to the first
135+
// available defined security scheme.
136+
auth = api.Auth[0]
137+
}
138+
139+
if config.Profiles == nil {
140+
config.Profiles = map[string]*APIProfile{}
141+
}
142+
143+
// Setup the default profile, taking care not to blast away any existing
144+
// custom configuration if we are just updating the values.
145+
def := config.Profiles["default"]
146+
147+
if def == nil {
148+
def = &APIProfile{}
149+
config.Profiles["default"] = def
150+
}
151+
152+
if def.Auth == nil {
153+
def.Auth = &APIAuth{}
154+
}
155+
156+
if auth.Name != "" {
157+
def.Auth.Name = auth.Name
158+
def.Auth.Params = map[string]string{}
159+
for k, v := range auth.Params {
160+
def.Auth.Params[k] = v
102161
}
103162
}
104163
}
@@ -229,10 +288,19 @@ func askInitAPI(a asker, cmd *cobra.Command, args []string) {
229288
configs[args[0]] = config
230289

231290
// Do an initial setup with a default profile first.
232-
askBaseURI(a, config)
233-
fmt.Println("Setting up a `default` profile")
234-
config.Profiles["default"] = &APIProfile{}
235-
askEditProfile(a, "default", config.Profiles["default"])
291+
if len(args) == 1 {
292+
askBaseURI(a, config)
293+
} else {
294+
config.Base = args[1]
295+
askLoadBaseAPI(a, config)
296+
}
297+
298+
if config.Profiles["default"] == nil {
299+
fmt.Println("Setting up a `default` profile")
300+
config.Profiles["default"] = &APIProfile{}
301+
302+
askEditProfile(a, "default", config.Profiles["default"])
303+
}
236304
}
237305

238306
for {

cli/interactive_test.go

+119
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli
22

33
import (
4+
"net/http"
5+
"net/url"
46
"os"
57
"path"
68
"testing"
@@ -76,3 +78,120 @@ func TestInteractive(t *testing.T) {
7678

7779
askInitAPI(mock, Root, []string{"example"})
7880
}
81+
82+
type testLoader struct {
83+
API API
84+
}
85+
86+
func (l *testLoader) LocationHints() []string {
87+
return []string{"/openapi.json"}
88+
}
89+
90+
func (l *testLoader) Detect(resp *http.Response) bool {
91+
return true
92+
}
93+
94+
func (l *testLoader) Load(entrypoint, spec url.URL, resp *http.Response) (API, error) {
95+
return l.API, nil
96+
}
97+
98+
func TestInteractiveAutoConfig(t *testing.T) {
99+
// Remove existing config if present...
100+
os.Remove(path.Join(userHomeDir(), ".test", "apis.json"))
101+
102+
reset(false)
103+
AddLoader(&testLoader{
104+
API: API{
105+
Short: "Swagger Petstore",
106+
Auth: []APIAuth{
107+
{
108+
Name: "oauth-authorization-code",
109+
Params: map[string]string{
110+
"client_id": "",
111+
"authorize_url": "https://example.com/authorize",
112+
"token_url": "https://example.com/token",
113+
},
114+
},
115+
},
116+
Operations: []Operation{
117+
{
118+
Name: "createpets",
119+
Short: "Create a pet",
120+
Long: "\n## Response 201\n\nNull response\n\n## Response default (application/json)\n\nunexpected error\n\n```schema\n{\n code*: (integer format:int32) \n message*: (string) \n}\n```\n",
121+
Method: "POST",
122+
URITemplate: "http://api.example.com/pets",
123+
PathParams: []*Param{},
124+
QueryParams: []*Param{},
125+
HeaderParams: []*Param{},
126+
},
127+
{
128+
Name: "listpets",
129+
Short: "List all pets",
130+
Long: "\n## Response 200 (application/json)\n\nA paged array of pets\n\n```schema\n[\n {\n id*: (integer format:int64) \n name*: (string) \n tag: (string) \n }\n]\n```\n\n## Response default (application/json)\n\nunexpected error\n\n```schema\n{\n code*: (integer format:int32) \n message*: (string) \n}\n```\n",
131+
Method: "GET",
132+
URITemplate: "http://api.example.com/pets",
133+
PathParams: []*Param{},
134+
QueryParams: []*Param{
135+
{
136+
Type: "integer",
137+
Name: "limit",
138+
Description: "How many items to return at one time (max 100)",
139+
},
140+
},
141+
HeaderParams: []*Param{},
142+
},
143+
{
144+
Name: "showpetbyid",
145+
Short: "Info for a specific pet",
146+
Long: "\n## Response 200 (application/json)\n\nExpected response to a valid request\n\n```schema\n{\n id*: (integer format:int64) \n name*: (string) \n tag: (string) \n}\n```\n\n## Response default (application/json)\n\nunexpected error\n\n```schema\n{\n code*: (integer format:int32) \n message*: (string) \n}\n```\n",
147+
Method: "GET",
148+
URITemplate: "http://api.example.com/pets/{petId}",
149+
PathParams: []*Param{
150+
{
151+
Type: "string",
152+
Name: "petId",
153+
Description: "The id of the pet to retrieve",
154+
},
155+
},
156+
QueryParams: []*Param{},
157+
HeaderParams: []*Param{},
158+
},
159+
},
160+
AutoConfig: AutoConfig{
161+
Prompt: map[string]AutoConfigVar{
162+
"client_id": {
163+
Description: "Client identifier",
164+
Example: "abc123",
165+
},
166+
},
167+
Auth: APIAuth{
168+
Name: "oauth-authorization-code",
169+
Params: map[string]string{
170+
"client_id": "",
171+
"authorize_url": "https://example.com/authorize",
172+
"token_url": "https://example.com/token",
173+
},
174+
},
175+
},
176+
},
177+
})
178+
defer reset(false)
179+
180+
defer gock.Off()
181+
182+
gock.New("http://api2.example.com").Get("/").Reply(200).JSON(map[string]interface{}{
183+
"Hello": "World",
184+
})
185+
186+
gock.New("http://api2.example.com").Get("/openapi.json").Reply(200).BodyString("dummy")
187+
188+
mock := &mockAsker{
189+
t: t,
190+
responses: []string{
191+
"foo",
192+
"Save and exit",
193+
},
194+
}
195+
196+
askInitAPI(mock, Root, []string{"autoconfig", "http://api2.example.com"})
197+
}

docs/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation
1919
- Supported formats
2020
- [OpenAPI 3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) and [JSON Schema](https://json-schema.org/)
21+
- Automatic configuration of API auth if advertised by the API
2122
- Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links
2223
- API endpoint-based auth built-in with support for profiles:
2324
- HTTP Basic

docs/configuration.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@ Should TTY autodetection for colored output cause any problems, you can manually
5353
Adding or editing an API is possible via an interactive terminal UI:
5454

5555
```bash
56-
$ restish api configure $NAME
56+
$ restish api configure $NAME [$BASE_URI]
5757
```
5858

5959
You should see something like the following, which enables you to create and edit profiles, headers, query params, and auth, eventually saving the data to `~/.restish/apis.json`:
6060

6161
<img alt="Screen Shot" src="https://user-images.githubusercontent.com/106826/83099522-79dd3200-a062-11ea-8a78-b03a2fecf030.png">
6262

63+
If the API offers autoconfiguration data (e.g. through the [`x-cli-config` OpenAPI extension](/openapi.md#AutoConfiguration)) then you may be prompted for other values and some settings may already be configured for you.
64+
6365
Once an API is configured, you can start using it by using its short name. For example, given an API named `example`:
6466

6567
```bash

0 commit comments

Comments
 (0)