Skip to content

Commit 0b83900

Browse files
Merge branch 'master' into vcheung-20260326-plugin-update-config
2 parents 7944cc8 + 42d6b05 commit 0b83900

12 files changed

Lines changed: 1179 additions & 63 deletions

File tree

api/openapi-spec/spec3.cli.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

api/openapi-spec/spec3.cli.preview.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

pkg/cmd/resource/operation.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package resource
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"net/http"
67
"os"
@@ -48,22 +49,36 @@ func (oc *OperationCmd) runOperationCmd(cmd *cobra.Command, args []string) error
4849
return err
4950
}
5051

51-
apiKey, err := oc.Profile.GetAPIKey(oc.Livemode)
52-
if err != nil {
53-
return err
54-
}
52+
apiKey, apiKeyErr := oc.Profile.GetAPIKey(oc.Livemode)
5553

5654
path := formatURL(oc.Path, args)
5755
requestParams := make(map[string]interface{})
5856
oc.addStringRequestParams(requestParams)
5957
oc.addIntRequestParams(requestParams)
6058
oc.addBoolRequestParams(requestParams)
6159

62-
err = oc.addArrayRequestParams(requestParams)
63-
if err != nil {
60+
if err := oc.addArrayRequestParams(requestParams); err != nil {
6461
return err
6562
}
6663

64+
if oc.DryRun {
65+
dryRunKey := apiKey
66+
if apiKeyErr != nil {
67+
dryRunKey = ""
68+
}
69+
output, err := oc.BuildDryRunOutput(dryRunKey, oc.APIBaseURL, path, &oc.Parameters, requestParams)
70+
if err != nil {
71+
return err
72+
}
73+
b, _ := json.MarshalIndent(output, "", " ")
74+
fmt.Fprintln(cmd.OutOrStdout(), string(b))
75+
return nil
76+
}
77+
78+
if apiKeyErr != nil {
79+
return apiKeyErr
80+
}
81+
6782
if oc.HTTPVerb == http.MethodDelete {
6883
// display account information and confirm whether user wants to proceed
6984
var mode = "Test"
@@ -99,7 +114,7 @@ func (oc *OperationCmd) runOperationCmd(cmd *cobra.Command, args []string) error
99114
return err
100115
}
101116
// else
102-
_, err = oc.MakeRequest(cmd.Context(), apiKey, path, &oc.Parameters, requestParams, false, nil)
117+
_, err := oc.MakeRequest(cmd.Context(), apiKey, path, &oc.Parameters, requestParams, false, nil)
103118
return err
104119
}
105120

pkg/cmd/resource/operation_test.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package resource
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
7+
"fmt"
58
"io"
69
"net/http"
710
"net/http/httptest"
@@ -13,6 +16,7 @@ import (
1316
"github.com/stretchr/testify/require"
1417

1518
"github.com/stripe/stripe-cli/pkg/config"
19+
"github.com/stripe/stripe-cli/pkg/requests"
1620
)
1721

