~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
143 lines · 3.2 KB Raw History
package httpjson

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
)

const (
	DefaultLimit int64 = 8 << 20

	Unlimited int64 = -1

	statusDetailLimit = 300
)

var ErrTooLarge = errors.New("httpjson: the response body is too large")

type Response struct {
	Status int

	Header http.Header

	Body []byte
}

type StatusError struct {
	Method string

	URL string

	Status int

	Body []byte
}

func (e *StatusError) HTTPStatus() int { return e.Status }

func (e *StatusError) Error() string {
	text := strings.TrimSpace(string(e.Body))
	if len(text) > statusDetailLimit {
		text = text[:statusDetailLimit] + "..."
	}
	if text == "" {
		return fmt.Sprintf("%s %s: HTTP %d", e.Method, e.URL, e.Status)
	}
	return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.URL, e.Status, text)
}

type TooLargeError struct {
	Status int

	Limit int64
}

func (e *TooLargeError) Error() string {
	return fmt.Sprintf("httpjson: the HTTP %d response is larger than %d bytes", e.Status, e.Limit)
}

func (e *TooLargeError) Is(target error) bool { return target == ErrTooLarge }

func NewRequest(ctx context.Context, method, target string, in any) (*http.Request, error) {
	var body io.Reader
	if in != nil {
		raw, err := json.Marshal(in)
		if err != nil {
			return nil, fmt.Errorf("httpjson: encoding %s %s: %w", method, target, err)
		}
		body = bytes.NewReader(raw)
	}
	req, err := http.NewRequestWithContext(ctx, method, target, body)
	if err != nil {
		return nil, err
	}
	if in != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	return req, nil
}

func Exchange(ctx context.Context, client *http.Client, req *http.Request, limit int64) (Response, error) {
	if client == nil {
		client = http.DefaultClient
	}
	if ctx != nil && ctx != req.Context() {
		req = req.WithContext(ctx)
	}
	if limit == 0 {
		limit = DefaultLimit
	}
	resp, err := client.Do(req)
	if err != nil {
		return Response{}, err
	}
	defer resp.Body.Close()
	var reader io.Reader = resp.Body
	if limit > 0 {
		reader = io.LimitReader(resp.Body, limit+1)
	}
	raw, err := io.ReadAll(reader)
	if err != nil {
		return Response{}, err
	}
	if limit > 0 && int64(len(raw)) > limit {
		return Response{}, &TooLargeError{Status: resp.StatusCode, Limit: limit}
	}
	return Response{Status: resp.StatusCode, Header: resp.Header, Body: raw}, nil
}

func Do[Out any](ctx context.Context, client *http.Client, req *http.Request) (Out, error) {
	return DoLimit[Out](ctx, client, req, DefaultLimit)
}

func DoLimit[Out any](ctx context.Context, client *http.Client, req *http.Request, limit int64) (Out, error) {
	var out Out
	resp, err := Exchange(ctx, client, req, limit)
	if err != nil {
		return out, err
	}
	if resp.Status < 200 || resp.Status > 299 {
		return out, &StatusError{Method: req.Method, URL: req.URL.Redacted(), Status: resp.Status, Body: resp.Body}
	}
	switch target := any(&out).(type) {
	case *Response:
		*target = resp
		return out, nil
	case *[]byte:
		*target = resp.Body
		return out, nil
	}
	if len(bytes.TrimSpace(resp.Body)) == 0 {
		return out, nil
	}
	if err := json.Unmarshal(resp.Body, &out); err != nil {
		return out, fmt.Errorf("httpjson: decoding %s %s: %w", req.Method, req.URL.Redacted(), err)
	}
	return out, nil
}