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  }