package httpc import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "time" ) // JSON bounds response memory, closes the body, and decodes only 2xx responses. // Optional headers/limits belong to this call and never alter the client. // output=nil discards a bounded stream; it does not buffer or validate JSON. // Empty successful bodies leave output unchanged. StatusError contains no body. func (c *Client) JSON(ctx context.Context, method, url string, input, output any, options ...RequestOption) error { if ctx == nil { return errors.New("httpc: nil context") } if c.closed.Load() { return ErrClosed } if err := ctx.Err(); err != nil { return err } settings := requestOptions{maxResponseBytes: c.config.MaxResponseBytes} if len(options) > 0 { var err error settings, err = jsonOptions(c.config.MaxResponseBytes, options) if err != nil { return err } } var body io.Reader if input != nil { data, err := json.Marshal(input) if err != nil { return err } body = bytes.NewReader(data) } request, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return err } if settings.headers != nil { request.Header = settings.headers } if input != nil { if _, exists := request.Header["Content-Type"]; !exists { request.Header.Set("Content-Type", "application/json") } } response, err := c.Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { drainStatusBody(response, settings.maxResponseBytes) return &StatusError{StatusCode: response.StatusCode} } if response.ContentLength > settings.maxResponseBytes { return ErrResponseTooLarge } limited := io.LimitReader(response.Body, settings.maxResponseBytes+1) if output == nil { count, err := io.Copy(io.Discard, limited) if err != nil { return err } if count > settings.maxResponseBytes { return ErrResponseTooLarge } return nil } data, err := io.ReadAll(limited) if err != nil { return err } if int64(len(data)) > settings.maxResponseBytes { return ErrResponseTooLarge } if len(bytes.TrimSpace(data)) == 0 { return nil } return json.Unmarshal(data, output) } // A known small status body may still be slow. Spend at most a short grace // period trying to retain the connection, then cancel the HTTP read. The status // remains authoritative and the deferred Close still owns body cleanup. func drainStatusBody(response *http.Response, limit int64) { if response.ContentLength < 0 || response.ContentLength > min(limit, 32<<10) { return } body, ok := response.Body.(*responseBody) if !ok { return } timer := time.AfterFunc(25*time.Millisecond, body.cancel) defer timer.Stop() drainSmallBody(response, limit) } // Keep the option builder out of the allocation path of existing plain calls. func jsonOptions(limit int64, options []RequestOption) (requestOptions, error) { settings := requestOptions{maxResponseBytes: limit} for _, option := range options { if option == nil { return requestOptions{}, errors.New("httpc: nil request option") } if err := option(&settings); err != nil { return requestOptions{}, err } } return settings, nil }