Repository: danielgtaylor/restish
Pull request #26 (2020-12-22 20:57:04)

feat: Autoconfiguration via x-cli-config
Adds support for an **optional** `x-cli-config` OpenAPI extension to let APIs tell Restish (and other clients) how to automatically configure themselves to make authenticated requests to the API, asking for user input for just the minimum set of necessary things (like secrets). This *significantly* simplifies having customers onboard to using a tool like Restish.


Patch:
diff --git a/README.md b/README.md
index cfcc6c7..0d9f880 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Features include:
     - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation
   - Supported formats
     - [OpenAPI 3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) and [JSON Schema](https://json-schema.org/)
+  - Automatic configuration of API auth if advertised by the API
 - Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links
 - API endpoint-based auth built-in with support for profiles:
   - HTTP Basic
diff --git a/cli/api.go b/cli/api.go
index 8b60cb5..6d9090d 100644
--- a/cli/api.go
+++ b/cli/api.go
@@ -21,6 +21,7 @@ type API struct {
 	Long       string      `json:"long,omitempty"`
 	Operations []Operation `json:"operations,omitempty"`
 	Auth       []APIAuth   `json:"auth,omitempty"`
+	AutoConfig AutoConfig  `json:"autoconfig,omitempty"`
 }
 
 // Merge two APIs together. Takes the description if none is set and merges
diff --git a/cli/apiconfig.go b/cli/apiconfig.go
index 53fbdb7..8be88da 100644
--- a/cli/apiconfig.go
+++ b/cli/apiconfig.go
@@ -21,8 +21,8 @@ type APIAuth struct {
 
 // APIProfile contains account-specific API information
 type APIProfile struct {
-	Headers map[string]string `json:"headers"`
-	Query   map[string]string `json:"query"`
+	Headers map[string]string `json:"headers,omitempty"`
+	Query   map[string]string `json:"query,omitempty"`
 	Auth    *APIAuth          `json:"auth"`
 }
 
@@ -76,7 +76,7 @@ func initAPIConfig() {
 		Aliases: []string{"config"},
 		Short:   "Initialize an API",
 		Long:    "Initializes an API with a short interactive prompt session to set up the base URI and auth if needed.",
-		Args:    cobra.ExactArgs(1),
+		Args:    cobra.MinimumNArgs(1),
 		Run:     askInitAPIDefault,
 	})
 
diff --git a/cli/autoconfig.go b/cli/autoconfig.go
new file mode 100644
index 0000000..404e115
--- /dev/null
+++ b/cli/autoconfig.go
@@ -0,0 +1,19 @@
+package cli
+
+// AutoConfigVar represents a variable given by the user when prompted during
+// auto-configuration setup of an API.
+type AutoConfigVar struct {
+	Description string        `json:"description,omitempty"`
+	Example     string        `json:"example,omitempty"`
+	Default     interface{}   `json:"default,omitempty"`
+	Enum        []interface{} `json:"enum,omitempty"`
+}
+
+// AutoConfig holds an API's automatic configuration settings for the CLI. These
+// are advertised via OpenAPI extension and picked up by the CLI to make it
+// easier to get started using an API.
+type AutoConfig struct {
+	Headers map[string]string        `json:"headers,omitempty"`
+	Prompt  map[string]AutoConfigVar `json:"prompt,omitempty"`
+	Auth    APIAuth                  `json:"auth,omitempty"`
+}
diff --git a/cli/cli.go b/cli/cli.go
index e76306e..ecf99fd 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -99,6 +99,7 @@ func Init(name string, version string) {
 	contentTypes = []contentTypeEntry{}
 	encodings = map[string]ContentEncoding{}
 	linkParsers = []LinkParser{}
+	loaders = []Loader{}
 
 	// Determine if we are using a TTY or colored output is forced-on.
 	tty = false
diff --git a/cli/interactive.go b/cli/interactive.go
index 9971d7c..079015f 100644
--- a/cli/interactive.go
+++ b/cli/interactive.go
@@ -72,33 +72,92 @@ func (a defaultAsker) askSelect(message string, options []string, def interface{
 func askBaseURI(a asker, config *APIConfig) {
 	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")
 
+	askLoadBaseAPI(a, config)
+}
+
+func askLoadBaseAPI(a asker, config *APIConfig) {
+	var auth APIAuth
+
 	dummy := &cobra.Command{}
 	if api, err := Load(config.Base, dummy); err == nil {
 		// Found an API, auto-load settings.
-		if len(api.Auth) > 0 {
-			auth := api.Auth[0]
 
-			if config.Profiles == nil {
-				config.Profiles = map[string]*APIProfile{}
+		if api.AutoConfig.Auth.Name != "" {
+			// Found auto-configuration settings.
+			fmt.Println("Found API auto-configuration, setting up default profile...")
+			ac := api.AutoConfig
+			responses := map[string]string{}
+
+			// Get inputs from the user.
+			for name, v := range ac.Prompt {
+				def := ""
+				if v.Default != nil {
+					def = fmt.Sprintf("%v", v.Default)
+				}
+
+				if len(v.Enum) > 0 {
+					enumStr := []string{}
+					for val := range v.Enum {
+						enumStr = append(enumStr, fmt.Sprintf("%v", val))
+					}
+					responses[name] = a.askSelect(name, enumStr, def, v.Description)
+				} else {
+					responses[name] = a.askInput(name, def, v.Default == nil, v.Description)
+				}
 			}
 
-			def := config.Profiles["default"]
+			// Generate params from user inputs.
+			params := map[string]string{}
+			for name, resp := range responses {
+				params[name] = resp
+			}
 
-			if def == nil {
-				def = &APIProfile{}
-				config.Profiles["default"] = def
+			for name, template := range ac.Auth.Params {
+				rendered := template
+
+				// Render by replacing `{name}` with the value.
+				for rn, rv := range responses {
+					rendered = strings.ReplaceAll(rendered, "{"+rn+"}", rv)
+				}
+
+				params[name] = rendered
 			}
 
-			if def.Auth == nil {
-				def.Auth = &APIAuth{}
+			// Setup auth for the profile based on the rendered params.
+			auth = APIAuth{
+				Name:   ac.Auth.Name,
+				Params: params,
 			}
+		}
 
-			if def.Auth.Name == "" {
-				def.Auth.Name = auth.Name
-				def.Auth.Params = map[string]string{}
-				for k, v := range auth.Params {
-					def.Auth.Params[k] = v
-				}
+		if auth.Name == "" && len(api.Auth) > 0 {
+			// No auto-configuration present or successful, so fall back to the first
+			// available defined security scheme.
+			auth = api.Auth[0]
+		}
+
+		if config.Profiles == nil {
+			config.Profiles = map[string]*APIProfile{}
+		}
+
+		// Setup the default profile, taking care not to blast away any existing
+		// custom configuration if we are just updating the values.
+		def := config.Profiles["default"]
+
+		if def == nil {
+			def = &APIProfile{}
+			config.Profiles["default"] = def
+		}
+
+		if def.Auth == nil {
+			def.Auth = &APIAuth{}
+		}
+
+		if auth.Name != "" {
+			def.Auth.Name = auth.Name
+			def.Auth.Params = map[string]string{}
+			for k, v := range auth.Params {
+				def.Auth.Params[k] = v
 			}
 		}
 	}
@@ -229,10 +288,19 @@ func askInitAPI(a asker, cmd *cobra.Command, args []string) {
 		configs[args[0]] = config
 
 		// Do an initial setup with a default profile first.
-		askBaseURI(a, config)
-		fmt.Println("Setting up a `default` profile")
-		config.Profiles["default"] = &APIProfile{}
-		askEditProfile(a, "default", config.Profiles["default"])
+		if len(args) == 1 {
+			askBaseURI(a, config)
+		} else {
+			config.Base = args[1]
+			askLoadBaseAPI(a, config)
+		}
+
+		if config.Profiles["default"] == nil {
+			fmt.Println("Setting up a `default` profile")
+			config.Profiles["default"] = &APIProfile{}
+
+			askEditProfile(a, "default", config.Profiles["default"])
+		}
 	}
 
 	for {
diff --git a/docs/README.md b/docs/README.md
index 0f429c3..1df3ae7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,6 +18,7 @@
     - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation
   - Supported formats
     - [OpenAPI 3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) and [JSON Schema](https://json-schema.org/)
+  - Automatic configuration of API auth if advertised by the API
 - Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links
 - API endpoint-based auth built-in with support for profiles:
   - HTTP Basic
diff --git a/docs/configuration.md b/docs/configuration.md
index 572168b..0621652 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -53,13 +53,15 @@ Should TTY autodetection for colored output cause any problems, you can manually
 Adding or editing an API is possible via an interactive terminal UI:
 
 ```bash
-$ restish api configure $NAME
+$ restish api configure $NAME [$BASE_URI]
 ```
 
 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`:
 
 <img alt="Screen Shot" src="https://user-images.githubusercontent.com/106826/83099522-79dd3200-a062-11ea-8a78-b03a2fecf030.png">
 
+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.
+
 Once an API is configured, you can start using it by using its short name. For example, given an API named `example`:
 
 ```bash
diff --git a/docs/openapi.md b/docs/openapi.md
index b7bdabf..3e83baa 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -34,6 +34,7 @@ Several extensions properties may be used to change the behavior of the CLI.
 | Name                | Description                                   |
 | ------------------- | --------------------------------------------- |
 | `x-cli-aliases`     | Sets up command aliases for operations.       |
+| `x-cli-config`      | Automatic CLI configuration settings.         |
 | `x-cli-description` | Provide an alternate description for the CLI. |
 | `x-cli-ignore`      | Ignore this path, operation, or parameter.    |
 | `x-cli-hidden`      | Hide this path, or operation.                 |
@@ -52,6 +53,87 @@ paths:
         - ls
 ```
 
+### AutoConfiguration
+
+The `x-cli-config` extensions allows you to use OpenAPI to tell a CLI client like Restish how to set up its configuration profiles when talking to your API, including things like which auth settings to use, prompting the user for secrets, and setting up persistent headers.
+
+The extension goes at the top level of your OpenAPI document:
+
+```yaml
+components:
+  securitySchemes:
+    default:
+      type: http
+      scheme: basic
+x-cli-config:
+  # Reference scheme by name or use CLI name (e.g. http-basic, oauth-authorization-code, etc)
+  security: default
+  headers:
+    # Optional custom header key: value pairs.
+    accept: custom/type+json
+  prompt:
+    username:
+      description: User's namee
+      example: alice
+    password:
+      description: User's password
+      example: abc123
+```
+
+Valid types for the security setting when not using a security scheme defined within the same document:
+
+| Value                      | Description                               |
+| -------------------------- | ----------------------------------------- |
+| `http-basic`               | HTTP basic auth                           |
+| `oauth-client-credentials` | OAuth2 pre-shared client key/secret (m2m) |
+| `oauth-authorization-code` | OAuth2 authorization code (user login)    |
+
+By default, all prompt variables become auth parameters of the same name. Additionally, a template system can be used to augment the value or create new params. Any value within `{...}` will get replaced by the value of the param with the given name. For example:
+
+```yaml
+x-cli-config:
+  prompt:
+    org:
+      description: Organization ID
+      example: github
+  params:
+    audience: https://example.com/{org}
+    some_static_value: foo
+```
+
+The above will prompt the user for an `org` and then fill in the params using the value from the user when creating the API configuration profile.
+
+#### Auth Parameters
+
+Each auth scheme has different built-in parameters you can prompt for or provide directly in the API. Please do not put secrets into your API description!
+
+Any additional prompt or param values you specify will be passed along when making requests for tokens.
+
+HTTP Basic:
+
+| Variable   | Type     | Description                    |
+| ---------- | -------- | ------------------------------ |
+| `username` | `string` | User's name for logging in     |
+| `password` | `string` | User's password for logging in |
+
+OAuth2 Client Credentials:
+
+| Variable        | Type     | Description                                    |
+| --------------- | -------- | ---------------------------------------------- |
+| `client_id`     | `string` | Client identifier                              |
+| `client_secret` | `string` | Client secret, do not expose this!             |
+| `token_url`     | `string` | URL to fetch new bearer tokens                 |
+| `scopes`        | `string` | Comma-separated list of scope names to request |
+
+OAuth2 Authorization Code:
+
+| Variable        | Type     | Description                                    |
+| --------------- | -------- | ---------------------------------------------- |
+| `client_id`     | `string` | Client identifier                              |
+| `authorize_url` | `string` | URL to authorize a user and get a code         |
+| `token_url`     | `string` | URL to fetch new bearer tokens                 |
+| `scopes`        | `string` | Comma-separated list of scope names to request |
+
 ### Description
 
 You can override the default description for the API, operations, and parameters easily:
diff --git a/openapi/openapi.go b/openapi/openapi.go
index ef0e6cf..b4a6dbf 100644
--- a/openapi/openapi.go
+++ b/openapi/openapi.go
@@ -2,9 +2,11 @@ package openapi
 
 import (
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/url"
+	"os"
 	"regexp"
 	"sort"
 	"strings"
@@ -38,6 +40,13 @@ const (
 	ExtHidden = "x-cli-hidden"
 )
 
+type autoConfig struct {
+	Security string                       `json:"security"`
+	Headers  map[string]string            `json:"headers,omitempty"`
+	Prompt   map[string]cli.AutoConfigVar `json:"prompt,omitempty"`
+	Params   map[string]string            `json:"params,omitempty"`
+}
+
 // Resolver is able to resolve relative URIs against a base.
 type Resolver interface {
 	Resolve(uri string) (*url.URL, error)
@@ -450,9 +459,76 @@ func loadOpenAPI3(cfg Resolver, cmd *cobra.Command, location *url.URL, resp *htt
 		Auth:       authSchemes,
 	}
 
+	if swagger.Extensions["x-cli-config"] != nil {
+		loadAutoConfig(&api, swagger)
+	}
+
 	return api, nil
 }
 
+func loadAutoConfig(api *cli.API, swagger *openapi3.Swagger) {
+	var config *autoConfig
+
+	if cfg, ok := swagger.Extensions["x-cli-config"].(json.RawMessage); ok {
+		if err := json.Unmarshal(cfg, &config); err != nil {
+			fmt.Fprintf(os.Stderr, "Unable to unmarshal x-cli-config: %v", err)
+			return
+		}
+	} else {
+		fmt.Fprintf(os.Stderr, "Unknown type for x-cli-config")
+	}
+
+	authName := config.Security
+	params := map[string]string{}
+
+	if swagger.Components.SecuritySchemes != nil {
+		ref := swagger.Components.SecuritySchemes[config.Security]
+		if ref != nil && ref.Value != nil {
+			// The config references a named security scheme.
+			scheme := ref.Value
+
+			// Conver it to the Restish security type and set some default params.
+			switch scheme.Type {
+			case "http":
+				if scheme.Scheme == "basic" {
+					authName = "http-basic"
+				}
+			case "oauth2":
+				if scheme.Flows != nil {
+					if scheme.Flows.AuthorizationCode != nil {
+						// Prefer auth code if multiple auth types are available.
+						authName = "oauth-authorization-code"
+						ac := scheme.Flows.AuthorizationCode
+						params["client_id"] = ""
+						params["authorize_url"] = ac.AuthorizationURL
+						params["token_url"] = ac.TokenURL
+					} else if scheme.Flows.ClientCredentials != nil {
+						authName = "oauth-client-credentials"
+						cc := scheme.Flows.ClientCredentials
+						params["client_id"] = ""
+						params["client_secret"] = ""
+						params["token_url"] = cc.TokenURL
+					}
+				}
+			}
+		}
+	}
+
+	// Params can override the values above if needed.
+	for k, v := range config.Params {
+		params[k] = v
+	}
+
+	api.AutoConfig = cli.AutoConfig{
+		Headers: config.Headers,
+		Prompt:  config.Prompt,
+		Auth: cli.APIAuth{
+			Name:   authName,
+			Params: params,
+		},
+	}
+}
+
 type loader struct {
 	location *url.URL
 	base     *url.URL


Test patch:
diff --git a/cli/interactive_test.go b/cli/interactive_test.go
index c7fbe13..90830a1 100644
--- a/cli/interactive_test.go
+++ b/cli/interactive_test.go
@@ -1,6 +1,8 @@
 package cli
 
 import (
+	"net/http"
+	"net/url"
 	"os"
 	"path"
 	"testing"
@@ -76,3 +78,120 @@ func TestInteractive(t *testing.T) {
 
 	askInitAPI(mock, Root, []string{"example"})
 }
+
+type testLoader struct {
+	API API
+}
+
+func (l *testLoader) LocationHints() []string {
+	return []string{"/openapi.json"}
+}
+
+func (l *testLoader) Detect(resp *http.Response) bool {
+	return true
+}
+
+func (l *testLoader) Load(entrypoint, spec url.URL, resp *http.Response) (API, error) {
+	return l.API, nil
+}
+
+func TestInteractiveAutoConfig(t *testing.T) {
+	// Remove existing config if present...
+	os.Remove(path.Join(userHomeDir(), ".test", "apis.json"))
+
+	reset(false)
+	AddLoader(&testLoader{
+		API: API{
+			Short: "Swagger Petstore",
+			Auth: []APIAuth{
+				{
+					Name: "oauth-authorization-code",
+					Params: map[string]string{
+						"client_id":     "",
+						"authorize_url": "https://example.com/authorize",
+						"token_url":     "https://example.com/token",
+					},
+				},
+			},
+			Operations: []Operation{
+				{
+					Name:         "createpets",
+					Short:        "Create a pet",
+					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",
+					Method:       "POST",
+					URITemplate:  "http://api.example.com/pets",
+					PathParams:   []*Param{},
+					QueryParams:  []*Param{},
+					HeaderParams: []*Param{},
+				},
+				{
+					Name:        "listpets",
+					Short:       "List all pets",
+					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",
+					Method:      "GET",
+					URITemplate: "http://api.example.com/pets",
+					PathParams:  []*Param{},
+					QueryParams: []*Param{
+						{
+							Type:        "integer",
+							Name:        "limit",
+							Description: "How many items to return at one time (max 100)",
+						},
+					},
+					HeaderParams: []*Param{},
+				},
+				{
+					Name:        "showpetbyid",
+					Short:       "Info for a specific pet",
+					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",
+					Method:      "GET",
+					URITemplate: "http://api.example.com/pets/{petId}",
+					PathParams: []*Param{
+						{
+							Type:        "string",
+							Name:        "petId",
+							Description: "The id of the pet to retrieve",
+						},
+					},
+					QueryParams:  []*Param{},
+					HeaderParams: []*Param{},
+				},
+			},
+			AutoConfig: AutoConfig{
+				Prompt: map[string]AutoConfigVar{
+					"client_id": {
+						Description: "Client identifier",
+						Example:     "abc123",
+					},
+				},
+				Auth: APIAuth{
+					Name: "oauth-authorization-code",
+					Params: map[string]string{
+						"client_id":     "",
+						"authorize_url": "https://example.com/authorize",
+						"token_url":     "https://example.com/token",
+					},
+				},
+			},
+		},
+	})
+	defer reset(false)
+
+	defer gock.Off()
+
+	gock.New("http://api2.example.com").Get("/").Reply(200).JSON(map[string]interface{}{
+		"Hello": "World",
+	})
+
+	gock.New("http://api2.example.com").Get("/openapi.json").Reply(200).BodyString("dummy")
+
+	mock := &mockAsker{
+		t: t,
+		responses: []string{
+			"foo",
+			"Save and exit",
+		},
+	}
+
+	askInitAPI(mock, Root, []string{"autoconfig", "http://api2.example.com"})
+}
diff --git a/openapi/openapi_test.go b/openapi/openapi_test.go
index db1cca7..5665c2e 100644
--- a/openapi/openapi_test.go
+++ b/openapi/openapi_test.go
@@ -124,6 +124,19 @@ components:
           format: int32
         message:
           type: string
+  securitySchemes:
+    default:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://example.com/authorize
+          tokenUrl: https://example.com/token
+x-cli-config:
+  security: default
+  prompt:
+    client_id:
+      description: Client identifier
+      example: abc123
 `
 
 func TestLoadOpenAPI(t *testing.T) {
@@ -139,7 +152,16 @@ func TestLoadOpenAPI(t *testing.T) {
 
 	expected := cli.API{
 		Short: "Swagger Petstore",
-		Auth:  []cli.APIAuth{},
+		Auth: []cli.APIAuth{
+			{
+				Name: "oauth-authorization-code",
+				Params: map[string]string{
+					"client_id":     "",
+					"authorize_url": "https://example.com/authorize",
+					"token_url":     "https://example.com/token",
+				},
+			},
+		},
 		Operations: []cli.Operation{
 			{
 				Name:         "createpets",
@@ -184,6 +206,22 @@ func TestLoadOpenAPI(t *testing.T) {
 				HeaderParams: []*cli.Param{},
 			},
 		},
+		AutoConfig: cli.AutoConfig{
+			Prompt: map[string]cli.AutoConfigVar{
+				"client_id": {
+					Description: "Client identifier",
+					Example:     "abc123",
+				},
+			},
+			Auth: cli.APIAuth{
+				Name: "oauth-authorization-code",
+				Params: map[string]string{
+					"client_id":     "",
+					"authorize_url": "https://example.com/authorize",
+					"token_url":     "https://example.com/token",
+				},
+			},
+		},
 	}
 
 	sort.Slice(api.Operations, func(i, j int) bool {