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 }