Repository: danielgtaylor/restish
Pull request #32 (2021-02-22 13:39:06)

Add support for client side TLS
This PR adds two main changes:
#### 1. Implement variable expansion in server URLs when determining the basePath
This was an outstanding TODO item in the code. 

It will use all `enum` values, or the `default` value if no `enum` is given. The openapi spec mandates that either of these are set.

#### 2. Support for TLS client certificates
This adds a TLS section to the API configuration object. It allows specifying InsecureSkipVerify, an additional CA cert, and client certs.
Client certs are not added as an API authentication method, since they are often used in together with these other authentication methods.

Global CLI options have been added to overwrite the API specific options and to be able to use them during API setup. The values used during initial API setup will be written to the config.

An additional CLI wizard/asker is also provided.
In order not to overload the wizard, we do not ask for TLS parameters during initial API configuration, instead relying on the global config flags. Similarly, the asker is only rendered if we detect that the current TLS parameters are not empty. This can of course easily be changed if you feel this does not provide the right UX.

As part of this change, a small refactoring on the CLI flags handling has been done: a secondary flagset with fixed global flags is created. This flagset can be parsed before API discovery, without getting in the way of cobra. I think this provides an elegant way to deal with the fact that some flags, such as `rsh-verbose` and the TLS flags, need to be parsed before the full flagset is known.

Both these features fill a real need for our usage.
I can split them in 2 PRs if you prefer.


Patch:
diff --git a/cli/apiconfig.go b/cli/apiconfig.go
index 8be88da..a7718f4 100644
--- a/cli/apiconfig.go
+++ b/cli/apiconfig.go
@@ -19,6 +19,14 @@ type APIAuth struct {
 	Params map[string]string `json:"params"`
 }
 
