@@ -12,8 +12,8 @@ import (
1212 "strings"
1313
1414 "github.com/cli/cli/v2/internal/ghinstance"
15+ graphql "github.com/cli/shurcooL-graphql"
1516 "github.com/henvic/httpretty"
16- "github.com/shurcooL/graphql"
1717)
1818
1919// ClientOption represents an argument to NewClient
@@ -98,6 +98,22 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
9898 }
9999}
100100
101+ // ExtractHeader extracts a named header from any response received by this client and, if non-blank, saves
102+ // it to dest.
103+ func ExtractHeader (name string , dest * string ) ClientOption {
104+ return func (tr http.RoundTripper ) http.RoundTripper {
105+ return & funcTripper {roundTrip : func (req * http.Request ) (* http.Response , error ) {
106+ res , err := tr .RoundTrip (req )
107+ if err == nil {
108+ if value := res .Header .Get (name ); value != "" {
109+ * dest = value
110+ }
111+ }
112+ return res , err
113+ }}
114+ }
115+ }
116+
101117type funcTripper struct {
102118 roundTrip func (* http.Request ) (* http.Response , error )
103119}
@@ -124,7 +140,18 @@ type graphQLResponse struct {
124140type GraphQLError struct {
125141 Type string
126142 Message string
127- // Path []interface // mixed strings and numbers
143+ Path []interface {} // mixed strings and numbers
144+ }
145+
146+ func (ge GraphQLError ) PathString () string {
147+ var res strings.Builder
148+ for i , v := range ge .Path {
149+ if i > 0 {
150+ res .WriteRune ('.' )
151+ }
152+ fmt .Fprintf (& res , "%v" , v )
153+ }
154+ return res .String ()
128155}
129156
130157// GraphQLErrorResponse contains errors returned in a GraphQL response
@@ -135,18 +162,41 @@ type GraphQLErrorResponse struct {
135162func (gr GraphQLErrorResponse ) Error () string {
136163 errorMessages := make ([]string , 0 , len (gr .Errors ))
137164 for _ , e := range gr .Errors {
138- errorMessages = append (errorMessages , e .Message )
165+ msg := e .Message
166+ if p := e .PathString (); p != "" {
167+ msg = fmt .Sprintf ("%s (%s)" , msg , p )
168+ }
169+ errorMessages = append (errorMessages , msg )
170+ }
171+ return fmt .Sprintf ("GraphQL: %s" , strings .Join (errorMessages , ", " ))
172+ }
173+
174+ // Match checks if this error is only about a specific type on a specific path. If the path argument ends
175+ // with a ".", it will match all its subpaths as well.
176+ func (gr GraphQLErrorResponse ) Match (expectType , expectPath string ) bool {
177+ for _ , e := range gr .Errors {
178+ if e .Type != expectType || ! matchPath (e .PathString (), expectPath ) {
179+ return false
180+ }
181+ }
182+ return true
183+ }
184+
185+ func matchPath (p , expect string ) bool {
186+ if strings .HasSuffix (expect , "." ) {
187+ return strings .HasPrefix (p , expect ) || p == strings .TrimSuffix (expect , "." )
139188 }
140- return fmt . Sprintf ( "GraphQL error: %s" , strings . Join ( errorMessages , " \n " ))
189+ return p == expect
141190}
142191
143192// HTTPError is an error returned by a failed API call
144193type HTTPError struct {
145- StatusCode int
146- RequestURL * url.URL
147- Message string
148- OAuthScopes string
149- Errors []HTTPErrorItem
194+ StatusCode int
195+ RequestURL * url.URL
196+ Message string
197+ Errors []HTTPErrorItem
198+
199+ scopesSuggestion string
150200}
151201
152202type HTTPErrorItem struct {
@@ -165,7 +215,63 @@ func (err HTTPError) Error() string {
165215 return fmt .Sprintf ("HTTP %d (%s)" , err .StatusCode , err .RequestURL )
166216}
167217
168- // GraphQL performs a GraphQL request and parses the response
218+ func (err HTTPError ) ScopesSuggestion () string {
219+ return err .scopesSuggestion
220+ }
221+
222+ // ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth
223+ // scopes in case a server response indicates that there are missing scopes.
224+ func ScopesSuggestion (resp * http.Response ) string {
225+ if resp .StatusCode < 400 || resp .StatusCode > 499 || resp .StatusCode == 422 {
226+ return ""
227+ }
228+
229+ endpointNeedsScopes := resp .Header .Get ("X-Accepted-Oauth-Scopes" )
230+ tokenHasScopes := resp .Header .Get ("X-Oauth-Scopes" )
231+ if tokenHasScopes == "" {
232+ return ""
233+ }
234+
235+ gotScopes := map [string ]struct {}{}
236+ for _ , s := range strings .Split (tokenHasScopes , "," ) {
237+ s = strings .TrimSpace (s )
238+ gotScopes [s ] = struct {}{}
239+ if strings .HasPrefix (s , "admin:" ) {
240+ gotScopes ["read:" + strings .TrimPrefix (s , "admin:" )] = struct {}{}
241+ gotScopes ["write:" + strings .TrimPrefix (s , "admin:" )] = struct {}{}
242+ } else if strings .HasPrefix (s , "write:" ) {
243+ gotScopes ["read:" + strings .TrimPrefix (s , "write:" )] = struct {}{}
244+ }
245+ }
246+
247+ for _ , s := range strings .Split (endpointNeedsScopes , "," ) {
248+ s = strings .TrimSpace (s )
249+ if _ , gotScope := gotScopes [s ]; s == "" || gotScope {
250+ continue
251+ }
252+ return fmt .Sprintf (
253+ "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s" ,
254+ s ,
255+ ghinstance .NormalizeHostname (resp .Request .URL .Hostname ()),
256+ )
257+ }
258+
259+ return ""
260+ }
261+
262+ // EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the
263+ // server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the
264+ // OAuth scopes they need.
265+ func EndpointNeedsScopes (resp * http.Response , s string ) * http.Response {
266+ if resp .StatusCode >= 400 && resp .StatusCode < 500 {
267+ oldScopes := resp .Header .Get ("X-Accepted-Oauth-Scopes" )
268+ resp .Header .Set ("X-Accepted-Oauth-Scopes" , fmt .Sprintf ("%s, %s" , oldScopes , s ))
269+ }
270+ return resp
271+ }
272+
273+ // GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
274+ // *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver.
169275func (c Client ) GraphQL (hostname string , query string , variables map [string ]interface {}, data interface {}) error {
170276 reqBody , err := json .Marshal (map [string ]interface {}{"query" : query , "variables" : variables })
171277 if err != nil {
@@ -261,9 +367,9 @@ func handleResponse(resp *http.Response, data interface{}) error {
261367
262368func HandleHTTPError (resp * http.Response ) error {
263369 httpError := HTTPError {
264- StatusCode : resp .StatusCode ,
265- RequestURL : resp .Request .URL ,
266- OAuthScopes : resp . Header . Get ( "X-Oauth-Scopes" ),
370+ StatusCode : resp .StatusCode ,
371+ RequestURL : resp .Request .URL ,
372+ scopesSuggestion : ScopesSuggestion ( resp ),
267373 }
268374
269375 if ! jsonTypeRE .MatchString (resp .Header .Get ("Content-Type" )) {
0 commit comments