Skip to content

Commit 49ca642

Browse files
feat!: add support for passing files as parameters
1 parent 7c4554a commit 49ca642

2 files changed

Lines changed: 389 additions & 3 deletions

File tree

pkg/cmd/flagoptions.go

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ package cmd
22

33
import (
44
"bytes"
5+
"encoding/base64"
56
"encoding/json"
67
"fmt"
78
"io"
9+
"maps"
810
"mime/multipart"
11+
"net/http"
912
"os"
13+
"reflect"
14+
"strings"
15+
"unicode/utf8"
1016

1117
"github.com/beeper/desktop-api-cli/internal/apiform"
1218
"github.com/beeper/desktop-api-cli/internal/apiquery"
@@ -27,6 +33,136 @@ const (
2733
ApplicationOctetStream
2834
)
2935

36+
func embedFiles(obj any) (any, error) {
37+
v := reflect.ValueOf(obj)
38+
result, err := embedFilesValue(v)
39+
if err != nil {
40+
return nil, err
41+
}
42+
return result.Interface(), nil
43+
}
44+
45+
// Replace "@file.txt" with the file's contents inside a value
46+
func embedFilesValue(v reflect.Value) (reflect.Value, error) {
47+
// Unwrap interface values to get the concrete type
48+
if v.Kind() == reflect.Interface {
49+
if v.IsNil() {
50+
return v, nil
51+
}
52+
v = v.Elem()
53+
}
54+
55+
switch v.Kind() {
56+
case reflect.Map:
57+
if v.Len() == 0 {
58+
return v, nil
59+
}
60+
result := reflect.MakeMap(v.Type())
61+
iter := v.MapRange()
62+
for iter.Next() {
63+
key := iter.Key()
64+
val := iter.Value()
65+
newVal, err := embedFilesValue(val)
66+
if err != nil {
67+
return reflect.Value{}, err
68+
}
69+
result.SetMapIndex(key, newVal)
70+
}
71+
return result, nil
72+
73+
case reflect.Slice, reflect.Array:
74+
if v.Len() == 0 {
75+
return v, nil
76+
}
77+
result := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
78+
for i := 0; i < v.Len(); i++ {
79+
newVal, err := embedFilesValue(v.Index(i))
80+
if err != nil {
81+
return reflect.Value{}, err
82+
}
83+
result.Index(i).Set(newVal)
84+
}
85+
return result, nil
86+
87+
case reflect.String:
88+
s := v.String()
89+
90+
if literal, ok := strings.CutPrefix(s, "\\@"); ok {
91+
// Allow for escaped @ signs if you don't want them to be treated as files
92+
return reflect.ValueOf("@" + literal), nil
93+
} else if filename, ok := strings.CutPrefix(s, "@data://"); ok {
94+
// The "@data://" prefix is for files you explicitly want to upload
95+
// as base64-encoded (even if the file itself is plain text)
96+
content, err := os.ReadFile(filename)
97+
if err != nil {
98+
return v, err
99+
}
100+
return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
101+
} else if filename, ok := strings.CutPrefix(s, "@file://"); ok {
102+
// The "@file://" prefix is for files that you explicitly want to
103+
// upload as a string literal with backslash escapes (not base64
104+
// encoded)
105+
content, err := os.ReadFile(filename)
106+
if err != nil {
107+
return v, err
108+
}
109+
return reflect.ValueOf(string(content)), nil
110+
} else if filename, ok := strings.CutPrefix(s, "@"); ok {
111+
content, err := os.ReadFile(filename)
112+
if err != nil {
113+
// If the string is "@username", it's probably supposed to be a
114+
// string literal and not a file reference. However, if the
115+
// string looks like "@file.txt" or "@/tmp/file", then it's
116+
// probably supposed to be a file.
117+
probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/")
118+
if probablyFile {
119+
// Give a useful error message if the user tried to upload a
120+
// file, but the file couldn't be read (e.g. mistyped
121+
// filename or permission error)
122+
return v, err
123+
}
124+
// Fall back to the raw value if the user provided something
125+
// like "@username" that's not intended to be a file.
126+
return v, nil
127+
}
128+
// If the file looks like a plain text UTF8 file format, then use the contents directly.
129+
if isUTF8TextFile(content) {
130+
return reflect.ValueOf(string(content)), nil
131+
}
132+
// Otherwise it's a binary file, so encode it with base64
133+
return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil
134+
}
135+
return v, nil
136+
137+
default:
138+
return v, nil
139+
}
140+
}
141+
142+
// Guess whether a file's contents are binary (e.g. a .jpg or .mp3), as opposed
143+
// to plain text (e.g. .txt or .md).
144+
func isUTF8TextFile(content []byte) bool {
145+
// Go's DetectContentType follows https://mimesniff.spec.whatwg.org/ and
146+
// these are the sniffable content types that are plain text:
147+
textTypes := []string{
148+
"text/",
149+
"application/json",
150+
"application/xml",
151+
"application/javascript",
152+
"application/x-javascript",
153+
"application/ecmascript",
154+
"application/x-ecmascript",
155+
}
156+
157+
contentType := http.DetectContentType(content)
158+
for _, prefix := range textTypes {
159+
if strings.HasPrefix(contentType, prefix) {
160+
return utf8.Valid(content)
161+
}
162+
}
163+
return false
164+
}
165+
30166
func flagOptions(
31167
cmd *cli.Command,
32168
nestedFormat apiquery.NestedQueryFormat,
@@ -55,9 +191,7 @@ func flagOptions(
55191
if err := yaml.Unmarshal(pipeData, &bodyData); err == nil {
56192
if bodyMap, ok := bodyData.(map[string]any); ok {
57193
if flagMap, ok := flagContents.Body.(map[string]any); ok {
58-
for k, v := range flagMap {
59-
bodyMap[k] = v
60-
}
194+
maps.Copy(bodyMap, flagMap)
61195
} else {
62196
bodyData = flagContents.Body
63197
}
@@ -70,6 +204,22 @@ func flagOptions(
70204
bodyData = flagContents.Body
71205
}
72206

207+
// Embed files passed as "@file.jpg" in the request body, headers, and query:
208+
bodyData, err := embedFiles(bodyData)
209+
if err != nil {
210+
return nil, err
211+
}
212+
if headersWithFiles, err := embedFiles(flagContents.Headers); err != nil {
213+
return nil, err
214+
} else {
215+
flagContents.Headers = headersWithFiles.(map[string]any)
216+
}
217+
if queriesWithFiles, err := embedFiles(flagContents.Queries); err != nil {
218+
return nil, err
219+
} else {
220+
flagContents.Queries = queriesWithFiles.(map[string]any)
221+
}
222+
73223
querySettings := apiquery.QuerySettings{
74224
NestedFormat: nestedFormat,
75225
ArrayFormat: arrayFormat,

0 commit comments

Comments
 (0)