1package commands 2 3import ( 4 "bytes" 5 "fmt" 6 "io" 7 "io/ioutil" 8 "os" 9 "regexp" 10 "strconv" 11 "strings" 12 "time" 13 14 "github.com/github/hub/github" 15 "github.com/github/hub/ui" 16 "github.com/github/hub/utils" 17) 18 19var cmdApi = &Command{ 20 Run: apiCommand, 21 Usage: "api [-it] [-X <METHOD>] [-H <HEADER>] [--cache <TTL>] <ENDPOINT> [-F <FIELD>|--input <FILE>]", 22 Long: `Low-level GitHub API request interface. 23 24## Options: 25 -X, --method <METHOD> 26 The HTTP method to use for the request (default: "GET"). The method is 27 automatically set to "POST" if ''--field'', ''--raw-field'', or ''--input'' 28 are used. 29 30 Use ''-XGET'' to force serializing fields into the query string for the GET 31 request instead of JSON body of the POST request. 32 33 -F, --field <KEY>=<VALUE> 34 Data to serialize with the request. <VALUE> has some magic handling; use 35 ''--raw-field'' for sending arbitrary string values. 36 37 If <VALUE> starts with "@", the rest of the value is interpreted as a 38 filename to read the value from. Use "@-" to read from standard input. 39 40 If <VALUE> is "true", "false", "null", or looks like a number, an 41 appropriate JSON type is used instead of a string. 42 43 It is not possible to serialize <VALUE> as a nested JSON array or hash. 44 Instead, construct the request payload externally and pass it via 45 ''--input''. 46 47 Unless ''-XGET'' was used, all fields are sent serialized as JSON within 48 the request body. When <ENDPOINT> is "graphql", all fields other than 49 "query" are grouped under "variables". See 50 <https://graphql.org/learn/queries/#variables> 51 52 -f, --raw-field <KEY>=<VALUE> 53 Same as ''--field'', except that it allows values starting with "@", literal 54 strings "true", "false", and "null", as well as strings that look like 55 numbers. 56 57 --input <FILE> 58 The filename to read the raw request body from. Use "-" to read from standard 59 input. Use this when you want to manually construct the request payload. 60 61 -H, --header <KEY>:<VALUE> 62 Set an HTTP request header. 63 64 -i, --include 65 Include HTTP response headers in the output. 66 67 -t, --flat 68 Parse response JSON and output the data in a line-based key-value format 69 suitable for use in shell scripts. 70 71 --paginate 72 Automatically request and output the next page of results until all 73 resources have been listed. For GET requests, this follows the ''<next\>'' 74 resource as indicated in the "Link" response header. For GraphQL queries, 75 this utilizes ''pageInfo'' that must be present in the query; see EXAMPLES. 76 77 Note that multiple JSON documents will be output as a result. If the API 78 rate limit has been reached, the final document that is output will be the 79 HTTP 403 notice, and the process will exit with a non-zero status. One way 80 this can be avoided is by enabling ''--obey-ratelimit''. 81 82 --color[=<WHEN>] 83 Enable colored output even if stdout is not a terminal. <WHEN> can be one 84 of "always" (default for ''--color''), "never", or "auto" (default). 85 86 --cache <TTL> 87 Cache valid responses to GET requests for <TTL> seconds. 88 89 When using "graphql" as <ENDPOINT>, caching will apply to responses to POST 90 requests as well. Just make sure to not use ''--cache'' for any GraphQL 91 mutations. 92 93 --obey-ratelimit 94 After exceeding the API rate limit, pause the process until the reset time 95 of the current rate limit window and retry the request. Note that this may 96 cause the process to hang for a long time (maximum of 1 hour). 97 98 <ENDPOINT> 99 The GitHub API endpoint to send the HTTP request to (default: "/"). 100 101 To learn about available endpoints, see <https://developer.github.com/v3/>. 102 To make GraphQL queries, use "graphql" as <ENDPOINT> and pass ''-F query=QUERY''. 103 104 If the literal strings "{owner}" or "{repo}" appear in <ENDPOINT> or in the 105 GraphQL "query" field, fill in those placeholders with values read from the 106 git remote configuration of the current git repository. 107 108## Examples: 109 110 # fetch information about the currently authenticated user as JSON 111 $ hub api user 112 113 # list user repositories as line-based output 114 $ hub api --flat users/octocat/repos 115 116 # post a comment to issue #23 of the current repository 117 $ hub api repos/{owner}/{repo}/issues/23/comments --raw-field 'body=Nice job!' 118 119 # perform a GraphQL query read from a file 120 $ hub api graphql -F query=@path/to/myquery.graphql 121 122 # perform pagination with GraphQL 123 $ hub api --paginate graphql -f query=' 124 query($endCursor: String) { 125 repositoryOwner(login: "USER") { 126 repositories(first: 100, after: $endCursor) { 127 nodes { 128 nameWithOwner 129 } 130 pageInfo { 131 hasNextPage 132 endCursor 133 } 134 } 135 } 136 } 137 ' 138 139## See also: 140 141hub(1) 142`, 143} 144 145func init() { 146 CmdRunner.Use(cmdApi) 147} 148 149func apiCommand(_ *Command, args *Args) { 150 path := "" 151 if !args.IsParamsEmpty() { 152 path = args.GetParam(0) 153 } 154 155 method := "GET" 156 if args.Flag.HasReceived("--method") { 157 method = args.Flag.Value("--method") 158 } else if args.Flag.HasReceived("--field") || args.Flag.HasReceived("--raw-field") || args.Flag.HasReceived("--input") { 159 method = "POST" 160 } 161 cacheTTL := args.Flag.Int("--cache") 162 163 params := make(map[string]interface{}) 164 for _, val := range args.Flag.AllValues("--field") { 165 parts := strings.SplitN(val, "=", 2) 166 if len(parts) >= 2 { 167 params[parts[0]] = magicValue(parts[1]) 168 } 169 } 170 for _, val := range args.Flag.AllValues("--raw-field") { 171 parts := strings.SplitN(val, "=", 2) 172 if len(parts) >= 2 { 173 params[parts[0]] = parts[1] 174 } 175 } 176 177 headers := make(map[string]string) 178 for _, val := range args.Flag.AllValues("--header") { 179 parts := strings.SplitN(val, ":", 2) 180 if len(parts) >= 2 { 181 headers[parts[0]] = strings.TrimLeft(parts[1], " ") 182 } 183 } 184 185 host := "" 186 owner := "" 187 repo := "" 188 localRepo, localRepoErr := github.LocalRepo() 189 if localRepoErr == nil { 190 var project *github.Project 191 if project, localRepoErr = localRepo.MainProject(); localRepoErr == nil { 192 host = project.Host 193 owner = project.Owner 194 repo = project.Name 195 } 196 } 197 if host == "" { 198 defHost, err := github.CurrentConfig().DefaultHostNoPrompt() 199 utils.Check(err) 200 host = defHost.Host 201 } 202 203 isGraphQL := path == "graphql" 204 if isGraphQL && params["query"] != nil { 205 query := params["query"].(string) 206 query = strings.Replace(query, "{owner}", owner, -1) 207 query = strings.Replace(query, "{repo}", repo, -1) 208 209 variables := make(map[string]interface{}) 210 for key, value := range params { 211 if key != "query" { 212 variables[key] = value 213 } 214 } 215 if len(variables) > 0 { 216 params = make(map[string]interface{}) 217 params["variables"] = variables 218 } 219 220 params["query"] = query 221 } else { 222 path = strings.Replace(path, "{owner}", owner, -1) 223 path = strings.Replace(path, "{repo}", repo, -1) 224 } 225 226 var body interface{} 227 if args.Flag.HasReceived("--input") { 228 fn := args.Flag.Value("--input") 229 if fn == "-" { 230 body = os.Stdin 231 } else { 232 fi, err := os.Open(fn) 233 utils.Check(err) 234 body = fi 235 defer fi.Close() 236 } 237 } else { 238 body = params 239 } 240 241 gh := github.NewClient(host) 242 243 out := ui.Stdout 244 colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) 245 parseJSON := args.Flag.Bool("--flat") 246 includeHeaders := args.Flag.Bool("--include") 247 paginate := args.Flag.Bool("--paginate") 248 rateLimitWait := args.Flag.Bool("--obey-ratelimit") 249 250 args.NoForward() 251 252 for { 253 response, err := gh.GenericAPIRequest(method, path, body, headers, cacheTTL) 254 utils.Check(err) 255 256 if rateLimitWait && response.StatusCode == 403 && response.RateLimitRemaining() == 0 { 257 pauseUntil(response.RateLimitReset()) 258 continue 259 } 260 261 success := response.StatusCode < 300 262 jsonType := true 263 if !success { 264 jsonType, _ = regexp.MatchString(`[/+]json(?:;|$)`, response.Header.Get("Content-Type")) 265 } 266 267 if includeHeaders { 268 fmt.Fprintf(out, "%s %s\r\n", response.Proto, response.Status) 269 response.Header.Write(out) 270 fmt.Fprintf(out, "\r\n") 271 } 272 273 endCursor := "" 274 hasNextPage := false 275 276 if parseJSON && jsonType { 277 hasNextPage, endCursor = utils.JSONPath(out, response.Body, colorize) 278 } else if paginate && isGraphQL { 279 bodyCopy := &bytes.Buffer{} 280 io.Copy(out, io.TeeReader(response.Body, bodyCopy)) 281 hasNextPage, endCursor = utils.JSONPath(ioutil.Discard, bodyCopy, false) 282 } else { 283 io.Copy(out, response.Body) 284 } 285 response.Body.Close() 286 287 if !success { 288 if ssoErr := github.ValidateGitHubSSO(response.Response); ssoErr != nil { 289 ui.Errorln() 290 ui.Errorln(ssoErr) 291 } 292 if scopeErr := github.ValidateSufficientOAuthScopes(response.Response); scopeErr != nil { 293 ui.Errorln() 294 ui.Errorln(scopeErr) 295 } 296 os.Exit(22) 297 } 298 299 if paginate { 300 if isGraphQL && hasNextPage && endCursor != "" { 301 if v, ok := params["variables"]; ok { 302 variables := v.(map[string]interface{}) 303 variables["endCursor"] = endCursor 304 } else { 305 variables := map[string]interface{}{"endCursor": endCursor} 306 params["variables"] = variables 307 } 308 goto next 309 } else if nextLink := response.Link("next"); nextLink != "" { 310 path = nextLink 311 goto next 312 } 313 } 314 315 break 316 next: 317 if !parseJSON { 318 fmt.Fprintf(out, "\n") 319 } 320 321 if rateLimitWait && response.RateLimitRemaining() == 0 { 322 pauseUntil(response.RateLimitReset()) 323 } 324 } 325} 326 327func pauseUntil(timestamp int) { 328 rollover := time.Unix(int64(timestamp)+1, 0) 329 duration := time.Until(rollover) 330 if duration > 0 { 331 ui.Errorf("API rate limit exceeded; pausing until %v ...\n", rollover) 332 time.Sleep(duration) 333 } 334} 335 336const ( 337 trueVal = "true" 338 falseVal = "false" 339 nilVal = "null" 340) 341 342func magicValue(value string) interface{} { 343 switch value { 344 case trueVal: 345 return true 346 case falseVal: 347 return false 348 case nilVal: 349 return nil 350 default: 351 if strings.HasPrefix(value, "@") { 352 return string(readFile(value[1:])) 353 } else if i, err := strconv.Atoi(value); err == nil { 354 return i 355 } else { 356 return value 357 } 358 } 359} 360 361func readFile(file string) (content []byte) { 362 var err error 363 if file == "-" { 364 content, err = ioutil.ReadAll(os.Stdin) 365 } else { 366 content, err = ioutil.ReadFile(file) 367 } 368 utils.Check(err) 369 return 370} 371