From 9341a9a02d723f25bbc43c69a5f3caa1a2584bf4 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Wed, 14 Jan 2026 20:17:10 -0800 Subject: [PATCH] Intelligently set content-type from body --- internal/cli/app.go | 87 +++++++------ internal/client/client.go | 13 +- internal/core/content_type.go | 211 ++++++++++++++++++++++++++++++++ internal/fetch/fetch.go | 6 +- internal/multipart/multipart.go | 192 +---------------------------- main.go | 3 +- 6 files changed, 268 insertions(+), 244 deletions(-) create mode 100644 internal/core/content_type.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 703be47..a7867fb 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -20,27 +20,28 @@ type App struct { Cfg config.Config - AWSSigv4 *aws.Config - Basic *core.KeyVal - Bearer string - BuildInfo bool - Complete string - ConfigPath string - Data io.Reader - DryRun bool - Edit bool - Form []core.KeyVal - Help bool - JSON io.Reader - Method string - Multipart []core.KeyVal - Output string - OutputDir bool - Range []string - UnixSocket string - Update bool - Version bool - XML io.Reader + AWSSigv4 *aws.Config + Basic *core.KeyVal + Bearer string + BuildInfo bool + Complete string + ConfigPath string + ContentType string + Data io.Reader + DryRun bool + Edit bool + Form []core.KeyVal + Help bool + Method string + Multipart []core.KeyVal + Output string + OutputDir bool + Range []string + UnixSocket string + Update bool + Version bool + + dataSet, jsonSet, xmlSet bool } func (a *App) PrintHelp(p *core.Printer) { @@ -277,14 +278,18 @@ func (a *App) CLI() *CLI { Description: "Send a request body", Default: "", IsSet: func() bool { - return a.Data != nil + return a.dataSet }, Fn: func(value string) error { - r, err := requestBody(value) + r, path, err := requestBody(value) if err != nil { return err } - a.Data = r + a.Data, a.ContentType, err = core.DetectContentType(r, path) + if err != nil { + return err + } + a.dataSet = true return nil }, }, @@ -489,14 +494,16 @@ func (a *App) CLI() *CLI { Description: "Send a JSON request body", Default: "", IsSet: func() bool { - return a.JSON != nil + return a.jsonSet }, Fn: func(value string) error { - r, err := requestBody(value) + r, _, err := requestBody(value) if err != nil { return err } - a.JSON = r + a.Data = r + a.ContentType = "application/json" + a.jsonSet = true return nil }, }, @@ -810,14 +817,16 @@ func (a *App) CLI() *CLI { Description: "Send an XML request body", Default: "", IsSet: func() bool { - return a.XML != nil + return a.xmlSet }, Fn: func(value string) error { - r, err := requestBody(value) + r, _, err := requestBody(value) if err != nil { return err } - a.XML = r + a.Data = r + a.ContentType = "application/xml" + a.xmlSet = true return nil }, }, @@ -825,37 +834,37 @@ func (a *App) CLI() *CLI { } } -func requestBody(value string) (io.Reader, error) { +func requestBody(value string) (io.Reader, string, error) { switch { case len(value) == 0 || value[0] != '@': - return strings.NewReader(value), nil + return strings.NewReader(value), "", nil case value == "@-": - return os.Stdin, nil + return os.Stdin, "", nil default: path := value[1:] // Expand '~' to the home directory. if len(path) >= 2 && path[0] == '~' && path[1] == os.PathSeparator { home, err := os.UserHomeDir() if err != nil { - return nil, err + return nil, "", err } path = home + path[1:] } f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { - return nil, core.FileNotExistsError(value[1:]) + return nil, "", core.FileNotExistsError(value[1:]) } - return nil, err + return nil, "", err } info, err := f.Stat() if err != nil { - return nil, err + return nil, "", err } if info.IsDir() { - return nil, fileIsDirError(value[1:]) + return nil, "", fileIsDirError(value[1:]) } - return f, nil + return f, path, nil } } diff --git a/internal/client/client.go b/internal/client/client.go index d20a292..cbb248d 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -243,18 +243,17 @@ type RequestConfig struct { AWSSigV4 *aws.Config Basic *core.KeyVal Bearer string + ContentType string Data io.Reader Form []core.KeyVal Headers []core.KeyVal HTTP core.HTTPVersion - JSON io.Reader Method string Multipart *multipart.Multipart NoEncode bool QueryParams []core.KeyVal Range []string URL *url.URL - XML io.Reader } // NewRequest returns an *http.Request given the provided configuration. @@ -279,12 +278,8 @@ func (c *Client) NewRequest(ctx context.Context, cfg RequestConfig) (*http.Reque q.Add(f.Key, f.Val) } body = strings.NewReader(q.Encode()) - case cfg.JSON != nil: - body = cfg.JSON case cfg.Multipart != nil: body = cfg.Multipart - case cfg.XML != nil: - body = cfg.XML } // If no scheme was provided, use various heuristics to choose between @@ -319,10 +314,8 @@ func (c *Client) NewRequest(ctx context.Context, cfg RequestConfig) (*http.Reque req.Header.Set("Content-Type", "application/x-www-form-urlencoded") case cfg.Multipart != nil: req.Header.Set("Content-Type", cfg.Multipart.ContentType()) - case cfg.JSON != nil: - req.Header.Set("Content-Type", "application/json") - case cfg.XML != nil: - req.Header.Set("Content-Type", "application/xml") + case cfg.ContentType != "": + req.Header.Set("Content-Type", cfg.ContentType) } // Optionally set the range header. diff --git a/internal/core/content_type.go b/internal/core/content_type.go new file mode 100644 index 0000000..6e67dec --- /dev/null +++ b/internal/core/content_type.go @@ -0,0 +1,211 @@ +package core + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +func DetectContentType(r io.Reader, filename string) (io.Reader, string, error) { + if ct := detectTypeByExtension(filename); ct != "" { + return r, ct, nil + } + + // Unable to detect MIME type by extension, try from raw bytes. + sniff := make([]byte, 512) + n, err := r.Read(sniff) + if err != nil && err != io.EOF { + return nil, "", err + } + + // Assemble the reader back together. + var out io.Reader = r + if rs, ok := r.(io.Seeker); ok && r != os.Stdin { + _, err = rs.Seek(0, 0) + if err != nil { + return nil, "", err + } + } else { + out = io.MultiReader(bytes.NewReader(sniff[:n]), r) + } + + ct := http.DetectContentType(sniff[:n]) + return out, ct, nil +} + +func detectTypeByExtension(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + return "" + } + + switch ext { + // Images + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".avif": + return "image/avif" + case ".heic", ".heif": + return "image/heif" + case ".jxl": + return "image/jxl" + case ".tif", ".tiff": + return "image/tiff" + case ".bmp": + return "image/bmp" + case ".ico": + return "image/x-icon" + case ".svg": + return "image/svg+xml" + case ".psd": + return "image/vnd.adobe.photoshop" + case ".raw", ".dng", ".nef", ".cr2", ".arw": + return "image/x-raw" + + // Video + case ".mp4": + return "video/mp4" + case ".m4v": + return "video/x-m4v" + case ".webm": + return "video/webm" + case ".mov": + return "video/quicktime" + case ".mkv": + return "video/x-matroska" + case ".avi": + return "video/x-msvideo" + case ".wmv": + return "video/x-ms-wmv" + case ".flv": + return "video/x-flv" + case ".mpeg", ".mpg": + return "video/mpeg" + case ".ogv": + return "video/ogg" + + // Audio + case ".mp3": + return "audio/mpeg" + case ".m4a": + return "audio/mp4" + case ".aac": + return "audio/aac" + case ".wav": + return "audio/wav" + case ".flac": + return "audio/flac" + case ".ogg": + return "audio/ogg" + case ".opus": + return "audio/opus" + case ".aiff", ".aif": + return "audio/aiff" + case ".mid", ".midi": + return "audio/midi" + + // Documents + case ".pdf": + return "application/pdf" + case ".txt": + return "text/plain; charset=utf-8" + case ".html", ".htm": + return "text/html; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".csv": + return "text/csv; charset=utf-8" + case ".json": + return "application/json" + case ".xml": + return "application/xml" + case ".yaml", ".yml": + return "application/yaml" + case ".md": + return "text/markdown; charset=utf-8" + case ".rtf": + return "application/rtf" + + // Office formats + case ".doc": + return "application/msword" + case ".docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case ".xls": + return "application/vnd.ms-excel" + case ".xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case ".ppt": + return "application/vnd.ms-powerpoint" + case ".pptx": + return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + + // Fonts + case ".woff": + return "font/woff" + case ".woff2": + return "font/woff2" + case ".ttf": + return "font/ttf" + case ".otf": + return "font/otf" + case ".eot": + return "application/vnd.ms-fontobject" + + // Archives + case ".zip": + return "application/zip" + case ".tar": + return "application/x-tar" + case ".gz": + return "application/gzip" + case ".tgz": + return "application/gzip" + case ".bz2": + return "application/x-bzip2" + case ".xz": + return "application/x-xz" + case ".7z": + return "application/x-7z-compressed" + case ".rar": + return "application/vnd.rar" + + // Executables / binaries + case ".exe": + return "application/vnd.microsoft.portable-executable" + case ".msi": + return "application/x-msi" + case ".deb": + return "application/vnd.debian.binary-package" + case ".rpm": + return "application/x-rpm" + + // Scripts / code + case ".js": + return "application/javascript" + case ".mjs": + return "application/javascript" + case ".ts": + return "application/typescript" + case ".go": + return "text/x-go; charset=utf-8" + case ".rs": + return "text/x-rust; charset=utf-8" + case ".py": + return "text/x-python; charset=utf-8" + case ".sh": + return "application/x-sh" + + default: + return "" + } +} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index a762ac1..ca12105 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -41,6 +41,7 @@ type Request struct { Basic *core.KeyVal Bearer string CACerts []*x509.Certificate + ContentType string Data io.Reader DNSServer *url.URL DryRun bool @@ -52,7 +53,6 @@ type Request struct { IgnoreStatus bool Image core.ImageSetting Insecure bool - JSON io.Reader NoEncode bool NoPager bool Method string @@ -69,7 +69,6 @@ type Request struct { UnixSocket string URL *url.URL Verbosity core.Verbosity - XML io.Reader } func Fetch(ctx context.Context, r *Request) int { @@ -105,18 +104,17 @@ func fetch(ctx context.Context, r *Request) (int, error) { AWSSigV4: r.AWSSigv4, Basic: r.Basic, Bearer: r.Bearer, + ContentType: r.ContentType, Data: r.Data, Form: r.Form, Headers: r.Headers, HTTP: r.HTTP, - JSON: r.JSON, Method: r.Method, Multipart: r.Multipart, NoEncode: r.NoEncode, QueryParams: r.QueryParams, Range: r.Range, URL: r.URL, - XML: r.XML, }) if err != nil { return 0, err diff --git a/internal/multipart/multipart.go b/internal/multipart/multipart.go index 5751336..08b5a2e 100644 --- a/internal/multipart/multipart.go +++ b/internal/multipart/multipart.go @@ -1,13 +1,10 @@ package multipart import ( - "bytes" "io" "mime/multipart" - "net/http" "net/textproto" "os" - "path/filepath" "strings" "github.com/ryanfowler/fetch/internal/core" @@ -72,18 +69,9 @@ func writeFilePart(mpw *multipart.Writer, key, filename string) error { } defer f.Close() - var r io.Reader = f - ct := detectTypeByExtension(filename) - if ct == "" { - // Unable to detect MIME type by extension, try from raw bytes. - sniff := make([]byte, 512) - n, err := f.Read(sniff) - if err != nil && err != io.EOF { - return err - } - - ct = http.DetectContentType(sniff[:n]) - r = io.MultiReader(bytes.NewReader(sniff[:n]), f) + r, ct, err := core.DetectContentType(f, filename) + if err != nil { + return err } headers := textproto.MIMEHeader{} @@ -98,177 +86,3 @@ func writeFilePart(mpw *multipart.Writer, key, filename string) error { _, err = io.Copy(w, r) return err } - -func detectTypeByExtension(filename string) string { - ext := strings.ToLower(filepath.Ext(filename)) - if ext == "" { - return "" - } - - switch ext { - // Images - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - case ".gif": - return "image/gif" - case ".webp": - return "image/webp" - case ".avif": - return "image/avif" - case ".heic", ".heif": - return "image/heif" - case ".jxl": - return "image/jxl" - case ".tif", ".tiff": - return "image/tiff" - case ".bmp": - return "image/bmp" - case ".ico": - return "image/x-icon" - case ".svg": - return "image/svg+xml" - case ".psd": - return "image/vnd.adobe.photoshop" - case ".raw", ".dng", ".nef", ".cr2", ".arw": - return "image/x-raw" - - // Video - case ".mp4": - return "video/mp4" - case ".m4v": - return "video/x-m4v" - case ".webm": - return "video/webm" - case ".mov": - return "video/quicktime" - case ".mkv": - return "video/x-matroska" - case ".avi": - return "video/x-msvideo" - case ".wmv": - return "video/x-ms-wmv" - case ".flv": - return "video/x-flv" - case ".mpeg", ".mpg": - return "video/mpeg" - case ".ogv": - return "video/ogg" - - // Audio - case ".mp3": - return "audio/mpeg" - case ".m4a": - return "audio/mp4" - case ".aac": - return "audio/aac" - case ".wav": - return "audio/wav" - case ".flac": - return "audio/flac" - case ".ogg": - return "audio/ogg" - case ".opus": - return "audio/opus" - case ".aiff", ".aif": - return "audio/aiff" - case ".mid", ".midi": - return "audio/midi" - - // Documents - case ".pdf": - return "application/pdf" - case ".txt": - return "text/plain; charset=utf-8" - case ".html", ".htm": - return "text/html; charset=utf-8" - case ".css": - return "text/css; charset=utf-8" - case ".csv": - return "text/csv; charset=utf-8" - case ".json": - return "application/json" - case ".xml": - return "application/xml" - case ".yaml", ".yml": - return "application/yaml" - case ".md": - return "text/markdown; charset=utf-8" - case ".rtf": - return "application/rtf" - - // Office formats - case ".doc": - return "application/msword" - case ".docx": - return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - case ".xls": - return "application/vnd.ms-excel" - case ".xlsx": - return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - case ".ppt": - return "application/vnd.ms-powerpoint" - case ".pptx": - return "application/vnd.openxmlformats-officedocument.presentationml.presentation" - - // Fonts - case ".woff": - return "font/woff" - case ".woff2": - return "font/woff2" - case ".ttf": - return "font/ttf" - case ".otf": - return "font/otf" - case ".eot": - return "application/vnd.ms-fontobject" - - // Archives - case ".zip": - return "application/zip" - case ".tar": - return "application/x-tar" - case ".gz": - return "application/gzip" - case ".tgz": - return "application/gzip" - case ".bz2": - return "application/x-bzip2" - case ".xz": - return "application/x-xz" - case ".7z": - return "application/x-7z-compressed" - case ".rar": - return "application/vnd.rar" - - // Executables / binaries - case ".exe": - return "application/vnd.microsoft.portable-executable" - case ".msi": - return "application/x-msi" - case ".deb": - return "application/vnd.debian.binary-package" - case ".rpm": - return "application/x-rpm" - - // Scripts / code - case ".js": - return "application/javascript" - case ".mjs": - return "application/javascript" - case ".ts": - return "application/typescript" - case ".go": - return "text/x-go; charset=utf-8" - case ".rs": - return "text/x-rust; charset=utf-8" - case ".py": - return "text/x-python; charset=utf-8" - case ".sh": - return "application/x-sh" - - default: - return "" - } -} diff --git a/main.go b/main.go index ceaadfe..e9e8843 100644 --- a/main.go +++ b/main.go @@ -126,6 +126,7 @@ func main() { Basic: app.Basic, Bearer: app.Bearer, CACerts: app.Cfg.CACerts, + ContentType: app.ContentType, Data: app.Data, DNSServer: app.Cfg.DNSServer, DryRun: app.DryRun, @@ -137,7 +138,6 @@ func main() { IgnoreStatus: getValue(app.Cfg.IgnoreStatus), Image: app.Cfg.Image, Insecure: getValue(app.Cfg.Insecure), - JSON: app.JSON, Method: app.Method, Multipart: multipart.NewMultipart(app.Multipart), NoEncode: getValue(app.Cfg.NoEncode), @@ -154,7 +154,6 @@ func main() { UnixSocket: app.UnixSocket, URL: app.URL, Verbosity: verbosity, - XML: app.XML, } status := fetch.Fetch(ctx, &req) os.Exit(status)