|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "net/url" |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + |
| 9 | + "github.com/spf13/cobra" |
| 10 | + |
| 11 | + "github.com/stripe/stripe-cli/pkg/playback" |
| 12 | + "github.com/stripe/stripe-cli/pkg/validators" |
| 13 | +) |
| 14 | + |
| 15 | +const defaultPort = 13111 |
| 16 | +const defaultWebhookPort = 13112 |
| 17 | +const endpointsDocString = ` |
| 18 | +--- Controlling the server --- |
| 19 | +You can configure the running server instance via HTTP GET endpoints (prefixed with "/playback/"). |
| 20 | +
|
| 21 | +--- List of server control endpoints --- |
| 22 | +POST /playback/mode/[mode] |
| 23 | +Sets the server mode to one of ["auto", "record", "replay"] |
| 24 | +
|
| 25 | +POST /playback/cassette/setroot?dir=[path_to_directory] |
| 26 | +Set the root directory for reading/writing cassettes. All cassette paths are relative to this directory. |
| 27 | +
|
| 28 | +POST /playback/cassette/load?filepath=[filepath] |
| 29 | +Load the cassette file at the given filepath, relative to the cassette root directory. |
| 30 | +
|
| 31 | +POST /playback/cassette/eject |
| 32 | +Eject (unload) the current cassette and do any teardown. In record mode, this writes the recorded interactions to the cassette file. |
| 33 | +` |
| 34 | + |
| 35 | +type playbackCmd struct { |
| 36 | + cmd *cobra.Command |
| 37 | + |
| 38 | + mode string |
| 39 | + |
| 40 | + apiBaseURL string |
| 41 | + filepath string |
| 42 | + cassetteDir string |
| 43 | + address string |
| 44 | + webhookURL string |
| 45 | +} |
| 46 | + |
| 47 | +func newPlaybackCmd() *playbackCmd { |
| 48 | + pc := &playbackCmd{} |
| 49 | + |
| 50 | + pc.cmd = &cobra.Command{ |
| 51 | + Hidden: true, |
| 52 | + Use: "playback", |
| 53 | + Args: validators.NoArgs, |
| 54 | + Short: "Start a `playback` server", |
| 55 | + Long: ` |
| 56 | +--- Overview --- |
| 57 | +The playback command starts a local proxy server that intercepts outgoing requests to the Stripe API. |
| 58 | +
|
| 59 | +It can also intercept incoming webhooks on /playback/webhooks. |
| 60 | +
|
| 61 | +There are three modes of operation: |
| 62 | +
|
| 63 | +"record": Any requests received are forwarded to the api.stripe.com, and the response is returned. All interactions |
| 64 | +are written to the loaded 'cassette' file for later playback in replay mode. |
| 65 | +
|
| 66 | +"replay": All received requests are terminated at the playback server, and responses are played back[1] from a cassette file. A existing cassette most be loaded. |
| 67 | +
|
| 68 | +"auto" (default): The server determines whether to run in "record" or "replay" mode on a per-cassette basis. If the cassette exists, operates in "replay" mode. If not, operates in "record" mode. |
| 69 | +
|
| 70 | +Currently, stripe playback only supports serving over HTTP. |
| 71 | +
|
| 72 | +[1]: requests are currently replayed sequentially in the same order they were recorded. |
| 73 | +` + endpointsDocString, |
| 74 | + Example: `stripe playback |
| 75 | + stripe playback --mode replay |
| 76 | + stripe playback --cassette "my_cassette.yaml"`, |
| 77 | + RunE: pc.runPlaybackCmd, |
| 78 | + } |
| 79 | + |
| 80 | + pc.cmd.Flags().StringVar(&pc.mode, "mode", "auto", "Auto: record if cassette doesn't exist, replay if exists. Record: always record/re-record. Replay: always replay.") |
| 81 | + pc.cmd.Flags().StringVar(&pc.address, "address", fmt.Sprintf("localhost:%d", defaultPort), "Address to serve on") |
| 82 | + pc.cmd.Flags().StringVar(&pc.webhookURL, "forward-to", fmt.Sprintf("http://localhost:%d", defaultWebhookPort), "URL to forward webhooks to") |
| 83 | + pc.cmd.Flags().StringVar(&pc.filepath, "cassette", "default_cassette.yaml", "The cassette file to use") |
| 84 | + pc.cmd.Flags().StringVar(&pc.cassetteDir, "cassette-root-dir", "./", "Directory to store all cassettes in. Relative cassette paths are considered relative to this directory.") |
| 85 | + |
| 86 | + // // Hidden configuration flags, useful for dev/debugging |
| 87 | + pc.cmd.Flags().StringVar(&pc.apiBaseURL, "api-base", "https://api.stripe.com", "The API base URL") |
| 88 | + pc.cmd.Flags().MarkHidden("api-base") // #nosec G104 |
| 89 | + |
| 90 | + return pc |
| 91 | +} |
| 92 | + |
| 93 | +func (pc *playbackCmd) runPlaybackCmd(cmd *cobra.Command, args []string) error { |
| 94 | + fmt.Println() |
| 95 | + fmt.Println("Setting up playback server...") |
| 96 | + fmt.Println() |
| 97 | + |
| 98 | + // --- Validate command-line args |
| 99 | + // Check that mode is valid |
| 100 | + if pc.mode != playback.Auto && pc.mode != playback.Record && pc.mode != playback.Replay { |
| 101 | + return fmt.Errorf( |
| 102 | + "\"%v\" is not a valid mode. It must be either \"%v\", \"%v\", or \"%v\"", |
| 103 | + pc.mode, playback.Auto, playback.Record, playback.Replay) |
| 104 | + } |
| 105 | + |
| 106 | + // Check that cassette root directory is valid |
| 107 | + absoluteCassetteDir, err := filepath.Abs(pc.cassetteDir) |
| 108 | + if err != nil { |
| 109 | + return fmt.Errorf("Error with --cassette-root-dir: %w", err) |
| 110 | + } |
| 111 | + |
| 112 | + cassetteDirInfo, err := os.Stat(absoluteCassetteDir) |
| 113 | + if err != nil { |
| 114 | + if os.IsNotExist(err) { |
| 115 | + return fmt.Errorf("the directory \"%v\" does not exist. Please create it, then re-run the command", absoluteCassetteDir) |
| 116 | + } |
| 117 | + return fmt.Errorf("Unexpected error when checking --cassette-root-dir: %w", err) |
| 118 | + } |
| 119 | + |
| 120 | + if !cassetteDirInfo.Mode().IsDir() { |
| 121 | + return fmt.Errorf("The provided `--cassette-root-dir` option is not a valid directory: %v", absoluteCassetteDir) |
| 122 | + } |
| 123 | + |
| 124 | + // Check webhook URL specifies a protocol |
| 125 | + parsedWhURL, err := url.Parse(pc.webhookURL) |
| 126 | + if err != nil { |
| 127 | + return fmt.Errorf("unable to parse \"%v\" as a URL. it should be a valid URL of the form [scheme]://[host]:[port]", pc.webhookURL) |
| 128 | + } |
| 129 | + |
| 130 | + if parsedWhURL.Scheme != "http" && parsedWhURL.Scheme != "https" { |
| 131 | + return fmt.Errorf("unsupported protocol scheme \"%v\". must be \"http\" or \"https\"", parsedWhURL.Scheme) |
| 132 | + } |
| 133 | + |
| 134 | + // --- Start up the playback HTTP server |
| 135 | + // TODO(DX-5702): `playback` should handle setup (and teardown) of `stripe listen` as well |
| 136 | + addressString := pc.address |
| 137 | + remoteURL := pc.apiBaseURL |
| 138 | + |
| 139 | + httpWrapper, err := playback.NewServer(remoteURL, pc.webhookURL, absoluteCassetteDir, pc.mode, pc.filepath) |
| 140 | + if err != nil { |
| 141 | + return err |
| 142 | + } |
| 143 | + |
| 144 | + server := httpWrapper.InitializeServer(addressString) |
| 145 | + go func() { |
| 146 | + err = server.ListenAndServe() |
| 147 | + fmt.Fprint(os.Stderr, err.Error()) |
| 148 | + os.Exit(1) |
| 149 | + }() |
| 150 | + |
| 151 | + // --- Print out post-startup summary on CLI |
| 152 | + fmt.Println() |
| 153 | + fmt.Println("------ Server Running ------") |
| 154 | + |
| 155 | + switch pc.mode { |
| 156 | + case playback.Record: |
| 157 | + fmt.Printf("In \"record\" mode.\n") |
| 158 | + fmt.Println("Will always record interactions, and write (or overwrite) to the given cassette filepath.") |
| 159 | + fmt.Println() |
| 160 | + case playback.Replay: |
| 161 | + fmt.Printf("In \"replay\" mode.\n") |
| 162 | + fmt.Println("Will always replay from the given cassette. Will error if loaded cassette path doesn't exist.") |
| 163 | + fmt.Println() |
| 164 | + case playback.Auto: |
| 165 | + fmt.Printf("In \"auto\" mode.\n") |
| 166 | + fmt.Println("Can both record or replay, depending on the file passed in. If exists, replays. If not, records.") |
| 167 | + fmt.Println() |
| 168 | + } |
| 169 | + |
| 170 | + fmt.Printf("Cassettes directory: \"%v\".\n", absoluteCassetteDir) |
| 171 | + fmt.Printf("Using cassette: \"%v\".\n", pc.filepath) |
| 172 | + fmt.Println() |
| 173 | + |
| 174 | + fmt.Printf("Listening via HTTP on %v\n", addressString) |
| 175 | + fmt.Println() |
| 176 | + |
| 177 | + fmt.Printf("Accepting webhooks on %v/%v\n", addressString, "playback/webhooks") |
| 178 | + fmt.Printf("Forwarding webhooks to %v\n", pc.webhookURL) |
| 179 | + fmt.Println("-----------------------------") |
| 180 | + fmt.Println() |
| 181 | + |
| 182 | + select {} |
| 183 | +} |
0 commit comments