1822
func TestNewOperationCmd(t *testing.T) {
@@ -164,6 +168,206 @@ func TestRunOperationCmd_NoAPIKey(t *testing.T) {
164168
require.Error(t, err, "your API key has not been configured. Use `stripe login` to set your API key")
165169
}
166170

171+
func TestRunOperationCmd_DryRun(t *testing.T) {
172+
serverCalled := false
173+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174+
serverCalled = true
175+
w.WriteHeader(http.StatusOK)
176+
}))
177+
defer ts.Close()
178+
179+
viper.Reset()
180+
181+
parentCmd := &cobra.Command{Annotations: make(map[string]string)}
182+
profile := config.Profile{APIKey: "sk_test_1234567890abcdef"}
183+
oc := NewOperationCmd(parentCmd, "foo", "/v1/bars/{id}", http.MethodPost, map[string]string{
184+
"param1": "string",
185+
}, map[string][]string{}, &config.Config{Profile: profile}, false, "")
186+
oc.APIBaseURL = ts.URL
187+
188+
var buf bytes.Buffer
189+
oc.Cmd.SetOut(&buf)
190+
oc.Cmd.Flags().Set("param1", "value1")
191+
oc.Cmd.Flags().Set("dry-run", "true")
192+
193+
err := oc.runOperationCmd(oc.Cmd, []string{"bar_123"})
194+
195+
require.NoError(t, err)
196+
require.False(t, serverCalled, "HTTP server should not be called during dry-run")
197+
198+
var result requests.DryRunOutput
199+
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
200+
// "sk_test_1234567890abcdef" (24 chars) redacts to "sk_test_************cdef"
201+
require.Equal(t, requests.DryRunOutput{DryRun: requests.DryRunDetails{
202+
Method: "POST",
203+
URL: ts.URL + "/v1/bars/bar_123",
204+
Params: map[string]interface{}{"param1": "value1"},
205+
Headers: map[string]string{
206+
"Authorization": "Bearer sk_test_************cdef",
207+
"Content-Type": "application/x-www-form-urlencoded",
208+
},
209+
}}, result)
210+
}
211+
212+
func TestRunOperationCmd_DryRun_NoAPIKey(t *testing.T) {
213+
viper.Reset()
214+
215+
parentCmd := &cobra.Command{Annotations: make(map[string]string)}
216+
oc := NewOperationCmd(parentCmd, "foo", "/v1/bars/{id}", http.MethodPost, map[string]string{}, map[string][]string{}, &config.Config{}, false, "")
217+
218+
var buf bytes.Buffer
219+
oc.Cmd.SetOut(&buf)
220+
oc.Cmd.Flags().Set("dry-run", "true")
221+
222+
err := oc.runOperationCmd(oc.Cmd, []string{"bar_123"})
223+
224+
require.NoError(t, err, "dry-run should succeed even without an API key")
225+
226+
var result requests.DryRunOutput
227+
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
228+
require.Equal(t, requests.DryRunOutput{DryRun: requests.DryRunDetails{
229+
Method: "POST",
230+
URL: "https://api.stripe.com/v1/bars/bar_123",
231+
Params: map[string]interface{}{},
232+
Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
233+
}}, result)
234+
}
235+
236+
// assertDryRunParityV1 checks that structured dry-run params are semantically
237+
// consistent with the URL-encoded body received by a test server.
238+
// One-directional: every param in dry-run must appear in the server request.
239+
func assertDryRunParityV1(t *testing.T, serverBody []byte, dryRunParams map[string]interface{}) {
240+
t.Helper()
241+
serverVals, err := url.ParseQuery(string(serverBody))
242+
require.NoError(t, err)
243+
flattenAndAssert(t, serverVals, dryRunParams, "")
244+
}
245+
246+
func flattenAndAssert(t *testing.T, serverVals url.Values, params map[string]interface{}, prefix string) {
247+
t.Helper()
248+
for k, v := range params {
249+
fullKey := k
250+
if prefix != "" {
251+
fullKey = prefix + "[" + k + "]"
252+
}
253+
switch val := v.(type) {
254+
case string:
255+
require.Equal(t, val, serverVals.Get(fullKey), "param %q mismatch", fullKey)
256+
case map[string]interface{}:
257+
flattenAndAssert(t, serverVals, val, fullKey)
258+
case []interface{}:
259+
for _, item := range val {
260+
require.Contains(t, serverVals[fullKey+"[]"], fmt.Sprint(item))
261+
}
262+
default:
263+
require.Equal(t, fmt.Sprint(val), serverVals.Get(fullKey), "param %q mismatch", fullKey)
264+
}
265+
}
266+
}
267+
268+
func TestRunOperationCmd_DryRunParity_V1(t *testing.T) {
269+
var capturedPath string
270+
var capturedBody []byte
271+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
272+
capturedPath = r.URL.Path
273+
capturedBody, _ = io.ReadAll(r.Body)
274+
w.WriteHeader(http.StatusOK)
275+
}))
276+
defer ts.Close()
277+
278+
viper.Reset()
279+
profile := config.Profile{APIKey: "sk_test_1234"}
280+
propFlags := map[string]string{
281+
"param1": "string",
282+
"int-param": "integer",
283+
"arr-param": "array",
284+
}
285+
286+
newOC := func(dryRun bool) (*OperationCmd, *cobra.Command) {
287+
parentCmd := &cobra.Command{Annotations: make(map[string]string)}
288+
oc := NewOperationCmd(parentCmd, "foo", "/v1/bars/{id}", http.MethodPost,
289+
propFlags, map[string][]string{}, &config.Config{Profile: profile}, false, "")
290+
oc.APIBaseURL = ts.URL
291+
oc.Cmd.Flags().Set("param1", "value1")
292+
oc.Cmd.Flags().Set("int-param", "42")
293+
oc.Cmd.Flags().Set("arr-param", "x")
294+
oc.Cmd.Flags().Set("arr-param", "y")
295+
oc.Cmd.Flags().Set("data", "metadata[env]=staging")
296+
oc.Cmd.Flags().Set("data", "metadata[version]=2")
297+
if dryRun {
298+
oc.Cmd.Flags().Set("dry-run", "true")
299+
}
300+
parentCmd.SetArgs([]string{"foo", "bar_123"})
301+
return oc, parentCmd
302+
}
303+
304+
// --- LIVE RUN ---
305+
_, liveCmd := newOC(false)
306+
require.NoError(t, liveCmd.ExecuteContext(t.Context()))
307+
308+
// --- DRY-RUN ---
309+
dryOC, dryCmd := newOC(true)
310+
var buf bytes.Buffer
311+
dryOC.Cmd.SetOut(&buf)
312+
require.NoError(t, dryCmd.ExecuteContext(t.Context()))
313+
314+
var dryOut requests.DryRunOutput
315+
require.NoError(t, json.Unmarshal(buf.Bytes(), &dryOut))
316+
317+
require.Equal(t, "/v1/bars/bar_123", capturedPath)
318+
require.Contains(t, dryOut.DryRun.URL, "/v1/bars/bar_123")
319+
assertDryRunParityV1(t, capturedBody, dryOut.DryRun.Params)
320+
}
321+
322+
func TestRunOperationCmd_DryRunParity_V2(t *testing.T) {
323+
var capturedBody []byte
324+
var capturedQuery string
325+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326+
capturedBody, _ = io.ReadAll(r.Body)
327+
capturedQuery = r.URL.RawQuery
328+
w.WriteHeader(http.StatusOK)
329+
}))
330+
defer ts.Close()
331+
332+
viper.Reset()
333+
profile := config.Profile{APIKey: "sk_test_1234"}
334+
jsonData := `{"event_name": "foo", "value": 100}`
335+
336+
newOC := func(dryRun bool) (*OperationCmd, *cobra.Command) {
337+
parentCmd := &cobra.Command{Annotations: make(map[string]string)}
338+
oc := NewOperationCmd(parentCmd, "create", "/v2/billing/meter_events",
339+
http.MethodPost, map[string]string{}, map[string][]string{},
340+
&config.Config{Profile: profile}, false, "")
341+
oc.APIBaseURL = ts.URL
342+
oc.Cmd.Flags().Set("data", jsonData)
343+
if dryRun {
344+
oc.Cmd.Flags().Set("dry-run", "true")
345+
}
346+
parentCmd.SetArgs([]string{"create"})
347+
return oc, parentCmd
348+
}
349+
350+
// --- LIVE RUN ---
351+
_, liveCmd := newOC(false)
352+
require.NoError(t, liveCmd.ExecuteContext(t.Context()))
353+
354+
// --- DRY-RUN ---
355+
dryOC, dryCmd := newOC(true)
356+
var buf bytes.Buffer
357+
dryOC.Cmd.SetOut(&buf)
358+
require.NoError(t, dryCmd.ExecuteContext(t.Context()))
359+
360+
var dryOut requests.DryRunOutput
361+
require.NoError(t, json.Unmarshal(buf.Bytes(), &dryOut))
362+
363+
require.Equal(t, "", capturedQuery)
364+
var liveParams map[string]interface{}
365+
require.NoError(t, json.Unmarshal(capturedBody, &liveParams))
366+
367+
require.Equal(t, liveParams, dryOut.DryRun.Params)
368+
require.NotContains(t, dryOut.DryRun.URL, "?")
369+
}
370+
167371
func TestConstructParamFromDot(t *testing.T) {
168372
param := constructParamFromDot("shipping.address.line1")
169373
require.Equal(t, "shipping[address][line1]", param)

0 commit comments

Comments
 (0)