@@ -2,11 +2,17 @@ package cmd
22
33import (
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+
30166func 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