github.com/chelnak/go-gh@v0.0.2/internal/api/http.go (about)

     1  package api
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io"
     7  	"net/http"
     8  	"os"
     9  	"path/filepath"
    10  	"regexp"
    11  	"runtime/debug"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/chelnak/go-gh/pkg/api"
    16  	"github.com/henvic/httpretty"
    17  )
    18  
    19  const (
    20  	accept          = "Accept"
    21  	authorization   = "Authorization"
    22  	contentType     = "Content-Type"
    23  	defaultHostname = "github.com"
    24  	jsonContentType = "application/json; charset=utf-8"
    25  	modulePath      = "github.com/chelnak/go-gh"
    26  	timeZone        = "Time-Zone"
    27  	userAgent       = "User-Agent"
    28  )
    29  
    30  var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
    31  var timeZoneNames = map[int]string{
    32  	-39600: "Pacific/Niue",
    33  	-36000: "Pacific/Honolulu",
    34  	-34200: "Pacific/Marquesas",
    35  	-32400: "America/Anchorage",
    36  	-28800: "America/Los_Angeles",
    37  	-25200: "America/Chihuahua",
    38  	-21600: "America/Chicago",
    39  	-18000: "America/Bogota",
    40  	-14400: "America/Caracas",
    41  	-12600: "America/St_Johns",
    42  	-10800: "America/Argentina/Buenos_Aires",
    43  	-7200:  "Atlantic/South_Georgia",
    44  	-3600:  "Atlantic/Cape_Verde",
    45  	0:      "Europe/London",
    46  	3600:   "Europe/Amsterdam",
    47  	7200:   "Europe/Athens",
    48  	10800:  "Europe/Istanbul",
    49  	12600:  "Asia/Tehran",
    50  	14400:  "Asia/Dubai",
    51  	16200:  "Asia/Kabul",
    52  	18000:  "Asia/Tashkent",
    53  	19800:  "Asia/Kolkata",
    54  	20700:  "Asia/Kathmandu",
    55  	21600:  "Asia/Dhaka",
    56  	23400:  "Asia/Rangoon",
    57  	25200:  "Asia/Bangkok",
    58  	28800:  "Asia/Manila",
    59  	31500:  "Australia/Eucla",
    60  	32400:  "Asia/Tokyo",
    61  	34200:  "Australia/Darwin",
    62  	36000:  "Australia/Brisbane",
    63  	37800:  "Australia/Adelaide",
    64  	39600:  "Pacific/Guadalcanal",
    65  	43200:  "Pacific/Nauru",
    66  	46800:  "Pacific/Auckland",
    67  	49500:  "Pacific/Chatham",
    68  	50400:  "Pacific/Kiritimati",
    69  }
    70  
    71  func newHTTPClient(opts *api.ClientOptions) http.Client {
    72  	if opts == nil {
    73  		opts = &api.ClientOptions{}
    74  	}
    75  
    76  	transport := http.DefaultTransport
    77  	if opts.Transport != nil {
    78  		transport = opts.Transport
    79  	}
    80  
    81  	transport = newHeaderRoundTripper(opts.AuthToken, opts.Headers, transport)
    82  
    83  	if opts.Log != nil {
    84  		logger := &httpretty.Logger{
    85  			Time:            true,
    86  			TLS:             false,
    87  			Colors:          false,
    88  			RequestHeader:   true,
    89  			RequestBody:     true,
    90  			ResponseHeader:  true,
    91  			ResponseBody:    true,
    92  			Formatters:      []httpretty.Formatter{&httpretty.JSONFormatter{}},
    93  			MaxResponseBody: 10000,
    94  		}
    95  		logger.SetOutput(opts.Log)
    96  		logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
    97  			return !inspectableMIMEType(h.Get(contentType)), nil
    98  		})
    99  		transport = logger.RoundTripper(transport)
   100  	}
   101  
   102  	if opts.EnableCache {
   103  		if opts.CacheDir == "" {
   104  			opts.CacheDir = filepath.Join(os.TempDir(), "gh-cli-cache")
   105  		}
   106  		if opts.CacheTTL == 0 {
   107  			opts.CacheTTL = time.Hour * 24
   108  		}
   109  		c := cache{dir: opts.CacheDir, ttl: opts.CacheTTL}
   110  		transport = c.RoundTripper(transport)
   111  	}
   112  
   113  	return http.Client{Transport: transport, Timeout: opts.Timeout}
   114  }
   115  
   116  func handleHTTPError(resp *http.Response) error {
   117  	httpError := api.HTTPError{
   118  		StatusCode:  resp.StatusCode,
   119  		RequestURL:  resp.Request.URL,
   120  		OAuthScopes: resp.Header.Get("X-Oauth-Scopes"),
   121  	}
   122  
   123  	if !jsonTypeRE.MatchString(resp.Header.Get(contentType)) {
   124  		httpError.Message = resp.Status
   125  		return httpError
   126  	}
   127  
   128  	body, err := io.ReadAll(resp.Body)
   129  	if err != nil {
   130  		httpError.Message = err.Error()
   131  		return httpError
   132  	}
   133  
   134  	var parsedBody struct {
   135  		Message string `json:"message"`
   136  		Errors  []json.RawMessage
   137  	}
   138  	if err := json.Unmarshal(body, &parsedBody); err != nil {
   139  		return httpError
   140  	}
   141  
   142  	var messages []string
   143  	if parsedBody.Message != "" {
   144  		messages = append(messages, parsedBody.Message)
   145  	}
   146  	for _, raw := range parsedBody.Errors {
   147  		switch raw[0] {
   148  		case '"':
   149  			var errString string
   150  			_ = json.Unmarshal(raw, &errString)
   151  			messages = append(messages, errString)
   152  			httpError.Errors = append(httpError.Errors, api.HttpErrorItem{Message: errString})
   153  		case '{':
   154  			var errInfo api.HttpErrorItem
   155  			_ = json.Unmarshal(raw, &errInfo)
   156  			msg := errInfo.Message
   157  			if errInfo.Code != "" && errInfo.Code != "custom" {
   158  				msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code))
   159  			}
   160  			if msg != "" {
   161  				messages = append(messages, msg)
   162  			}
   163  			httpError.Errors = append(httpError.Errors, errInfo)
   164  		}
   165  	}
   166  	httpError.Message = strings.Join(messages, "\n")
   167  
   168  	return httpError
   169  }
   170  
   171  // Convert common error codes to human readable messages
   172  // See https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors for more details.
   173  func errorCodeToMessage(code string) string {
   174  	switch code {
   175  	case "missing", "missing_field":
   176  		return "is missing"
   177  	case "invalid", "unprocessable":
   178  		return "is invalid"
   179  	case "already_exists":
   180  		return "already exists"
   181  	default:
   182  		return code
   183  	}
   184  }
   185  
   186  func inspectableMIMEType(t string) bool {
   187  	return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t)
   188  }
   189  
   190  func isEnterprise(host string) bool {
   191  	return host != defaultHostname
   192  }
   193  
   194  type headerRoundTripper struct {
   195  	headers map[string]string
   196  	rt      http.RoundTripper
   197  }
   198  
   199  func newHeaderRoundTripper(authToken string, headers map[string]string, rt http.RoundTripper) http.RoundTripper {
   200  	if headers == nil {
   201  		headers = map[string]string{}
   202  	}
   203  	if headers[contentType] == "" {
   204  		headers[contentType] = jsonContentType
   205  	}
   206  	if headers[userAgent] == "" {
   207  		headers[userAgent] = "go-gh"
   208  		info, ok := debug.ReadBuildInfo()
   209  		if ok {
   210  			for _, dep := range info.Deps {
   211  				if dep.Path == modulePath {
   212  					headers[userAgent] += fmt.Sprintf(" %s", dep.Version)
   213  					break
   214  				}
   215  			}
   216  		}
   217  	}
   218  	if headers[authorization] == "" && authToken != "" {
   219  		headers[authorization] = fmt.Sprintf("token %s", authToken)
   220  	}
   221  	if headers[timeZone] == "" {
   222  		headers[timeZone] = currentTimeZone()
   223  	}
   224  	if headers[accept] == "" {
   225  		// Preview for PullRequest.mergeStateStatus.
   226  		a := "application/vnd.github.merge-info-preview+json"
   227  		// Preview for visibility when RESTing repos into an org.
   228  		a += ", application/vnd.github.nebula-preview"
   229  		// Preview for Commit.statusCheckRollup for old GHES versions.
   230  		a += ", application/vnd.github.antiope-preview"
   231  		// Preview for // PullRequest.isDraft for old GHES versions.
   232  		a += ", application/vnd.github.shadow-cat-preview"
   233  		headers[accept] = a
   234  	}
   235  	return headerRoundTripper{headers: headers, rt: rt}
   236  }
   237  
   238  func (hrt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
   239  	for k, v := range hrt.headers {
   240  		req.Header.Set(k, v)
   241  	}
   242  	return hrt.rt.RoundTrip(req)
   243  }
   244  
   245  func currentTimeZone() string {
   246  	tz := time.Local.String()
   247  	if tz == "Local" {
   248  		_, offset := time.Now().Zone()
   249  		tz = timeZoneNames[offset]
   250  	}
   251  	return tz
   252  }