github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/api/client.go (about) 1 package api 2 3 import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "io" 8 "net/http" 9 "regexp" 10 "strings" 11 12 "github.com/ungtb10d/cli/v2/internal/ghinstance" 13 "github.com/cli/go-gh" 14 ghAPI "github.com/cli/go-gh/pkg/api" 15 ) 16 17 const ( 18 accept = "Accept" 19 authorization = "Authorization" 20 cacheTTL = "X-GH-CACHE-TTL" 21 graphqlFeatures = "GraphQL-Features" 22 features = "merge_queue" 23 userAgent = "User-Agent" 24 ) 25 26 var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) 27 28 func NewClientFromHTTP(httpClient *http.Client) *Client { 29 client := &Client{http: httpClient} 30 return client 31 } 32 33 type Client struct { 34 http *http.Client 35 } 36 37 func (c *Client) HTTP() *http.Client { 38 return c.http 39 } 40 41 type GraphQLError struct { 42 ghAPI.GQLError 43 } 44 45 type HTTPError struct { 46 ghAPI.HTTPError 47 scopesSuggestion string 48 } 49 50 func (err HTTPError) ScopesSuggestion() string { 51 return err.scopesSuggestion 52 } 53 54 // GraphQL performs a GraphQL request and parses the response. If there are errors in the response, 55 // GraphQLError will be returned, but the data will also be parsed into the receiver. 56 func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { 57 opts := clientOptions(hostname, c.http.Transport) 58 opts.Headers[graphqlFeatures] = features 59 gqlClient, err := gh.GQLClient(&opts) 60 if err != nil { 61 return err 62 } 63 return handleResponse(gqlClient.Do(query, variables, data)) 64 } 65 66 // GraphQL performs a GraphQL mutation and parses the response. If there are errors in the response, 67 // GraphQLError will be returned, but the data will also be parsed into the receiver. 68 func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error { 69 opts := clientOptions(hostname, c.http.Transport) 70 opts.Headers[graphqlFeatures] = features 71 gqlClient, err := gh.GQLClient(&opts) 72 if err != nil { 73 return err 74 } 75 return handleResponse(gqlClient.Mutate(name, mutation, variables)) 76 } 77 78 // GraphQL performs a GraphQL query and parses the response. If there are errors in the response, 79 // GraphQLError will be returned, but the data will also be parsed into the receiver. 80 func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error { 81 opts := clientOptions(hostname, c.http.Transport) 82 opts.Headers[graphqlFeatures] = features 83 gqlClient, err := gh.GQLClient(&opts) 84 if err != nil { 85 return err 86 } 87 return handleResponse(gqlClient.Query(name, query, variables)) 88 } 89 90 // REST performs a REST request and parses the response. 91 func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error { 92 opts := clientOptions(hostname, c.http.Transport) 93 restClient, err := gh.RESTClient(&opts) 94 if err != nil { 95 return err 96 } 97 return handleResponse(restClient.Do(method, p, body, data)) 98 } 99 100 func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) { 101 opts := clientOptions(hostname, c.http.Transport) 102 restClient, err := gh.RESTClient(&opts) 103 if err != nil { 104 return "", err 105 } 106 107 resp, err := restClient.Request(method, p, body) 108 if err != nil { 109 return "", err 110 } 111 defer resp.Body.Close() 112 113 success := resp.StatusCode >= 200 && resp.StatusCode < 300 114 if !success { 115 return "", HandleHTTPError(resp) 116 } 117 118 if resp.StatusCode == http.StatusNoContent { 119 return "", nil 120 } 121 122 b, err := io.ReadAll(resp.Body) 123 if err != nil { 124 return "", err 125 } 126 127 err = json.Unmarshal(b, &data) 128 if err != nil { 129 return "", err 130 } 131 132 var next string 133 for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { 134 if len(m) > 2 && m[2] == "next" { 135 next = m[1] 136 } 137 } 138 139 return next, nil 140 } 141 142 // HandleHTTPError parses a http.Response into a HTTPError. 143 func HandleHTTPError(resp *http.Response) error { 144 return handleResponse(ghAPI.HandleHTTPError(resp)) 145 } 146 147 // handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an 148 // HTTPError or GraphQLError respectively. 149 func handleResponse(err error) error { 150 if err == nil { 151 return nil 152 } 153 154 var restErr ghAPI.HTTPError 155 if errors.As(err, &restErr) { 156 return HTTPError{ 157 HTTPError: restErr, 158 scopesSuggestion: generateScopesSuggestion(restErr.StatusCode, 159 restErr.Headers.Get("X-Accepted-Oauth-Scopes"), 160 restErr.Headers.Get("X-Oauth-Scopes"), 161 restErr.RequestURL.Hostname()), 162 } 163 } 164 165 var gqlErr ghAPI.GQLError 166 if errors.As(err, &gqlErr) { 167 return GraphQLError{ 168 GQLError: gqlErr, 169 } 170 } 171 172 return err 173 } 174 175 // ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth 176 // scopes in case a server response indicates that there are missing scopes. 177 func ScopesSuggestion(resp *http.Response) string { 178 return generateScopesSuggestion(resp.StatusCode, 179 resp.Header.Get("X-Accepted-Oauth-Scopes"), 180 resp.Header.Get("X-Oauth-Scopes"), 181 resp.Request.URL.Hostname()) 182 } 183 184 // EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the 185 // server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the 186 // OAuth scopes they need. 187 func EndpointNeedsScopes(resp *http.Response, s string) *http.Response { 188 if resp.StatusCode >= 400 && resp.StatusCode < 500 { 189 oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") 190 resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s)) 191 } 192 return resp 193 } 194 195 func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string { 196 if statusCode < 400 || statusCode > 499 || statusCode == 422 { 197 return "" 198 } 199 200 if tokenHasScopes == "" { 201 return "" 202 } 203 204 gotScopes := map[string]struct{}{} 205 for _, s := range strings.Split(tokenHasScopes, ",") { 206 s = strings.TrimSpace(s) 207 gotScopes[s] = struct{}{} 208 209 // Certain scopes may be grouped under a single "top-level" scope. The following branch 210 // statements include these grouped/implied scopes when the top-level scope is encountered. 211 // See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps. 212 if s == "repo" { 213 gotScopes["repo:status"] = struct{}{} 214 gotScopes["repo_deployment"] = struct{}{} 215 gotScopes["public_repo"] = struct{}{} 216 gotScopes["repo:invite"] = struct{}{} 217 gotScopes["security_events"] = struct{}{} 218 } else if s == "user" { 219 gotScopes["read:user"] = struct{}{} 220 gotScopes["user:email"] = struct{}{} 221 gotScopes["user:follow"] = struct{}{} 222 } else if s == "codespace" { 223 gotScopes["codespace:secrets"] = struct{}{} 224 } else if strings.HasPrefix(s, "admin:") { 225 gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} 226 gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} 227 } else if strings.HasPrefix(s, "write:") { 228 gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{} 229 } 230 } 231 232 for _, s := range strings.Split(endpointNeedsScopes, ",") { 233 s = strings.TrimSpace(s) 234 if _, gotScope := gotScopes[s]; s == "" || gotScope { 235 continue 236 } 237 return fmt.Sprintf( 238 "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s", 239 s, 240 ghinstance.NormalizeHostname(hostname), 241 ) 242 } 243 244 return "" 245 } 246 247 func clientOptions(hostname string, transport http.RoundTripper) ghAPI.ClientOptions { 248 // AuthToken, and Headers are being handled by transport, 249 // so let go-gh know that it does not need to resolve them. 250 opts := ghAPI.ClientOptions{ 251 AuthToken: "none", 252 Headers: map[string]string{ 253 authorization: "", 254 }, 255 Host: hostname, 256 SkipDefaultHeaders: true, 257 Transport: transport, 258 LogIgnoreEnv: true, 259 } 260 return opts 261 }