From f45b350236f4672024403f58214d07f9c379595c Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Thu, 16 Jul 2026 20:58:19 +0800 Subject: [PATCH 1/9] docs: improve options.go function comments for clarity Tighten and standardize the doc comments across options.go: fix grammar, use concise phrasing ('from key-value pairs', 'sets Content-Type to'), name the actual parameter where it clarifies behavior, and add doc links matching the existing style. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- options.go | 51 ++++++++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/options.go b/options.go index 75ba611..148ac73 100644 --- a/options.go +++ b/options.go @@ -48,7 +48,7 @@ type Options struct { // Option is the functional option type. type Option func(*Options) -// newDefaultOptions creates a new default HTTP options. +// newDefaultOptions creates default HTTP options. func newDefaultOptions() *Options { return &Options{ ctx: context.Background(), @@ -67,7 +67,7 @@ func parseOptions(options ...Option) *Options { // Context sets the HTTP request context. // -// For outgoing client request, the context controls the entire lifetime of +// For an outgoing client request, the context controls the entire lifetime of // a request and its response: obtaining a connection, sending the request, // and reading the response headers and body. func Context(ctx context.Context) Option { @@ -111,7 +111,7 @@ func Headers[T map[string]string | http.Header](headers T) Option { } } -// HeaderPairs sets HTTP headers formed by the mapping of key-value pairs. +// HeaderPairs sets HTTP headers from key-value pairs. // The keys should be in canonical form, as returned by // [http.CanonicalHeaderKey]. It panics if len(kv) is odd. // @@ -140,8 +140,7 @@ func HeaderPairs(kv ...string) Option { return Headers(headers) } -// Params sets the given query parameters into the URL query string. -// Two types are supported: +// Params sets the URL query string parameters. Two types are supported: // // # Type 1: map[string]string // @@ -176,7 +175,7 @@ func Params[T map[string]string | url.Values](params T) Option { } } -// ParamPairs sets the query parameters formed by the mapping of key-value pairs. +// ParamPairs sets query parameters from key-value pairs. // It panics if len(kv) is odd. // // Values with the same key will be merged into a list: @@ -202,7 +201,7 @@ func ParamPairs(kv ...string) Option { return Params(params) } -// Body sets io.Reader to hold request body. +// Body sets the request body from an [io.Reader]. func Body(body io.Reader) Option { return func(opts *Options) { opts.Body = body @@ -210,12 +209,11 @@ func Body(body io.Reader) Option { } } -// Data sets data of request body. It also deduces Content-Type based on -// input data types: +// Data sets the request body from data, deducing Content-Type by its type: // -// 1. auto deduce by [http.DetectContentType]: io.Reader, []byte -// 2. "application/json": struct, slice(except []byte), and map -// 3. "text/plain": others +// - io.Reader, []byte: detected via [http.DetectContentType] +// - struct, slice (except []byte), map: "application/json" +// - otherwise: "text/plain" func Data(data any) Option { return func(opts *Options) { opts.Data = data @@ -223,9 +221,8 @@ func Data(data any) Option { } } -// Form sets the given form values into the request body. -// It also sets the Content-Type as "application/x-www-form-urlencoded". -// Two types are supported: +// Form sets the request body from form values, and sets Content-Type to +// "application/x-www-form-urlencoded". Two types are supported: // // # Type 1: map[string]string // @@ -261,7 +258,7 @@ func Form[T map[string]string | url.Values](params T) Option { } } -// FormPairs sets form values by the mapping of key-value pairs. +// FormPairs sets form values from key-value pairs. // It panics if len(kv) is odd. // // Values with the same key will be merged into a list: @@ -287,8 +284,8 @@ func FormPairs(kv ...string) Option { return Form(form) } -// JSON marshals the given struct as JSON into the request body. -// It also sets the Content-Type as "application/json". +// JSON marshals v as JSON into the request body, and sets Content-Type to +// "application/json". func JSON(v any) Option { return func(opts *Options) { opts.JSON = v @@ -296,8 +293,8 @@ func JSON(v any) Option { } } -// Files sets files to a map of (field, fileHandler). -// It also sets the Content-Type as "multipart/form-data". +// Files sets files as a map of field to file handler, and sets Content-Type +// to "multipart/form-data". func Files(files map[string]*os.File) Option { return func(opts *Options) { if opts.Files != nil { @@ -311,21 +308,21 @@ func Files(files map[string]*os.File) Option { } } -// ToText unmarshals HTTP response body to string. +// ToText captures the response body as text into the string pointed to by v. func ToText(v *string) Option { return func(opts *Options) { opts.ToText = v } } -// ToJSON unmarshals HTTP response body to given struct as JSON. +// ToJSON decodes the response body into v as JSON. func ToJSON(v any) Option { return func(opts *Options) { opts.ToJSON = v } } -// BasicAuth is the option to implement HTTP Basic Auth. +// BasicAuth enables HTTP Basic Auth with the given username and password. func BasicAuth(username, password string) Option { return func(opts *Options) { opts.AuthInfo = &auth.AuthInfo{ @@ -343,8 +340,8 @@ func Timeout(timeout time.Duration) Option { } } -// Dump dumps outgoing client request and response to the corresponding -// input param (req or resp) if not nil. +// Dump dumps the outgoing request into req and the response into resp, +// when the corresponding pointer is non-nil. // // Refer: // - https://pkg.go.dev/net/http/httputil#DumpRequestOut @@ -356,8 +353,8 @@ func Dump(req, resp *string) Option { } } -// Interceptor prepends an interceptor to environment interceptors for current -// request only. +// Interceptor prepends an interceptor to the client interceptors for the +// current request only. func Interceptor(interceptor InterceptorFunc) Option { return func(opts *Options) { opts.Interceptor = interceptor From 7ddaf621a1aac84de2ff08d9eef4f1e409ba8046 Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Thu, 16 Jul 2026 21:05:13 +0800 Subject: [PATCH 2/9] docs: improve comments across all source files for clarity Extend the comment cleanup beyond options.go to the rest of the package: request.go, response.go, client.go, method.go, interceptor.go, and default.go. Tighten prose, fix grammar, and standardize phrasing ('wraps [http.X]', 'sends an HTTP GET request', 'sets Content-Type to'). Also correct a couple of misleading inline comments (e.g. the do() body-close note, the StatusText return). Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- client.go | 21 ++++++++++----------- default.go | 2 +- interceptor.go | 8 +++----- method.go | 8 ++++---- options.go | 2 +- request.go | 22 ++++++++++------------ response.go | 31 ++++++++++--------------------- 7 files changed, 39 insertions(+), 55 deletions(-) diff --git a/client.go b/client.go index e36046b..49043b2 100644 --- a/client.go +++ b/client.go @@ -19,14 +19,14 @@ func WithTimeout(timeout time.Duration) ClientOption { } } -// WithTransport specifies a transport for client. +// WithTransport specifies the transport for the client. func WithTransport(transport http.RoundTripper) ClientOption { return func(c *Client) { c.client.Transport = transport } } -// WithInterceptor specifies an interceptor for client. +// WithInterceptor specifies the interceptor for the client. // You can use [ChainInterceptors] to chain multiple interceptors into one. func WithInterceptor(interceptor InterceptorFunc) ClientOption { return func(c *Client) { @@ -40,7 +40,7 @@ type Client struct { interceptor InterceptorFunc } -// NewClient creates a new client to serve HTTP requests. +// NewClient creates a new Client for sending HTTP requests. func NewClient(setters ...ClientOption) *Client { client := newDefaultClient() for _, setter := range setters { @@ -49,10 +49,10 @@ func NewClient(setters ...ClientOption) *Client { return client } -// request is the common func to send an HTTP request. +// request sends an HTTP request via the client. func (c *Client) request(method, url string, opts *Options, body []byte) (*Response, error) { ctx := opts.ctx - if opts.Timeout > 0 { // ctx with timeout if specified + if opts.Timeout > 0 { // apply per-request timeout var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, opts.Timeout) defer cancel() @@ -85,8 +85,7 @@ func (c *Client) request(method, url string, opts *Options, body []byte) (*Respo // do sends an HTTP request and returns an HTTP response, following policy // (such as redirects, cookies, auth) as configured on the client. func (c *Client) do(_ context.Context, r *Request) (*Response, error) { - // If the returned error is nil, the Response will contain - // a non-nil Body which the user is expected to close. + // The response body is read and closed by newResponse. resp, err := c.client.Do(r.Request) if err != nil { return nil, err @@ -102,7 +101,7 @@ func (c *Client) do(_ context.Context, r *Request) (*Response, error) { return newResponse(resp, r.opts) } -// Get sends an HTTP request with GET method. +// Get sends an HTTP GET request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. @@ -115,7 +114,7 @@ func (c *Client) Post(url string, options ...Option) (*Response, error) { return c.callMethod(http.MethodPost, url, options...) } -// Put sends an HTTP request with PUT method. +// Put sends an HTTP PUT request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. @@ -123,7 +122,7 @@ func (c *Client) Put(url string, options ...Option) (*Response, error) { return c.callMethod(http.MethodPut, url, options...) } -// Patch sends an HTTP request with PATCH method. +// Patch sends an HTTP PATCH request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. @@ -131,7 +130,7 @@ func (c *Client) Patch(url string, options ...Option) (*Response, error) { return c.callMethod(http.MethodPatch, url, options...) } -// Delete sends an HTTP request with DELETE method. +// Delete sends an HTTP DELETE request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. diff --git a/default.go b/default.go index 9902a93..e738b28 100644 --- a/default.go +++ b/default.go @@ -29,7 +29,7 @@ func getDefaultClient() *Client { return defaultClient } -// InitDefaultClient initializes the default client with given options. +// InitDefaultClient initializes the default client with the given options. func InitDefaultClient(setters ...ClientOption) { client := getDefaultClient() for _, setter := range setters { diff --git a/interceptor.go b/interceptor.go index 7558623..151102e 100644 --- a/interceptor.go +++ b/interceptor.go @@ -4,13 +4,11 @@ import ( "context" ) -// Do is called by Interceptor to complete HTTP requests. +// Do is called by an interceptor to complete the HTTP request. type Do func(ctx context.Context, r *Request) (*Response, error) -// InterceptorFunc provides a hook to intercept the execution of an HTTP request -// invocation. When an interceptor(s) is set, requests delegates all HTTP -// client invocations to the interceptor, and it is the responsibility of the -// interceptor to call do to complete the processing of the HTTP request. +// InterceptorFunc intercepts an HTTP request. When set, the client delegates +// the request to the interceptor, which must call do to complete it. type InterceptorFunc func(ctx context.Context, r *Request, do Do) (*Response, error) // ChainInterceptors chains multiple interceptors into one. diff --git a/method.go b/method.go index 8e6cc48..e342922 100644 --- a/method.go +++ b/method.go @@ -1,6 +1,6 @@ package requests -// Get sends an HTTP request with GET method. +// Get sends an HTTP GET request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. @@ -13,7 +13,7 @@ func Post(url string, options ...Option) (*Response, error) { return getDefaultClient().Post(url, options...) } -// Put sends an HTTP request with PUT method. +// Put sends an HTTP PUT request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. @@ -21,7 +21,7 @@ func Put(url string, options ...Option) (*Response, error) { return getDefaultClient().Put(url, options...) } -// Patch sends an HTTP request with PATCH method. +// Patch sends an HTTP PATCH request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. @@ -29,7 +29,7 @@ func Patch(url string, options ...Option) (*Response, error) { return getDefaultClient().Patch(url, options...) } -// Delete sends an HTTP request with DELETE method. +// Delete sends an HTTP DELETE request. // // On error, any Response can be ignored. A non-nil Response with a // non-nil error only occurs when Response.StatusCode() is not 2xx. diff --git a/options.go b/options.go index 148ac73..67b25d6 100644 --- a/options.go +++ b/options.go @@ -12,7 +12,7 @@ import ( "github.com/Wenchy/requests/internal/auth" ) -// Options defines all optional parameters for HTTP request. +// Options defines the optional parameters for an HTTP request. type Options struct { ctx context.Context diff --git a/request.go b/request.go index c08a08e..42fb666 100644 --- a/request.go +++ b/request.go @@ -1,6 +1,6 @@ -// Package requests is an elegant and simple HTTP library for golang, built for human beings. +// Package requests is an elegant and simple HTTP library for Go, built for human beings. // -// This package mimics the implementation of the classic Python package Requests(https://requests.readthedocs.io/) +// It mimics the classic Python Requests library (https://requests.readthedocs.io/). package requests import ( @@ -16,7 +16,7 @@ import ( "github.com/Wenchy/requests/internal/auth" ) -// Request is a wrapper of http.Request. +// Request wraps [http.Request]. type Request struct { *http.Request opts *Options @@ -28,7 +28,7 @@ func (r *Request) Bytes() []byte { return r.body } -// Text parses the HTTP request body as string. +// Text returns the HTTP request body as a string. func (r *Request) Text() string { return string(r.body) } @@ -67,7 +67,7 @@ func newRequest(ctx context.Context, method, url string, opts *Options, body []b // request sends an HTTP request. func request(c *Client, method, url string, opts *Options) (*Response, error) { - // NOTE: get the body size from io.Reader. It is costy for large body. + // NOTE: reading the body into memory is costly for large bodies. body := bytes.NewBuffer(nil) if opts.Body != nil { _, err := io.Copy(body, opts.Body) @@ -79,8 +79,7 @@ func request(c *Client, method, url string, opts *Options) (*Response, error) { return c.request(method, url, opts, body.Bytes()) } -// requestData sends an HTTP request to the specified URL, with raw string -// as the request body. +// requestData sends an HTTP request with opts.Data as the body. func requestData(c *Client, method, url string, opts *Options) (*Response, error) { body := bytes.NewBuffer(nil) if opts.Data != nil { @@ -98,8 +97,7 @@ func requestData(c *Client, method, url string, opts *Options) (*Response, error return c.request(method, url, opts, body.Bytes()) } -// requestForm sends an HTTP request to the specified URL, with form's keys and -// values URL-encoded as the request body. +// requestForm sends an HTTP request with form values URL-encoded as the body. func requestForm(c *Client, method, url string, opts *Options) (*Response, error) { body := bytes.NewBuffer(nil) if opts.Form != nil { @@ -114,7 +112,7 @@ func requestForm(c *Client, method, url string, opts *Options) (*Response, error return c.request(method, url, opts, body.Bytes()) } -// requestJSON sends an HTTP request, and encode request body as json. +// requestJSON sends an HTTP request with opts.JSON encoded as JSON in the body. func requestJSON(c *Client, method, url string, opts *Options) (*Response, error) { body := bytes.NewBuffer(nil) if opts.JSON != nil { @@ -132,7 +130,7 @@ func requestJSON(c *Client, method, url string, opts *Options) (*Response, error return c.request(method, url, opts, body.Bytes()) } -// requestFiles sends an uploading request for multiple multipart-encoded files. +// requestFiles sends an HTTP request with files multipart-encoded in the body. func requestFiles(c *Client, method, url string, opts *Options) (*Response, error) { body := bytes.NewBuffer(nil) bodyWriter := multipart.NewWriter(body) @@ -182,7 +180,7 @@ var ( formContentType = "application/x-www-form-urlencoded" ) -// deduceContentTypeAndBody parses content type and request body from request data +// deduceContentTypeAndBody deduces the Content-Type and body from data. func deduceContentTypeAndBody(data any) (string, []byte, error) { if reader, ok := data.(io.Reader); ok { body, err := io.ReadAll(reader) diff --git a/response.go b/response.go index 7f95c45..d1ea48a 100644 --- a/response.go +++ b/response.go @@ -7,16 +7,15 @@ import ( "net/http" ) -// Response is a wrapper of http.Response. +// Response wraps [http.Response]. type Response struct { *http.Response body []byte // auto filled from Response.Body } -// newResponse reads and closes Response.Body. Then check the HTTP status -// in Response.StatusCode. It will return an error with status and text -// body embedded if status code is not 2xx, and none-nil response is also -// returned. +// newResponse reads and closes the response body. It returns a non-nil +// response along with an error containing the status and text body when +// the status code is not 2xx. func newResponse(resp *http.Response, opts *Options) (*Response, error) { r := &Response{ Response: resp, @@ -24,8 +23,7 @@ func newResponse(resp *http.Response, opts *Options) (*Response, error) { if err := r.readAndCloseBody(); err != nil { return nil, err } - // return error with status and text body embedded if status code - // is not 2xx, and response is also returned. + // non-2xx: return the response along with an error. if resp.StatusCode < 200 || resp.StatusCode > 299 { // TODO: only extracts 128 bytes from body. return r, errors.New(resp.Status + " " + r.Text()) @@ -41,7 +39,7 @@ func newResponse(resp *http.Response, opts *Options) (*Response, error) { return r, nil } -// readAndCloseBody drains all the HTTP response body stream and then closes it. +// readAndCloseBody drains and closes the response body. func (r *Response) readAndCloseBody() (err error) { defer func() { err1 := r.Response.Body.Close() @@ -51,9 +49,7 @@ func (r *Response) readAndCloseBody() (err error) { return err } -// StatusCode returns status code of HTTP response. -// -// NOTE: It returns -1 if response is nil. +// StatusCode returns the HTTP response status code, or -1 if the response is nil. func (r *Response) StatusCode() int { if r == nil || r.Response == nil { // return special status code -1 which is not registered with IANA. @@ -62,16 +58,9 @@ func (r *Response) StatusCode() int { return r.Response.StatusCode } -// StatusText returns a text for the HTTP status code. -// -// NOTE: -// - It returns "" if response is nil. -// - It returns the empty string if the code is unknown. -// -// e.g. "OK" +// StatusText returns the HTTP status text, or "" if the response is nil. func (r *Response) StatusText() string { if r == nil || r.Response == nil { - // return special status code -1 which is not registered with IANA. return "" } return r.Response.Status @@ -82,12 +71,12 @@ func (r *Response) Bytes() []byte { return r.body } -// Text parses the HTTP response body as string. +// Text returns the HTTP response body as a string. func (r *Response) Text() string { return string(r.body) } -// JSON decodes the HTTP response body as JSON format. +// JSON decodes the HTTP response body into v as JSON. func (r *Response) JSON(v any) error { return json.Unmarshal(r.body, v) } From a9beb4ff0042ac4d21e8277b30f0dc9a94fa3d0c Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Thu, 16 Jul 2026 21:15:54 +0800 Subject: [PATCH 3/9] docs(options): make Data comment match deduceContentTypeAndBody behavior Reflect the actual evaluation order: io.Reader is matched first (on the value as passed), non-reader pointers are dereferenced, then the []byte/struct-map-slice/default rules apply. Document the *[]byte raw case and the typed-nil-pointer footgun ((*T)(nil) is sent as ""). Co-Authored-By: Claude Opus 4.8 --- options.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/options.go b/options.go index 67b25d6..572293c 100644 --- a/options.go +++ b/options.go @@ -209,11 +209,16 @@ func Body(body io.Reader) Option { } } -// Data sets the request body from data, deducing Content-Type by its type: +// Data sets the request body from data, deducing Content-Type from its type: // -// - io.Reader, []byte: detected via [http.DetectContentType] -// - struct, slice (except []byte), map: "application/json" -// - otherwise: "text/plain" +// - io.Reader (e.g. *bytes.Buffer): read and detected via [http.DetectContentType] +// - []byte or *[]byte: detected via [http.DetectContentType] +// - struct, map, slice (except []byte): "application/json" +// - otherwise: "text/plain", formatted with %v +// +// Non-reader pointers are dereferenced before the kind is determined, so +// *struct and *[]byte behave like their pointed-to values. A typed nil +// pointer such as (*T)(nil) falls into the last case and is sent as "". func Data(data any) Option { return func(opts *Options) { opts.Data = data From 4916d40c2de345c4e2bce142c461adb88725930c Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Fri, 17 Jul 2026 14:10:37 +0800 Subject: [PATCH 4/9] fix(data): respect caller-supplied Content-Type for Data and JSON Since 13db74d, requestData unconditionally called opts.Headers.Set("Content-Type", ...), overwriting any Content-Type the caller set via Headers/HeaderPairs. JSON already had the same force-override behavior. This restores the pre-13db74d capability for Data and aligns JSON with it: Content-Type is only set when the caller has not provided one. Form and Files keep forced override, since their Content-Type carries the boundary the body actually depends on. Adds setContentTypeIfAbsent helper, documents the precedence in the Data/JSON/Form/Files option comments, and pins the behavior with TestContentTypeOverride. Co-Authored-By: Claude Opus 4.8 --- options.go | 22 +++++++++++-------- request.go | 12 ++++++++-- request_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/options.go b/options.go index 572293c..7327758 100644 --- a/options.go +++ b/options.go @@ -216,9 +216,11 @@ func Body(body io.Reader) Option { // - struct, map, slice (except []byte): "application/json" // - otherwise: "text/plain", formatted with %v // -// Non-reader pointers are dereferenced before the kind is determined, so -// *struct and *[]byte behave like their pointed-to values. A typed nil -// pointer such as (*T)(nil) falls into the last case and is sent as "". +// An explicit Content-Type set via [Headers] or [HeaderPairs] takes +// precedence over the deduced value. Non-reader pointers are dereferenced +// before the kind is determined, so *struct and *[]byte behave like their +// pointed-to values. A typed nil pointer such as (*T)(nil) falls into the +// last case and is sent as "". func Data(data any) Option { return func(opts *Options) { opts.Data = data @@ -226,8 +228,9 @@ func Data(data any) Option { } } -// Form sets the request body from form values, and sets Content-Type to -// "application/x-www-form-urlencoded". Two types are supported: +// Form sets the request body from form values, and forces Content-Type to +// "application/x-www-form-urlencoded" (overriding any caller value). Two +// types are supported: // // # Type 1: map[string]string // @@ -289,8 +292,8 @@ func FormPairs(kv ...string) Option { return Form(form) } -// JSON marshals v as JSON into the request body, and sets Content-Type to -// "application/json". +// JSON marshals v as JSON into the request body. It sets Content-Type to +// "application/json" unless the caller has already set one. func JSON(v any) Option { return func(opts *Options) { opts.JSON = v @@ -298,8 +301,9 @@ func JSON(v any) Option { } } -// Files sets files as a map of field to file handler, and sets Content-Type -// to "multipart/form-data". +// Files sets files as a map of field to file handler, and forces +// Content-Type to "multipart/form-data" with the multipart boundary +// (overriding any caller value). func Files(files map[string]*os.File) Option { return func(opts *Options) { if opts.Files != nil { diff --git a/request.go b/request.go index 42fb666..247915e 100644 --- a/request.go +++ b/request.go @@ -91,7 +91,7 @@ func requestData(c *Client, method, url string, opts *Options) (*Response, error if err != nil { return nil, err } - opts.Headers.Set("Content-Type", contentType) + setContentTypeIfAbsent(opts.Headers, contentType) } opts.Body = body return c.request(method, url, opts, body.Bytes()) @@ -125,7 +125,7 @@ func requestJSON(c *Client, method, url string, opts *Options) (*Response, error return nil, err } } - opts.Headers.Set("Content-Type", jsonContentType) + setContentTypeIfAbsent(opts.Headers, jsonContentType) opts.Body = body return c.request(method, url, opts, body.Bytes()) } @@ -180,6 +180,14 @@ var ( formContentType = "application/x-www-form-urlencoded" ) +// setContentTypeIfAbsent sets Content-Type only when the caller has not +// already provided one, so an explicit Content-Type header takes precedence. +func setContentTypeIfAbsent(h http.Header, contentType string) { + if h.Get("Content-Type") == "" { + h.Set("Content-Type", contentType) + } +} + // deduceContentTypeAndBody deduces the Content-Type and body from data. func deduceContentTypeAndBody(data any) (string, []byte, error) { if reader, ok := data.(io.Reader); ok { diff --git a/request_test.go b/request_test.go index fd97d2f..faf151a 100644 --- a/request_test.go +++ b/request_test.go @@ -951,3 +951,61 @@ func Test_deduceContentTypeAndBody(t *testing.T) { }) } } + +func TestContentTypeOverride(t *testing.T) { + var gotContentType string + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + tests := []struct { + name string + options []Option + want string + }{ + { + name: "data deduces content type", + options: []Option{Data(map[string]string{"k": "v"})}, + want: jsonContentType, + }, + { + name: "data respects caller content type", + options: []Option{ + Headers(map[string]string{"Content-Type": "application/xml"}), + Data(map[string]string{"k": "v"}), + }, + want: "application/xml", + }, + { + name: "json deduces content type", + options: []Option{JSON(map[string]string{"k": "v"})}, + want: jsonContentType, + }, + { + name: "json respects caller content type", + options: []Option{ + Headers(map[string]string{"Content-Type": "application/xml"}), + JSON(map[string]string{"k": "v"}), + }, + want: "application/xml", + }, + { + name: "form forces content type (caller ignored)", + options: []Option{ + Headers(map[string]string{"Content-Type": "application/xml"}), + Form(map[string]string{"k": "v"}), + }, + want: formContentType, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotContentType = "" + _, err := Post(testServer.URL, tt.options...) + assert.NoError(t, err) + assert.Equal(t, tt.want, gotContentType) + }) + } +} From 74c7f9135694d322325b5056a7204ead63b868f1 Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Fri, 17 Jul 2026 14:16:51 +0800 Subject: [PATCH 5/9] fix(data): force Content-Type for JSON; respect override only for Data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON, Form, and Files all produce a body of known format, so they force their Content-Type (overriding any caller value) for consistency and to prevent a caller from declaring a mismatched type. Only Data respects a caller-supplied Content-Type, since its body format is deduced (guessed) and the caller may know better. For custom JSON-based media types (e.g. application/ld+json), Data with an explicit Content-Type covers it — Data marshals structs/maps the same way JSON does. Reverts the JSON respect-override added in the previous commit; JSON keeps its pre-existing force behavior. Updates the JSON comment and TestContentTypeOverride accordingly. Co-Authored-By: Claude Opus 4.8 --- options.go | 6 ++++-- request.go | 2 +- request_test.go | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/options.go b/options.go index 7327758..942c92f 100644 --- a/options.go +++ b/options.go @@ -292,8 +292,10 @@ func FormPairs(kv ...string) Option { return Form(form) } -// JSON marshals v as JSON into the request body. It sets Content-Type to -// "application/json" unless the caller has already set one. +// JSON marshals v as JSON into the request body, and forces Content-Type to +// "application/json" (overriding any caller value). For a custom JSON-based +// media type (e.g. "application/ld+json"), use [Data] with an explicit +// Content-Type instead. func JSON(v any) Option { return func(opts *Options) { opts.JSON = v diff --git a/request.go b/request.go index 247915e..2999db4 100644 --- a/request.go +++ b/request.go @@ -125,7 +125,7 @@ func requestJSON(c *Client, method, url string, opts *Options) (*Response, error return nil, err } } - setContentTypeIfAbsent(opts.Headers, jsonContentType) + opts.Headers.Set("Content-Type", jsonContentType) opts.Body = body return c.request(method, url, opts, body.Bytes()) } diff --git a/request_test.go b/request_test.go index faf151a..29d1c66 100644 --- a/request_test.go +++ b/request_test.go @@ -979,17 +979,17 @@ func TestContentTypeOverride(t *testing.T) { want: "application/xml", }, { - name: "json deduces content type", + name: "json sets content type", options: []Option{JSON(map[string]string{"k": "v"})}, want: jsonContentType, }, { - name: "json respects caller content type", + name: "json forces content type (caller ignored)", options: []Option{ Headers(map[string]string{"Content-Type": "application/xml"}), JSON(map[string]string{"k": "v"}), }, - want: "application/xml", + want: jsonContentType, }, { name: "form forces content type (caller ignored)", From 89ac90ecef2f4bcb3548fa9dbbe3b858fe9d6595 Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Fri, 17 Jul 2026 17:37:03 +0800 Subject: [PATCH 6/9] docs(data): document two-case Content-Type handling; pin #48 equivalence Frame Data's behavior around issue #48's two cases: a caller-supplied Content-Type (via Headers/HeaderPairs) is used as-is; otherwise it is deduced from the data type. Note the deduction is a heuristic (e.g. XML is detected as text/xml, not application/xml), so set it explicitly when the exact type matters. Document Body as the raw/streamed path that sets no Content-Type, and add a README example for sending xml/octet-stream with an explicit Content-Type. Add TestDataStringVsBytesEquivalent to pin #48's requirement that Data(string) and Data([]byte) with the same content produce the same HTTP request. No behavior change. Co-Authored-By: Claude Opus 4.8 --- README.md | 21 +++++++++++++++++++++ options.go | 23 ++++++++++++++++------- request_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fef59f7..a2504d3 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,27 @@ if err != nil { 15+ lines4+ lines +### POST a raw body with an explicit Content-Type + +`Body` sends a raw/streamed body and sets no Content-Type; declare one with +`Headers` when the server expects a specific type: + +```go +// application/xml +r, err := requests.Post("http://example.com", + requests.Body(strings.NewReader(xmlStr)), + requests.Headers(map[string]string{"Content-Type": "application/xml"})) + +// application/octet-stream +r, err = requests.Post("http://example.com", + requests.Data([]byte(binaryData)), + requests.Headers(map[string]string{"Content-Type": "application/octet-stream"})) +``` + +`Data` also deduces a Content-Type from the data type when none is set, but +the deduction is a heuristic (e.g. XML is detected as `text/xml`, not +`application/xml`), so set it explicitly when the exact type matters. + ### GET a JSON object with context diff --git a/options.go b/options.go index 942c92f..958d80d 100644 --- a/options.go +++ b/options.go @@ -201,7 +201,10 @@ func ParamPairs(kv ...string) Option { return Params(params) } -// Body sets the request body from an [io.Reader]. +// Body sets the request body from an [io.Reader]. It does not set +// Content-Type and streams the reader as-is; declare a Content-Type via +// [Headers] or [HeaderPairs] when the server expects one (e.g. +// "application/xml", "text/html", "application/octet-stream"). func Body(body io.Reader) Option { return func(opts *Options) { opts.Body = body @@ -209,18 +212,24 @@ func Body(body io.Reader) Option { } } -// Data sets the request body from data, deducing Content-Type from its type: +// Data sets the request body from data. Content-Type is resolved in two +// cases (see issue #48): if the caller sets it via [Headers] or +// [HeaderPairs], that value is used as-is; otherwise it is deduced from +// data's type: // // - io.Reader (e.g. *bytes.Buffer): read and detected via [http.DetectContentType] // - []byte or *[]byte: detected via [http.DetectContentType] // - struct, map, slice (except []byte): "application/json" // - otherwise: "text/plain", formatted with %v // -// An explicit Content-Type set via [Headers] or [HeaderPairs] takes -// precedence over the deduced value. Non-reader pointers are dereferenced -// before the kind is determined, so *struct and *[]byte behave like their -// pointed-to values. A typed nil pointer such as (*T)(nil) falls into the -// last case and is sent as "". +// The deduction is a heuristic and may be imprecise — for example XML is +// detected as "text/xml" (or "text/plain" without an XML declaration), not +// "application/xml" — so set Content-Type explicitly when the exact type +// matters. +// +// Non-reader pointers are dereferenced before the kind is determined, so +// *struct and *[]byte behave like their pointed-to values. A typed nil +// pointer such as (*T)(nil) falls into the last case and is sent as "". func Data(data any) Option { return func(opts *Options) { opts.Data = data diff --git a/request_test.go b/request_test.go index 29d1c66..2758e56 100644 --- a/request_test.go +++ b/request_test.go @@ -1009,3 +1009,29 @@ func TestContentTypeOverride(t *testing.T) { }) } } + +func TestDataStringVsBytesEquivalent(t *testing.T) { + // Issue #48: Data(string) and Data([]byte) with the same content must + // produce the same HTTP request (Content-Type and body). + content := `{"group":1,"version":"v1.0"` + var gotCT string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCT = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + capture := func(opt Option) (string, string) { + gotCT, gotBody = "", nil + _, err := Post(srv.URL, opt) + assert.NoError(t, err) + return gotCT, string(gotBody) + } + + ct1, body1 := capture(Data(content)) + ct2, body2 := capture(Data([]byte(content))) + assert.Equal(t, ct1, ct2, "Content-Type differs between Data(string) and Data([]byte)") + assert.Equal(t, body1, body2, "body differs between Data(string) and Data([]byte)") +} From 908249b40bef85942c60dab007a4d1eff863df9f Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Fri, 17 Jul 2026 19:39:30 +0800 Subject: [PATCH 7/9] docs(test): use HeaderPairs for single Content-Type header Prefer HeaderPairs("Content-Type", "application/xml") over Headers(map[string]string{"Content-Type": "..."}) when setting a single Content-Type, in the README example and TestContentTypeOverride. Cleaner and consistent with the existing HeaderPairs idiom. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++--- request_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a2504d3..37c07dc 100644 --- a/README.md +++ b/README.md @@ -145,18 +145,18 @@ if err != nil { ### POST a raw body with an explicit Content-Type `Body` sends a raw/streamed body and sets no Content-Type; declare one with -`Headers` when the server expects a specific type: +`HeaderPairs` when the server expects a specific type: ```go // application/xml r, err := requests.Post("http://example.com", requests.Body(strings.NewReader(xmlStr)), - requests.Headers(map[string]string{"Content-Type": "application/xml"})) + requests.HeaderPairs("Content-Type", "application/xml")) // application/octet-stream r, err = requests.Post("http://example.com", requests.Data([]byte(binaryData)), - requests.Headers(map[string]string{"Content-Type": "application/octet-stream"})) + requests.HeaderPairs("Content-Type", "application/octet-stream")) ``` `Data` also deduces a Content-Type from the data type when none is set, but diff --git a/request_test.go b/request_test.go index 2758e56..16c1bff 100644 --- a/request_test.go +++ b/request_test.go @@ -973,7 +973,7 @@ func TestContentTypeOverride(t *testing.T) { { name: "data respects caller content type", options: []Option{ - Headers(map[string]string{"Content-Type": "application/xml"}), + HeaderPairs("Content-Type", "application/xml"), Data(map[string]string{"k": "v"}), }, want: "application/xml", @@ -986,7 +986,7 @@ func TestContentTypeOverride(t *testing.T) { { name: "json forces content type (caller ignored)", options: []Option{ - Headers(map[string]string{"Content-Type": "application/xml"}), + HeaderPairs("Content-Type", "application/xml"), JSON(map[string]string{"k": "v"}), }, want: jsonContentType, @@ -994,7 +994,7 @@ func TestContentTypeOverride(t *testing.T) { { name: "form forces content type (caller ignored)", options: []Option{ - Headers(map[string]string{"Content-Type": "application/xml"}), + HeaderPairs("Content-Type", "application/xml"), Form(map[string]string{"k": "v"}), }, want: formContentType, From 8d2ba156c609c74ef7f09c822e6d0955cc84b0bb Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Fri, 17 Jul 2026 19:54:32 +0800 Subject: [PATCH 8/9] chore: comments --- options.go | 5 ++--- request_test.go | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/options.go b/options.go index 958d80d..744a6f0 100644 --- a/options.go +++ b/options.go @@ -213,9 +213,8 @@ func Body(body io.Reader) Option { } // Data sets the request body from data. Content-Type is resolved in two -// cases (see issue #48): if the caller sets it via [Headers] or -// [HeaderPairs], that value is used as-is; otherwise it is deduced from -// data's type: +// cases: if the caller sets it via [Headers] or [HeaderPairs], that value +// is used as-is; otherwise it is deduced from data's type: // // - io.Reader (e.g. *bytes.Buffer): read and detected via [http.DetectContentType] // - []byte or *[]byte: detected via [http.DetectContentType] diff --git a/request_test.go b/request_test.go index 16c1bff..e4c337c 100644 --- a/request_test.go +++ b/request_test.go @@ -1011,23 +1011,23 @@ func TestContentTypeOverride(t *testing.T) { } func TestDataStringVsBytesEquivalent(t *testing.T) { - // Issue #48: Data(string) and Data([]byte) with the same content must + // Data(string) and Data([]byte) with the same content must // produce the same HTTP request (Content-Type and body). - content := `{"group":1,"version":"v1.0"` - var gotCT string + content := `{"group":1,"version":"v1.0"}` + var gotContentType string var gotBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotCT = r.Header.Get("Content-Type") + gotContentType = r.Header.Get("Content-Type") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) })) defer srv.Close() capture := func(opt Option) (string, string) { - gotCT, gotBody = "", nil + gotContentType, gotBody = "", nil _, err := Post(srv.URL, opt) assert.NoError(t, err) - return gotCT, string(gotBody) + return gotContentType, string(gotBody) } ct1, body1 := capture(Data(content)) From cd2516388defb46fe4e3567282f8dbe65f44d78f Mon Sep 17 00:00:00 2001 From: wenchyzhu Date: Fri, 17 Jul 2026 19:59:31 +0800 Subject: [PATCH 9/9] refactor(data): skip Content-Type deduction when caller provides it Split deduceContentTypeAndBody into dataToBody (body bytes) and deduceContentType (Content-Type), keeping deduceContentTypeAndBody as a thin wrapper for tests. requestData now produces the body unconditionally but deduces and sets Content-Type only when the caller has not set one, making the two-case behavior explicit and avoiding the deduction work when it would be discarded. No behavior change. Also drops the now-unused setContentTypeIfAbsent and renames the shadowing 'bytes' local to 'dataBytes'. Co-Authored-By: Claude Opus 4.8 --- request.go | 70 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/request.go b/request.go index 2999db4..8cf4894 100644 --- a/request.go +++ b/request.go @@ -79,19 +79,22 @@ func request(c *Client, method, url string, opts *Options) (*Response, error) { return c.request(method, url, opts, body.Bytes()) } -// requestData sends an HTTP request with opts.Data as the body. +// requestData sends an HTTP request with opts.Data as the body. It deduces +// Content-Type only when the caller has not set one. func requestData(c *Client, method, url string, opts *Options) (*Response, error) { body := bytes.NewBuffer(nil) if opts.Data != nil { - contentType, bytes, err := deduceContentTypeAndBody(opts.Data) + dataBytes, err := dataToBody(opts.Data) if err != nil { return nil, err } - _, err = body.Write(bytes) - if err != nil { + // Deduce Content-Type only when the caller has not set one. + if opts.Headers.Get("Content-Type") == "" { + opts.Headers.Set("Content-Type", deduceContentType(opts.Data, dataBytes)) + } + if _, err = body.Write(dataBytes); err != nil { return nil, err } - setContentTypeIfAbsent(opts.Headers, contentType) } opts.Body = body return c.request(method, url, opts, body.Bytes()) @@ -180,38 +183,59 @@ var ( formContentType = "application/x-www-form-urlencoded" ) -// setContentTypeIfAbsent sets Content-Type only when the caller has not -// already provided one, so an explicit Content-Type header takes precedence. -func setContentTypeIfAbsent(h http.Header, contentType string) { - if h.Get("Content-Type") == "" { - h.Set("Content-Type", contentType) - } -} - -// deduceContentTypeAndBody deduces the Content-Type and body from data. -func deduceContentTypeAndBody(data any) (string, []byte, error) { +// dataToBody encodes data into request body bytes. It is the shared body +// production used whether or not Content-Type is deduced. +func dataToBody(data any) ([]byte, error) { if reader, ok := data.(io.Reader); ok { - body, err := io.ReadAll(reader) - return http.DetectContentType(body), body, err + return io.ReadAll(reader) } bodyValue := reflect.Indirect(reflect.ValueOf(data)) // A typed nil pointer (e.g. (*MyStruct)(nil)) reaches here as a non-nil // interface but yields an invalid reflect.Value after Indirect. Guard // against it so bodyValue.Interface() below does not panic. if !bodyValue.IsValid() { - return plainTextType, fmt.Appendf(nil, "%v", data), nil + return fmt.Appendf(nil, "%v", data), nil } switch bodyValue.Kind() { case reflect.Struct, reflect.Map, reflect.Slice: - // check slice here to differentiate between any slice vs byte slice. // Assert against the (possibly dereferenced) bodyValue so that *[]byte // is treated as raw bytes instead of being JSON/base64-marshaled. if body, ok := bodyValue.Interface().([]byte); ok { - return http.DetectContentType(body), body, nil + return body, nil + } + return json.Marshal(data) + default: + return fmt.Appendf(nil, "%v", bodyValue.Interface()), nil + } +} + +// deduceContentType deduces the Content-Type for data from its type and the +// already-encoded body. It is only called when the caller has not set one. +func deduceContentType(data any, body []byte) string { + if _, ok := data.(io.Reader); ok { + return http.DetectContentType(body) + } + bodyValue := reflect.Indirect(reflect.ValueOf(data)) + if !bodyValue.IsValid() { + return plainTextType + } + switch bodyValue.Kind() { + case reflect.Struct, reflect.Map, reflect.Slice: + // []byte (and *[]byte, after Indirect) is detected as raw bytes. + if _, ok := bodyValue.Interface().([]byte); ok { + return http.DetectContentType(body) } - body, err := json.Marshal(data) - return jsonContentType, body, err + return jsonContentType default: - return plainTextType, fmt.Appendf(nil, "%v", bodyValue.Interface()), nil + return plainTextType + } +} + +// deduceContentTypeAndBody deduces the Content-Type and body from data. +func deduceContentTypeAndBody(data any) (string, []byte, error) { + body, err := dataToBody(data) + if err != nil { + return "", nil, err } + return deduceContentType(data, body), body, nil }