+// TLSConfig contains the TLS setup for the HTTP client
+type TLSConfig struct {
+	InsecureSkipVerify bool   `json:"insecure" mapstructure:"insecure"`
+	Cert               string `json:"cert"`
+	Key                string `json:"key"`
+	CACert             string `json:"ca_cert" mapstructure:"ca_cert"`
+}
+
 // APIProfile contains account-specific API information
 type APIProfile struct {
 	Headers map[string]string `json:"headers,omitempty"`
@@ -33,6 +41,7 @@ type APIConfig struct {
 	Base      string                 `json:"base"`
 	SpecFiles []string               `json:"spec_files,omitempty" mapstructure:"spec_files,omitempty"`
 	Profiles  map[string]*APIProfile `json:"profiles,omitempty" mapstructure:",omitempty"`
+	TLS       *TLSConfig             `json:"tls,omitempty" mapstructure:",omitempty"`
 }
 
 // Save the API configuration to disk.
diff --git a/cli/cli.go b/cli/cli.go
index 92bf237..b1bbdd0 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -19,12 +19,18 @@ import (
 	"github.com/mattn/go-colorable"
 	"github.com/mattn/go-isatty"
 	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
 )
 
 // Root command (entrypoint) of the CLI.
 var Root *cobra.Command
 
+// GlobalFlags contains all the fixed up front flags
+// This allows us to parse them before we hand over control
+// to cobra
+var GlobalFlags *pflag.FlagSet
+
 // Cache is used to store temporary data between runs.
 var Cache *viper.Viper
 
@@ -135,16 +141,6 @@ func Init(name string, version string) {
 		PersistentPreRun: func(cmd *cobra.Command, args []string) {
 			settings := viper.AllSettings()
 			LogDebug("Configuration: %v", settings)
-
-			if viper.GetBool("rsh-insecure") {
-				if t, ok := http.DefaultTransport.(*http.Transport); ok {
-					LogWarning("Disabling TLS security checks")
-					if t.TLSClientConfig == nil {
-						t.TLSClientConfig = &tls.Config{}
-					}
-					t.TLSClientConfig.InsecureSkipVerify = true
-				}
-			}
 		},
 		Run: func(cmd *cobra.Command, args []string) {
 			generic(http.MethodGet, args[0], args[1:])
@@ -317,6 +313,14 @@ Not after (expires): %s (%s)
 	}
 	Root.AddCommand(linkCmd)
 
+	GlobalFlags = pflag.NewFlagSet("eager-flags", pflag.ContinueOnError)
+	GlobalFlags.ParseErrorsWhitelist.UnknownFlags = true
+	// GlobalFlags are 'hidden', don't print anything on error
+	GlobalFlags.Usage = func() {}
+	// Ensure parsing doesn't stop if the help flag is set
+	// (help seems to be special cased from ParseErrorsWhitelist.UnknownFlags)
+	GlobalFlags.BoolP("help", "h", false, "")
+
 	AddGlobalFlag("rsh-verbose", "v", "Enable verbose log output", false, false)
 	AddGlobalFlag("rsh-output-format", "o", "Output format [auto, json, yaml]", "auto", false)
 	AddGlobalFlag("rsh-filter", "f", "Filter / project results using JMESPath Plus", "", false)
@@ -328,6 +332,9 @@ Not after (expires): %s (%s)
 	AddGlobalFlag("rsh-profile", "p", "API auth profile", "default", false)
 	AddGlobalFlag("rsh-no-cache", "", "Disable HTTP cache", false, false)
 	AddGlobalFlag("rsh-insecure", "", "Disable SSL verification", false, false)
+	AddGlobalFlag("rsh-client-cert", "", "Path to a PEM encoded client certificate", "", false)
+	AddGlobalFlag("rsh-client-key", "", "Path to a PEM encoded private key", "", false)
+	AddGlobalFlag("rsh-ca-cert", "", "Path to a PEM encoded CA cert", "", false)
 
 	initAPIConfig()
 }
@@ -427,15 +434,34 @@ func Run() {
 		if !strings.HasPrefix(arg, "-") {
 			args = append(args, arg)
 		}
+	}
 
-		// Try to detect if verbose mode is enabled via a flag.
-		if arg == "-v" || arg == "--rsh-verbose" {
-			enableVerbose = true
+	// Because we may be doing HTTP calls before cobra has parsed the flags
+	// we parse the GlobalFlags here and already set some config values
+	// to ensure they are available
+	if err := GlobalFlags.Parse(os.Args[1:]); err != nil {
+		if err != pflag.ErrHelp {
+			panic(err)
 		}
 	}
+	if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose {
+		viper.Set("rsh-verbose", true)
+	}
+	if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure {
+		viper.Set("rsh-insecure", true)
+	}
+	if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" {
+		viper.Set("rsh-client-cert", cert)
+	}
+	if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" {
+		viper.Set("rsh-client-key", key)
+	}
+	if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" {
+		viper.Set("rsh-ca-cert", caCert)
+	}
 
