diff --git a/README.md b/README.md index fef59f7..37c07dc 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 +`HeaderPairs` when the server expects a specific type: + +```go +// application/xml +r, err := requests.Post("http://example.com", + requests.Body(strings.NewReader(xmlStr)), + requests.HeaderPairs("Content-Type", "application/xml")) + +// application/octet-stream +r, err = requests.Post("http://example.com", + requests.Data([]byte(binaryData)), + requests.HeaderPairs("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/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 75ba611..744a6f0 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 @@ -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,10 @@ 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]. 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 @@ -210,12 +212,23 @@ 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. Content-Type is resolved in two +// cases: if the caller sets it via [Headers] or [HeaderPairs], that value +// is used as-is; otherwise it is deduced from data's 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 (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 +// +// 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 @@ -223,9 +236,9 @@ 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 forces Content-Type to +// "application/x-www-form-urlencoded" (overriding any caller value). Two +// types are supported: // // # Type 1: map[string]string // @@ -261,7 +274,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 +300,10 @@ 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 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 @@ -296,8 +311,9 @@ 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 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 { @@ -311,21 +327,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 +359,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 +372,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 diff --git a/request.go b/request.go index c08a08e..8cf4894 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,27 +79,28 @@ 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. 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 } - opts.Headers.Set("Content-Type", contentType) } opts.Body = body 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 +115,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 +133,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,30 +183,59 @@ var ( formContentType = "application/x-www-form-urlencoded" ) -// deduceContentTypeAndBody parses content type and request body from request 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 } - body, err := json.Marshal(data) - return jsonContentType, body, err + return json.Marshal(data) default: - return plainTextType, fmt.Appendf(nil, "%v", bodyValue.Interface()), nil + 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) + } + return jsonContentType + default: + 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 } diff --git a/request_test.go b/request_test.go index fd97d2f..e4c337c 100644 --- a/request_test.go +++ b/request_test.go @@ -951,3 +951,87 @@ 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{ + HeaderPairs("Content-Type", "application/xml"), + Data(map[string]string{"k": "v"}), + }, + want: "application/xml", + }, + { + name: "json sets content type", + options: []Option{JSON(map[string]string{"k": "v"})}, + want: jsonContentType, + }, + { + name: "json forces content type (caller ignored)", + options: []Option{ + HeaderPairs("Content-Type", "application/xml"), + JSON(map[string]string{"k": "v"}), + }, + want: jsonContentType, + }, + { + name: "form forces content type (caller ignored)", + options: []Option{ + HeaderPairs("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) + }) + } +} + +func TestDataStringVsBytesEquivalent(t *testing.T) { + // 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 gotContentType string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + capture := func(opt Option) (string, string) { + gotContentType, gotBody = "", nil + _, err := Post(srv.URL, opt) + assert.NoError(t, err) + return gotContentType, 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)") +} 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) }