Skip to content

Commit ae51bbc

Browse files
feat: fix edge cases for sending request data and add YAML support
1 parent f5fca80 commit ae51bbc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3361
-3267
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ dodopayments [resource] [command] [flags]
3434

3535
```sh
3636
dodopayments checkout-sessions create \
37-
--product-cart.product_id product_id \
38-
--product-cart.quantity 0
37+
--product-cart '{product_id: product_id, quantity: 0}'
3938
```
4039

4140
For details about specific commands, use the `--help` flag.

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ require (
77
github.com/charmbracelet/bubbletea v1.3.6
88
github.com/charmbracelet/lipgloss v1.1.0
99
github.com/charmbracelet/x/term v0.2.1
10-
github.com/dodopayments/dodopayments-go v1.61.5
10+
github.com/dodopayments/dodopayments-go v1.61.6
11+
github.com/goccy/go-yaml v1.18.0
1112
github.com/itchyny/json2yaml v0.1.4
1213
github.com/muesli/reflow v0.3.0
1314
github.com/tidwall/gjson v1.18.0
1415
github.com/tidwall/pretty v1.2.1
15-
github.com/tidwall/sjson v1.2.5
1616
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
1717
github.com/urfave/cli/v3 v3.3.2
1818
golang.org/x/term v0.37.0
@@ -36,6 +36,7 @@ require (
3636
github.com/russross/blackfriday/v2 v2.1.0 // indirect
3737
github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c // indirect
3838
github.com/tidwall/match v1.1.1 // indirect
39+
github.com/tidwall/sjson v1.2.5 // indirect
3940
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
4041
golang.org/x/sync v0.15.0 // indirect
4142
golang.org/x/sys v0.38.0 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHH
2222
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
2323
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2424
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
25-
github.com/dodopayments/dodopayments-go v1.61.5 h1:w6nYfiZwPZkYDJWBRkk9IouGoNFGEY3gkdX5r/thLeM=
26-
github.com/dodopayments/dodopayments-go v1.61.5/go.mod h1:hHaTVpUBY8uhDNiPcBwiEIpRJexEj8vlu7x7I0LVkDE=
25+
github.com/dodopayments/dodopayments-go v1.61.6 h1:HOu2OVSpQOdG2gWH6lYWgL3AqrA0Bm7+d0zld++cXvk=
26+
github.com/dodopayments/dodopayments-go v1.61.6/go.mod h1:hHaTVpUBY8uhDNiPcBwiEIpRJexEj8vlu7x7I0LVkDE=
2727
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2828
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
29+
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
30+
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
2931
github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8=
3032
github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI=
3133
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

internal/apiform/encoder.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package apiform
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"mime/multipart"
7+
"net/textproto"
8+
"path"
9+
"reflect"
10+
"sort"
11+
"strconv"
12+
"strings"
13+
)
14+
15+
// Marshal encodes a value as multipart form data using default settings
16+
func Marshal(value any, writer *multipart.Writer) error {
17+
e := &encoder{
18+
format: FormatRepeat,
19+
}
20+
return e.marshal(value, writer)
21+
}
22+
23+
// MarshalWithSettings encodes a value with custom array format
24+
func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error {
25+
e := &encoder{
26+
format: arrayFormat,
27+
}
28+
return e.marshal(value, writer)
29+
}
30+
31+
type encoder struct {
32+
format FormFormat
33+
}
34+
35+
func (e *encoder) marshal(value any, writer *multipart.Writer) error {
36+
val := reflect.ValueOf(value)
37+
if !val.IsValid() {
38+
return nil
39+
}
40+
return e.encodeValue("", val, writer)
41+
}
42+
43+
func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error {
44+
if !val.IsValid() {
45+
return writer.WriteField(key, "")
46+
}
47+
48+
t := val.Type()
49+
50+
if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) {
51+
return e.encodeReader(key, val, writer)
52+
}
53+
54+
switch t.Kind() {
55+
case reflect.Pointer:
56+
if val.IsNil() || !val.IsValid() {
57+
return writer.WriteField(key, "")
58+
}
59+
return e.encodeValue(key, val.Elem(), writer)
60+
61+
case reflect.Slice, reflect.Array:
62+
return e.encodeArray(key, val, writer)
63+
64+
case reflect.Map:
65+
return e.encodeMap(key, val, writer)
66+
67+
case reflect.Interface:
68+
if val.IsNil() {
69+
return writer.WriteField(key, "")
70+
}
71+
return e.encodeValue(key, val.Elem(), writer)
72+
73+
case reflect.String:
74+
return writer.WriteField(key, val.String())
75+
76+
case reflect.Bool:
77+
if val.Bool() {
78+
return writer.WriteField(key, "true")
79+
}
80+
return writer.WriteField(key, "false")
81+
82+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
83+
return writer.WriteField(key, strconv.FormatInt(val.Int(), 10))
84+
85+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
86+
return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10))
87+
88+
case reflect.Float32:
89+
return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32))
90+
91+
case reflect.Float64:
92+
return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64))
93+
94+
default:
95+
return fmt.Errorf("unknown type: %s", t.String())
96+
}
97+
}
98+
99+
func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error {
100+
if e.format == FormatComma {
101+
var values []string
102+
for i := 0; i < val.Len(); i++ {
103+
item := val.Index(i)
104+
var strValue string
105+
switch item.Kind() {
106+
case reflect.String:
107+
strValue = item.String()
108+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
109+
strValue = strconv.FormatInt(item.Int(), 10)
110+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
111+
strValue = strconv.FormatUint(item.Uint(), 10)
112+
case reflect.Float32, reflect.Float64:
113+
strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64)
114+
case reflect.Bool:
115+
strValue = strconv.FormatBool(item.Bool())
116+
default:
117+
return fmt.Errorf("comma format not supported for complex array elements")
118+
}
119+
values = append(values, strValue)
120+
}
121+
return writer.WriteField(key, strings.Join(values, ","))
122+
}
123+
124+
for i := 0; i < val.Len(); i++ {
125+
var formattedKey string
126+
switch e.format {
127+
case FormatRepeat:
128+
formattedKey = key
129+
case FormatBrackets:
130+
formattedKey = key + "[]"
131+
case FormatIndicesDots:
132+
if key == "" {
133+
formattedKey = strconv.Itoa(i)
134+
} else {
135+
formattedKey = key + "." + strconv.Itoa(i)
136+
}
137+
case FormatIndicesBrackets:
138+
if key == "" {
139+
formattedKey = strconv.Itoa(i)
140+
} else {
141+
formattedKey = key + "[" + strconv.Itoa(i) + "]"
142+
}
143+
default:
144+
return fmt.Errorf("apiform: unsupported array format")
145+
}
146+
147+
if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil {
148+
return err
149+
}
150+
}
151+
return nil
152+
}
153+
154+
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
155+
156+
func escapeQuotes(s string) string {
157+
return quoteEscaper.Replace(s)
158+
}
159+
160+
func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error {
161+
reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader)
162+
if !ok {
163+
return nil
164+
}
165+
166+
// Set defaults
167+
filename := "anonymous_file"
168+
contentType := "application/octet-stream"
169+
170+
// Get filename if available
171+
if named, ok := reader.(interface{ Filename() string }); ok {
172+
filename = named.Filename()
173+
} else if named, ok := reader.(interface{ Name() string }); ok {
174+
filename = path.Base(named.Name())
175+
}
176+
177+
// Get content type if available
178+
if typed, ok := reader.(interface{ ContentType() string }); ok {
179+
contentType = typed.ContentType()
180+
}
181+
182+
h := make(textproto.MIMEHeader)
183+
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
184+
escapeQuotes(key), escapeQuotes(filename)))
185+
h.Set("Content-Type", contentType)
186+
187+
filewriter, err := writer.CreatePart(h)
188+
if err != nil {
189+
return err
190+
}
191+
_, err = io.Copy(filewriter, reader)
192+
return err
193+
}
194+
195+
func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error {
196+
type mapPair struct {
197+
key string
198+
value reflect.Value
199+
}
200+
201+
if key != "" {
202+
key = key + "."
203+
}
204+
205+
// Collect and sort map entries for deterministic output
206+
pairs := []mapPair{}
207+
iter := val.MapRange()
208+
for iter.Next() {
209+
if iter.Key().Type().Kind() != reflect.String {
210+
return fmt.Errorf("cannot encode a map with a non string key")
211+
}
212+
pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()})
213+
}
214+
215+
sort.Slice(pairs, func(i, j int) bool {
216+
return pairs[i].key < pairs[j].key
217+
})
218+
219+
// Process sorted pairs
220+
for _, p := range pairs {
221+
if err := e.encodeValue(key+p.key, p.value, writer); err != nil {
222+
return err
223+
}
224+
}
225+
226+
return nil
227+
}

internal/apiform/form.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package apiform
2+
3+
type Marshaler interface {
4+
MarshalMultipart() ([]byte, string, error)
5+
}
6+
7+
type FormFormat int
8+
9+
const (
10+
// FormatRepeat represents arrays as repeated keys with the same value
11+
FormatRepeat FormFormat = iota
12+
// Comma-separated values 1,2,3
13+
FormatComma
14+
// FormatBrackets uses the key[] notation for arrays
15+
FormatBrackets
16+
// FormatIndicesDots uses key.0, key.1, etc. notation
17+
FormatIndicesDots
18+
// FormatIndicesBrackets uses key[0], key[1], etc. notation
19+
FormatIndicesBrackets
20+
)

0 commit comments

Comments
 (0)