-	// Now that flags are parsed we can enable verbose mode if requested.
-	if enableVerbose || viper.GetBool("rsh-verbose") {
+	// Now that global flags are parsed we can enable verbose mode if requested.
+	if viper.GetBool("rsh-verbose") {
 		enableVerbose = true
 	}
 
diff --git a/cli/flag.go b/cli/flag.go
index 90fb0dc..721d4b9 100644
--- a/cli/flag.go
+++ b/cli/flag.go
@@ -16,20 +16,25 @@ func AddGlobalFlag(name, short, description string, defaultValue interface{}, mu
 	case bool:
 		if multi {
 			flags.BoolSliceP(name, short, viper.Get(name).([]bool), description)
+			GlobalFlags.BoolSliceP(name, short, viper.Get(name).([]bool), description)
 		} else {
 			flags.BoolP(name, short, viper.GetBool(name), description)
+			GlobalFlags.BoolP(name, short, viper.GetBool(name), description)
 		}
 	case int, int16, int32, int64, uint16, uint32, uint64:
 		if multi {
 			flags.IntSliceP(name, short, viper.Get(name).([]int), description)
+			GlobalFlags.IntSliceP(name, short, viper.Get(name).([]int), description)
 		} else {
 			flags.IntP(name, short, viper.GetInt(name), description)
+			GlobalFlags.IntP(name, short, viper.GetInt(name), description)
 		}
 	case float32, float64:
 		if multi {
 			panic(fmt.Errorf("unsupported float slice param"))
 		} else {
 			flags.Float64P(name, short, viper.GetFloat64(name), description)
+			GlobalFlags.Float64P(name, short, viper.GetFloat64(name), description)
 		}
 	default:
 		if multi {
@@ -40,8 +45,10 @@ func AddGlobalFlag(name, short, description string, defaultValue interface{}, mu
 				viper.Set(name, v)
 			}
 			flags.StringSliceP(name, short, v.([]string), description)
+			GlobalFlags.StringSliceP(name, short, v.([]string), description)
 		} else {
 			flags.StringP(name, short, fmt.Sprintf("%v", viper.Get(name)), description)
+			GlobalFlags.StringP(name, short, fmt.Sprintf("%v", viper.Get(name)), description)
 		}
 	}
 
diff --git a/cli/interactive.go b/cli/interactive.go
index 5edc661..c3ac832 100644
--- a/cli/interactive.go
+++ b/cli/interactive.go
@@ -8,6 +8,7 @@ import (
 	"github.com/AlecAivazis/survey/v2"
 	"github.com/AlecAivazis/survey/v2/terminal"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
 )
 
 var surveyOpts = []survey.AskOpt{}
@@ -291,11 +292,83 @@ func askAddProfile(a asker, config *APIConfig) {
 	askEditProfile(a, name, config.Profiles[name])
 }
 
+func askTLSConfig(a asker, config *APIConfig) {
+	if config.TLS == nil {
+		config.TLS = &TLSConfig{}
+	}
+
+	for {
+		options := make([]string, 0, 7)
+
+		if config.TLS.InsecureSkipVerify {
+			options = append(options, "Delete insecure")
+		} else {
+			options = append(options, "Set insecure")
+		}
+
+		if config.TLS.Cert == "" {
+			options = append(options, "Set certificate")
+		} else {
+			options = append(options, "Edit certificate", "Delete certificate")
+		}
+
+		if config.TLS.Key == "" {
+			options = append(options, "Set key")
+		} else {
+			options = append(options, "Edit key", "Delete key")
+		}
+
+		if config.TLS.CACert == "" {
+			options = append(options, "Set CA certificate")
+		} else {
+			options = append(options, "Edit CA certificate", "Delete CA certificate")
+		}
+
+		options = append(options, "Finished with TLS configuration")
+
+		switch choice := a.askSelect("Select TLS configuration options", options, nil, ""); choice {
+		case "Delete insecure":
+			config.TLS.InsecureSkipVerify = false
+		case "Set insecure":
+			config.TLS.InsecureSkipVerify = true
+		case "Set certificate":
+			config.TLS.Cert = a.askInput("Certificate path", "", false, "")
+		case "Edit certificate":
+			config.TLS.Cert = a.askInput("Certificate path", config.TLS.Cert, false, "")
+		case "Delete certificate":
+			config.TLS.Cert = ""
+		case "Set key":
+			config.TLS.Key = a.askInput("Key path", "", false, "")
+		case "Edit key":
+			config.TLS.Key = a.askInput("Key path", config.TLS.Key, false, "")
+		case "Delete key":
+			config.TLS.Key = ""
+		case "Set CA certificate":
+			config.TLS.CACert = a.askInput("CA Certificate path", "", false, "")
+		case "Edit CA certificate":
+			config.TLS.CACert = a.askInput("CA Certificate path", config.TLS.CACert, false, "")
+		case "Delete CA certificate":
+			config.TLS.CACert = ""
+		case "Finished with TLS configuration":
+			return
+		}
+	}
+}
+
 func askInitAPI(a asker, cmd *cobra.Command, args []string) {
 	var config *APIConfig = configs[args[0]]
 
 	if config == nil {
-		config = &APIConfig{name: args[0], Profiles: map[string]*APIProfile{}}
+		config = &APIConfig{
+			name:     args[0],
+			Profiles: map[string]*APIProfile{},
+			TLS: &TLSConfig{
+				InsecureSkipVerify: viper.GetBool("rsh-insecure"),
+				Cert:               viper.GetString("rsh-client-cert"),
+				Key:                viper.GetString("rsh-client-key"),
+				CACert:             viper.GetString("rsh-ca-cert"),
+			},
+		}
 		configs[args[0]] = config
 
 		// Do an initial setup with a default profile first.
@@ -324,6 +397,10 @@ func askInitAPI(a asker, cmd *cobra.Command, args []string) {
 			options = append(options, "Edit profile "+k)
 		}
 
+		if (config.TLS != nil) && (*config.TLS != TLSConfig{}) {
+			options = append(options, "Edit TLS configuration")
+		}
+
 		options = append(options, "Save and exit")
 
 		choice := a.askSelect("Select option", options, nil, "")
@@ -336,6 +413,8 @@ func askInitAPI(a asker, cmd *cobra.Command, args []string) {
 		case strings.HasPrefix(choice, "Edit profile"):
 			profile := strings.SplitN(choice, " ", 3)[2]
 			askEditProfile(a, profile, config.Profiles[profile])
+		case choice == "Edit TLS configuration":
+			askTLSConfig(a, config)
 		case choice == "Save and exit":
 			config.Save()
 			return
diff --git a/cli/request.go b/cli/request.go
index f187f1b..d03d54a 100644
--- a/cli/request.go
+++ b/cli/request.go
@@ -1,6 +1,8 @@
 package cli
 
 import (
+	"crypto/tls"
+	"crypto/x509"
 	"fmt"
 	"io/ioutil"
 	"net/http"
@@ -161,6 +163,58 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e
 		}
 	}
 
+	// The assumption is that all Transport implementations eventually use the
+	// default HTTP transport.
+	// We can therefore inject the TLS config once here, along with all the other
+	// config options, instead of modifying all the places where Transports are
+	// created
+	LogDebug("Adding TLS configuration")
+	if t, ok := http.DefaultTransport.(*http.Transport); ok {
+		if t.TLSClientConfig == nil {
+			t.TLSClientConfig = &tls.Config{}
+		}
+		if config.TLS == nil {
+			config.TLS = &TLSConfig{}
+		}
+
+		// CLI flags overwrite profile options
+		if viper.GetBool("rsh-insecure") {
+			config.TLS.InsecureSkipVerify = true
+		}
+		if cert := viper.GetString("rsh-client-cert"); cert != "" {
+			config.TLS.Cert = cert
+		}
+		if key := viper.GetString("rsh-client-key"); key != "" {
+			config.TLS.Key = key
+		}
+		if caCert := viper.GetString("rsh-ca-cert"); caCert != "" {
+			config.TLS.CACert = caCert
+		}
+
+		if config.TLS.InsecureSkipVerify {
+			LogWarning("Disabling TLS security checks")
+			t.TLSClientConfig.InsecureSkipVerify = config.TLS.InsecureSkipVerify
+		}
+		if config.TLS.Cert != "" {
+			cert, err := tls.LoadX509KeyPair(config.TLS.Cert, config.TLS.Key)
+			if err != nil {
+				return nil, err
+			}
+			t.TLSClientConfig.Certificates = append(t.TLSClientConfig.Certificates, cert)
+		}
+		if config.TLS.CACert != "" {
+			caCert, err := ioutil.ReadFile(config.TLS.CACert)
+			if err != nil {
+				return nil, err
+			}
+			systemCerts := BestEffortSystemCertPool()
+			if !systemCerts.AppendCertsFromPEM(caCert) {
+				return nil, fmt.Errorf("Failed to append CACert %s RootCA list", config.TLS.CACert)
+			}
+			t.TLSClientConfig.RootCAs = systemCerts
+		}
+	}
+
 	if log {
 		LogDebugRequest(req)
 	}
@@ -348,3 +402,12 @@ func MakeRequestAndFormat(req *http.Request) {
 		panic(err)
 	}
 }
+
+// BestEffortSystemCertPool returns system cert pool as best effort, otherwise an empty cert pool
+func BestEffortSystemCertPool() *x509.CertPool {
+	rootCAs, _ := x509.SystemCertPool()
+	if rootCAs == nil {
+		return x509.NewCertPool()
+	}
+	return rootCAs
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index 855e665..65129e4 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -15,19 +15,21 @@ Global configuration affects all commands and can be set in one of three ways, g
 
 The global options in addition to `--help` and `--version` are:
 
-| Argument                    | Env Var             | Example           | Description                                                                      |
-| --------------------------- | ------------------- | ----------------- | -------------------------------------------------------------------------------- |
-| `-f`, `--rsh-filter`        | `RSH_FILTER`        | `body.users[].id` | [JMESPath Plus](https://github.com/danielgtaylor/go-jmespath-plus#readme) filter |
-| `-H`, `--rsh-header`        | `RSH_HEADER`        | `Version:2020-05` | Set a header name/value                                                          |
-| `--rsh-insecure`            | `RSH_INSECURE`      |                   | Disable TLS certificate checks                                                   |
-| `--rsh-no-cache`            | `RSH_NO_CACHE`      |                   | Disable HTTP caching                                                             |
-| `--rsh-no-paginate`         | `RSH_NO_PAGINATE`   |                   | Disable automatic `next` link pagination                                         |
-| `-o`, `--rsh-output-format` | `RSH_OUTPUT_FORMAT` | `json`            | [Output format](/output.md), defaults to `auto`                                  |
-| `-p`, `--rsh-profile`       | `RSH_PROFILE`       | `testing`         | Auth profile name, defaults to `default`                                         |
-| `-q`, `--rsh-query`         | `RSH_QUERY`         | `search=foo`      | Set a query parameter                                                            |
-| `-r`, `--rsh-raw`           | `RSH_RAW`           |                   | Raw output for shell processing                                                  |
-| `-s`, `--rsh-server`        | `RSH_SERVER`        | `https://foo.com` | Override API server base URL                                                     |
-| `-v`, `--rsh-verbose`       | `RSH_VERBOSE`       |                   | Enable verbose output                                                            |
+| Argument                    | Env Var             | Example             | Description                                                                      |
+| --------------------------- | ------------------- | ------------------- | -------------------------------------------------------------------------------- |
+| `-f`, `--rsh-filter`        | `RSH_FILTER`        | `body.users[].id`   | [JMESPath Plus](https://github.com/danielgtaylor/go-jmespath-plus#readme) filter |
+| `-H`, `--rsh-header`        | `RSH_HEADER`        | `Version:2020-05`   | Set a header name/value                                                          |
+| `--rsh-insecure`            | `RSH_INSECURE`      |                     | Disable TLS certificate checks                                                   |
+| `--rsh-client-cert`         | `RSH_CLIENT_CERT`   | `/etc/ssl/cert.pem` | Path to a PEM encoded client certificate                                         |
+| `--rsh-client-key`          | `RSH_CLIENT_KEY`    | `/etc/ssl/key.pem`  | Path to a PEM encoded private key                                                |
+| `--rsh-ca-cert`             | `RSH_CA_CERT`       | `/etc/ssl/ca.pem`   | Path to a PEM encoded CA certificate                                             |
+| `--rsh-no-paginate`         | `RSH_NO_PAGINATE`   |                     | Disable automatic `next` link pagination                                         |
+| `-o`, `--rsh-output-format` | `RSH_OUTPUT_FORMAT` | `json`              | [Output format](/output.md), defaults to `auto`                                  |
+| `-p`, `--rsh-profile`       | `RSH_PROFILE`       | `testing`           | Auth profile name, defaults to `default`                                         |
+| `-q`, `--rsh-query`         | `RSH_QUERY`         | `search=foo`        | Set a query parameter                                                            |
+| `-r`, `--rsh-raw`           | `RSH_RAW`           |                     | Raw output for shell processing                                                  |
+| `-s`, `--rsh-server`        | `RSH_SERVER`        | `https://foo.com`   | Override API server base URL                                                     |
+| `-v`, `--rsh-verbose`       | `RSH_VERBOSE`       |                     | Enable verbose output                                                            |
 
 Configuration file keys are the same as long-form arguments without the `--` prefix.
 
diff --git a/openapi/openapi.go b/openapi/openapi.go
index 77d1840..bb40158 100644
--- a/openapi/openapi.go
+++ b/openapi/openapi.go
@@ -341,6 +341,61 @@ func openapiOperation(cmd *cobra.Command, method string, uriTemplate *url.URL, p
 	}
 }
 
+// getBasePath returns the basePath to which the operation paths need to be appended (if any)
+// It assumes the open-api description has been validated before: the casts should always succeed
+// if the description adheres to the openapi spec schema.
+func getBasePath(location *url.URL, servers openapi3.Servers) (string, error) {
+	prefix := fmt.Sprintf("%s://%s", location.Scheme, location.Host)
+
+	for _, s := range servers {
+		// Interprete all operation paths as relative to the provided location
+		if strings.HasPrefix(s.URL, "/") {
+			return s.URL, nil
+		}
+
+		// localhost special casing?
+
+		// Create a list with all possible parametrised server names
+		endpoints := []string{s.URL}
+		for k, v := range s.Variables {
+			key := fmt.Sprintf("{%s}", k)
+			if len(v.Enum) == 0 {
+				for i := range endpoints {
+					endpoints[i] = strings.ReplaceAll(
+						endpoints[i],
+						key,
+						v.Default.(string),
+					)
+				}
+			} else {
+				nEndpoints := make([]string, len(v.Enum)*len(endpoints))
+				for j := range v.Enum {
+					val := v.Enum[j].(string)
+					for i := range endpoints {
+						nEndpoints[i+j*len(endpoints)] = strings.ReplaceAll(
+							endpoints[i],
+							key,
+							val,
+						)
+					}
+				}
+				endpoints = nEndpoints
+			}
+		}
+
+		for i := range endpoints {
+			if strings.HasPrefix(endpoints[i], prefix) {
+				base, err := url.Parse(endpoints[i])
+				if err != nil {
+					return "", err
+				}
+				return base.Path, nil
+			}
+		}
+	}
+	return "", nil
+}
+
 func loadOpenAPI3(cfg Resolver, cmd *cobra.Command, location *url.URL, resp *http.Response) (cli.API, error) {
 	loader := openapi3.NewSwaggerLoader()
 
@@ -356,19 +411,9 @@ func loadOpenAPI3(cfg Resolver, cmd *cobra.Command, location *url.URL, resp *htt
 	// spew.Dump(swagger)
 
 	// See if this server has any base path prefix we need to account for.
-	// TODO: handle variables in the server path?
-	basePath := ""
-	prefix := location.Scheme + "://" + location.Host
-	for _, s := range swagger.Servers {
-		if strings.HasPrefix(s.URL, "/") {
-			basePath = s.URL
-		} else if strings.HasPrefix(s.URL, prefix) {
-			base, err := url.Parse(s.URL)
-			if err != nil {
-				return cli.API{}, err
-			}
-			basePath = base.Path
-		}
+	basePath, err := getBasePath(location, swagger.Servers)
+	if err != nil {
+		return cli.API{}, err
 	}
 
 	operations := []cli.Operation{}


Test patch:
diff --git a/openapi/openapi_test.go b/openapi/openapi_test.go
index 7edc86d..fd28624 100644
--- a/openapi/openapi_test.go
+++ b/openapi/openapi_test.go
@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"github.com/danielgtaylor/restish/cli"
+	"github.com/getkin/kin-openapi/openapi3"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -233,3 +234,131 @@ func TestLoadOpenAPI(t *testing.T) {
 
 	assert.Equal(t, expected, api)
 }
+
+func TestGetBasePath(t *testing.T) {
+	cases := []struct {
+		name     string
+		location *url.URL
+		servers  openapi3.Servers
+		output   string
+		hasError bool
+	}{
+		{
+			name:     "Should return location if server is only a path",
+			location: parseURL("http://localhost:12345/api"),
+			servers:  openapi3.Servers{{URL: "/api"}},
+			output:   "/api",
+		},
+		{
+			name:     "Should return the empty string if no matches can be found",
+			location: parseURL("http://localhost:1245"),
+			servers:  openapi3.Servers{{URL: "http://my-api.foo.bar/foo"}},
+			output:   "",
+		},
+		{
+			name:     "Should return the prefix of the matched URL 1",
+			location: parseURL("http://my-api.foo.bar"),
+			servers:  openapi3.Servers{{URL: "http://my-api.foo.bar/mount/api"}},
+			output:   "/mount/api",
+		},
+		{
+			name:     "Should return the prefix of the matched URL 2",
+			location: parseURL("http://my-api.foo.bar/mount/api"),
+			servers:  openapi3.Servers{{URL: "http://my-api.foo.bar/mount/api"}},
+			output:   "/mount/api",
+		},
+		{
+			name:     "Should use default value for expanded url parameter 1",
+			location: parseURL("http://my-api.foo.bar"),
+			servers: openapi3.Servers{
+				{
+					URL: "http://my-api.foo.bar/{mount}/api",
+					Variables: map[string]*openapi3.ServerVariable{
+						"mount": {Default: "point"},
+					},
+				},
+			},
+			output: "/point/api",
+		},
+		{
+			name:     "Should use default value for expanded url parameter 2",
+			location: parseURL("http://my-api.foo.bar"),
+			servers: openapi3.Servers{
+				{URL: "http://my-api.some.other.domain:12456"},
+				{
+					URL: "http://my-api.foo.bar/{mount}/api",
+					Variables: map[string]*openapi3.ServerVariable{
+						"mount": {Default: "point"},
+					},
+				},
+			},
+			output: "/point/api",
+		},
+		{
+			name:     "Should match with enum values over default for expanded url parameter 1",
+			location: parseURL("http://my-api.foo.bar"),
+			servers: openapi3.Servers{
+				{
+					URL: "http://my-api.foo.bar/{mount}/api",
+					Variables: map[string]*openapi3.ServerVariable{
+						"mount": {Default: "point", Enum: []interface{}{"vec", "point"}},
+					},
+				},
+			},
+			output: "/vec/api",
+		},
+		{
+			name:     "Should match with enum values over default for expanded url parameter 2",
+			location: parseURL("http://my-api.foo.bar"),
+			servers: openapi3.Servers{
+				{URL: "http://my-api.some.other.domain:12456"},
+				{
+					URL: "http://my-api.foo.bar/{mount}/api",
+					Variables: map[string]*openapi3.ServerVariable{
+						"mount": {Default: "point", Enum: []interface{}{"vec", "point"}},
+					},
+				},
+			},
+			output: "/vec/api",
+		},
+		{
+			name:     "Should match against all expanded parameters",
+			location: parseURL("http://ppmy-api.foo.bar/vec"),
+			servers: openapi3.Servers{
+				{
+					URL: "http://{env}my-api.foo.bar/{mount}/api",
+					Variables: map[string]*openapi3.ServerVariable{
+						"env":   {Default: "pp"},
+						"mount": {Default: "point", Enum: []interface{}{"vec", "point"}},
+					},
+				},
+			},
+			output: "/vec/api",
+		},
+		{
+			name:     "Should return an error if the openapi server can't be parsed",
+			location: parseURL("http://localhost"),
+			servers:  openapi3.Servers{{URL: "http://localhost@1224:foo"}},
+			hasError: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			output, err := getBasePath(tc.location, tc.servers)
+			if !tc.hasError {
+				if assert.NoError(t, err) {
+					assert.Equal(t, tc.output, output)
+				}
+			} else {
+				assert.Error(t, err)
+			}
+		})
+	}
+}
+
+// parseURL parses the input as a URL ignoring any errors
+func parseURL(s string) *url.URL {
+	output, _ := url.Parse(s)
+	return output
+}