2019-01-23 01:23:56 +03:00
|
|
|
package commands
|
|
|
|
|
|
|
|
import (
|
2019-06-15 16:54:07 +03:00
|
|
|
"bytes"
|
2019-01-24 16:36:23 +03:00
|
|
|
"fmt"
|
2019-01-23 01:23:56 +03:00
|
|
|
"io"
|
|
|
|
"io/ioutil"
|
|
|
|
"os"
|
2019-01-24 16:38:03 +03:00
|
|
|
"regexp"
|
2019-01-26 16:47:55 +03:00
|
|
|
"strconv"
|
2019-01-23 01:23:56 +03:00
|
|
|
"strings"
|
2019-10-20 23:05:06 +03:00
|
|
|
"time"
|
2019-01-23 01:23:56 +03:00
|
|
|
|
|
|
|
"github.com/github/hub/github"
|
|
|
|
"github.com/github/hub/ui"
|
|
|
|
"github.com/github/hub/utils"
|
|
|
|
)
|
|
|
|
|
2020-03-21 01:11:47 +03:00
|
|
|
var cmdAPI = &Command{
|
2019-01-23 01:23:56 +03:00
|
|
|
Run: apiCommand,
|
2019-02-07 06:22:59 +03:00
|
|
|
Usage: "api [-it] [-X <METHOD>] [-H <HEADER>] [--cache <TTL>] <ENDPOINT> [-F <FIELD>|--input <FILE>]",
|
2019-01-24 21:28:18 +03:00
|
|
|
Long: `Low-level GitHub API request interface.
|
2019-01-23 01:23:56 +03:00
|
|
|
|
|
|
|
## Options:
|
|
|
|
-X, --method <METHOD>
|
2019-01-24 21:28:18 +03:00
|
|
|
The HTTP method to use for the request (default: "GET"). The method is
|
2019-06-26 13:38:26 +03:00
|
|
|
automatically set to "POST" if ''--field'', ''--raw-field'', or ''--input''
|
|
|
|
are used.
|
2019-01-24 21:28:18 +03:00
|
|
|
|
2019-06-26 13:38:26 +03:00
|
|
|
Use ''-XGET'' to force serializing fields into the query string for the GET
|
2019-01-24 21:28:18 +03:00
|
|
|
request instead of JSON body of the POST request.
|
|
|
|
|
2019-02-07 06:22:59 +03:00
|
|
|
-F, --field <KEY>=<VALUE>
|
|
|
|
Data to serialize with the request. <VALUE> has some magic handling; use
|
2019-06-26 13:38:26 +03:00
|
|
|
''--raw-field'' for sending arbitrary string values.
|
2019-01-26 16:47:55 +03:00
|
|
|
|
|
|
|
If <VALUE> starts with "@", the rest of the value is interpreted as a
|
|
|
|
filename to read the value from. Use "@-" to read from standard input.
|
|
|
|
|
|
|
|
If <VALUE> is "true", "false", "null", or looks like a number, an
|
|
|
|
appropriate JSON type is used instead of a string.
|
2019-01-24 21:28:18 +03:00
|
|
|
|
2019-02-07 06:22:59 +03:00
|
|
|
It is not possible to serialize <VALUE> as a nested JSON array or hash.
|
|
|
|
Instead, construct the request payload externally and pass it via
|
2019-06-26 13:38:26 +03:00
|
|
|
''--input''.
|
2019-02-07 06:22:59 +03:00
|
|
|
|
2019-06-26 13:38:26 +03:00
|
|
|
Unless ''-XGET'' was used, all fields are sent serialized as JSON within
|
|
|
|
the request body. When <ENDPOINT> is "graphql", all fields other than
|
|
|
|
"query" are grouped under "variables". See
|
2019-01-26 21:08:30 +03:00
|
|
|
<https://graphql.org/learn/queries/#variables>
|
2019-01-24 21:28:18 +03:00
|
|
|
|
2019-02-07 06:22:59 +03:00
|
|
|
-f, --raw-field <KEY>=<VALUE>
|
2019-06-26 13:38:26 +03:00
|
|
|
Same as ''--field'', except that it allows values starting with "@", literal
|
2019-01-26 21:08:30 +03:00
|
|
|
strings "true", "false", and "null", as well as strings that look like
|
|
|
|
numbers.
|
2019-01-24 21:28:18 +03:00
|
|
|
|
2019-02-07 06:00:33 +03:00
|
|
|
--input <FILE>
|
|
|
|
The filename to read the raw request body from. Use "-" to read from standard
|
|
|
|
input. Use this when you want to manually construct the request payload.
|
|
|
|
|
2019-02-07 06:22:59 +03:00
|
|
|
-H, --header <KEY>:<VALUE>
|
|
|
|
Set an HTTP request header.
|
2019-01-28 00:05:29 +03:00
|
|
|
|
2019-01-28 02:36:07 +03:00
|
|
|
-i, --include
|
|
|
|
Include HTTP response headers in the output.
|
|
|
|
|
2019-01-23 01:23:56 +03:00
|
|
|
-t, --flat
|
2019-01-24 21:28:18 +03:00
|
|
|
Parse response JSON and output the data in a line-based key-value format
|
|
|
|
suitable for use in shell scripts.
|
|
|
|
|
2019-06-15 16:16:16 +03:00
|
|
|
--paginate
|
|
|
|
Automatically request and output the next page of results until all
|
2019-06-26 13:38:26 +03:00
|
|
|
resources have been listed. For GET requests, this follows the ''<next\>''
|
2019-06-15 16:16:16 +03:00
|
|
|
resource as indicated in the "Link" response header. For GraphQL queries,
|
2019-06-26 13:38:26 +03:00
|
|
|
this utilizes ''pageInfo'' that must be present in the query; see EXAMPLES.
|
2019-06-15 16:16:16 +03:00
|
|
|
|
2019-10-31 00:24:53 +03:00
|
|
|
Note that multiple JSON documents will be output as a result. If the API
|
|
|
|
rate limit has been reached, the final document that is output will be the
|
|
|
|
HTTP 403 notice, and the process will exit with a non-zero status. One way
|
2019-11-15 01:01:33 +03:00
|
|
|
this can be avoided is by enabling ''--obey-ratelimit''.
|
2019-06-15 16:16:16 +03:00
|
|
|
|
2019-02-18 16:21:25 +03:00
|
|
|
--color[=<WHEN>]
|
|
|
|
Enable colored output even if stdout is not a terminal. <WHEN> can be one
|
2019-06-26 13:38:26 +03:00
|
|
|
of "always" (default for ''--color''), "never", or "auto" (default).
|
2019-02-18 16:21:25 +03:00
|
|
|
|
2019-01-23 01:23:56 +03:00
|
|
|
--cache <TTL>
|
2019-06-15 16:16:16 +03:00
|
|
|
Cache valid responses to GET requests for <TTL> seconds.
|
2019-01-24 21:28:18 +03:00
|
|
|
|
|
|
|
When using "graphql" as <ENDPOINT>, caching will apply to responses to POST
|
2019-06-26 13:38:26 +03:00
|
|
|
requests as well. Just make sure to not use ''--cache'' for any GraphQL
|
2019-01-24 21:28:18 +03:00
|
|
|
mutations.
|
|
|
|
|
2019-10-31 00:24:53 +03:00
|
|
|
--obey-ratelimit
|
|
|
|
After exceeding the API rate limit, pause the process until the reset time
|
|
|
|
of the current rate limit window and retry the request. Note that this may
|
|
|
|
cause the process to hang for a long time (maximum of 1 hour).
|
2019-10-20 23:05:06 +03:00
|
|
|
|
2019-01-24 21:28:18 +03:00
|
|
|
<ENDPOINT>
|
|
|
|
The GitHub API endpoint to send the HTTP request to (default: "/").
|
2020-03-21 01:11:47 +03:00
|
|
|
|
2019-01-24 21:28:18 +03:00
|
|
|
To learn about available endpoints, see <https://developer.github.com/v3/>.
|
2019-06-26 13:38:26 +03:00
|
|
|
To make GraphQL queries, use "graphql" as <ENDPOINT> and pass ''-F query=QUERY''.
|
2019-01-24 21:28:18 +03:00
|
|
|
|
2019-01-26 16:47:55 +03:00
|
|
|
If the literal strings "{owner}" or "{repo}" appear in <ENDPOINT> or in the
|
|
|
|
GraphQL "query" field, fill in those placeholders with values read from the
|
|
|
|
git remote configuration of the current git repository.
|
2019-01-24 21:28:18 +03:00
|
|
|
|
|
|
|
## Examples:
|
|
|
|
|
|
|
|
# fetch information about the currently authenticated user as JSON
|
|
|
|
$ hub api user
|
|
|
|
|
|
|
|
# list user repositories as line-based output
|
|
|
|
$ hub api --flat users/octocat/repos
|
|
|
|
|
|
|
|
# post a comment to issue #23 of the current repository
|
2019-06-26 13:38:26 +03:00
|
|
|
$ hub api repos/{owner}/{repo}/issues/23/comments --raw-field 'body=Nice job!'
|
2019-01-24 21:28:18 +03:00
|
|
|
|
|
|
|
# perform a GraphQL query read from a file
|
|
|
|
$ hub api graphql -F query=@path/to/myquery.graphql
|
|
|
|
|
2019-06-15 16:16:16 +03:00
|
|
|
# perform pagination with GraphQL
|
2019-06-26 13:38:26 +03:00
|
|
|
$ hub api --paginate graphql -f query='
|
2019-06-15 16:16:16 +03:00
|
|
|
query($endCursor: String) {
|
|
|
|
repositoryOwner(login: "USER") {
|
|
|
|
repositories(first: 100, after: $endCursor) {
|
|
|
|
nodes {
|
|
|
|
nameWithOwner
|
|
|
|
}
|
|
|
|
pageInfo {
|
|
|
|
hasNextPage
|
|
|
|
endCursor
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2019-06-26 13:38:26 +03:00
|
|
|
}
|
|
|
|
'
|
2019-06-15 16:16:16 +03:00
|
|
|
|
2019-01-24 21:28:18 +03:00
|
|
|
## See also:
|
|
|
|
|
|
|
|
hub(1)
|
2019-01-23 01:23:56 +03:00
|
|
|
`,
|
|
|
|
}
|
|
|
|
|
|
|
|
func init() {
|
2020-03-21 01:11:47 +03:00
|
|
|
CmdRunner.Use(cmdAPI)
|
2019-01-23 01:23:56 +03:00
|
|
|
}
|
|
|
|
|
2019-10-20 23:05:06 +03:00
|
|
|
func apiCommand(_ *Command, args *Args) {
|
2019-01-23 01:23:56 +03:00
|
|
|
path := ""
|
|
|
|
if !args.IsParamsEmpty() {
|
|
|
|
path = args.GetParam(0)
|
|
|
|
}
|
|
|
|
|
|
|
|
method := "GET"
|
|
|
|
if args.Flag.HasReceived("--method") {
|
|
|
|
method = args.Flag.Value("--method")
|
2019-02-07 06:00:33 +03:00
|
|
|
} else if args.Flag.HasReceived("--field") || args.Flag.HasReceived("--raw-field") || args.Flag.HasReceived("--input") {
|
2019-01-23 01:23:56 +03:00
|
|
|
method = "POST"
|
|
|
|
}
|
|
|
|
cacheTTL := args.Flag.Int("--cache")
|
|
|
|
|
|
|
|
params := make(map[string]interface{})
|
|
|
|
for _, val := range args.Flag.AllValues("--field") {
|
|
|
|
parts := strings.SplitN(val, "=", 2)
|
|
|
|
if len(parts) >= 2 {
|
2019-01-26 16:47:55 +03:00
|
|
|
params[parts[0]] = magicValue(parts[1])
|
2019-01-24 16:33:20 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
for _, val := range args.Flag.AllValues("--raw-field") {
|
|
|
|
parts := strings.SplitN(val, "=", 2)
|
|
|
|
if len(parts) >= 2 {
|
|
|
|
params[parts[0]] = parts[1]
|
2019-01-23 01:23:56 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-01-28 00:05:29 +03:00
|
|
|
headers := make(map[string]string)
|
|
|
|
for _, val := range args.Flag.AllValues("--header") {
|
|
|
|
parts := strings.SplitN(val, ":", 2)
|
|
|
|
if len(parts) >= 2 {
|
|
|
|
headers[parts[0]] = strings.TrimLeft(parts[1], " ")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-01-23 01:23:56 +03:00
|
|
|
host := ""
|
|
|
|
owner := ""
|
|
|
|
repo := ""
|
|
|
|
localRepo, localRepoErr := github.LocalRepo()
|
|
|
|
if localRepoErr == nil {
|
|
|
|
var project *github.Project
|
|
|
|
if project, localRepoErr = localRepo.MainProject(); localRepoErr == nil {
|
|
|
|
host = project.Host
|
|
|
|
owner = project.Owner
|
|
|
|
repo = project.Name
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if host == "" {
|
2019-01-26 17:56:05 +03:00
|
|
|
defHost, err := github.CurrentConfig().DefaultHostNoPrompt()
|
2019-01-23 01:23:56 +03:00
|
|
|
utils.Check(err)
|
|
|
|
host = defHost.Host
|
|
|
|
}
|
2019-01-24 16:36:23 +03:00
|
|
|
|
2019-06-15 16:54:07 +03:00
|
|
|
isGraphQL := path == "graphql"
|
|
|
|
if isGraphQL && params["query"] != nil {
|
2019-01-24 16:36:23 +03:00
|
|
|
query := params["query"].(string)
|
2019-08-21 03:02:48 +03:00
|
|
|
query = strings.Replace(query, "{owner}", owner, -1)
|
|
|
|
query = strings.Replace(query, "{repo}", repo, -1)
|
2019-01-26 16:47:55 +03:00
|
|
|
|
|
|
|
variables := make(map[string]interface{})
|
|
|
|
for key, value := range params {
|
|
|
|
if key != "query" {
|
|
|
|
variables[key] = value
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(variables) > 0 {
|
|
|
|
params = make(map[string]interface{})
|
|
|
|
params["variables"] = variables
|
|
|
|
}
|
|
|
|
|
2019-01-24 16:36:23 +03:00
|
|
|
params["query"] = query
|
|
|
|
} else {
|
2019-08-21 03:02:48 +03:00
|
|
|
path = strings.Replace(path, "{owner}", owner, -1)
|
|
|
|
path = strings.Replace(path, "{repo}", repo, -1)
|
2019-01-24 16:36:23 +03:00
|
|
|
}
|
2019-01-23 01:23:56 +03:00
|
|
|
|
2019-02-07 06:00:33 +03:00
|
|
|
var body interface{}
|
|
|
|
if args.Flag.HasReceived("--input") {
|
|
|
|
fn := args.Flag.Value("--input")
|
|
|
|
if fn == "-" {
|
|
|
|
body = os.Stdin
|
|
|
|
} else {
|
|
|
|
fi, err := os.Open(fn)
|
|
|
|
utils.Check(err)
|
|
|
|
body = fi
|
|
|
|
defer fi.Close()
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
body = params
|
|
|
|
}
|
|
|
|
|
2019-01-23 01:23:56 +03:00
|
|
|
gh := github.NewClient(host)
|
2019-01-24 16:38:03 +03:00
|
|
|
|
2019-01-28 00:34:05 +03:00
|
|
|
out := ui.Stdout
|
2019-02-18 16:21:25 +03:00
|
|
|
colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color"))
|
2019-01-28 00:34:05 +03:00
|
|
|
parseJSON := args.Flag.Bool("--flat")
|
2019-06-15 16:16:16 +03:00
|
|
|
includeHeaders := args.Flag.Bool("--include")
|
|
|
|
paginate := args.Flag.Bool("--paginate")
|
2019-10-31 00:24:53 +03:00
|
|
|
rateLimitWait := args.Flag.Bool("--obey-ratelimit")
|
2019-01-28 00:34:05 +03:00
|
|
|
|
2019-06-15 16:16:16 +03:00
|
|
|
args.NoForward()
|
2019-01-24 16:38:03 +03:00
|
|
|
|
2019-10-30 23:12:02 +03:00
|
|
|
for {
|
2019-06-15 16:16:16 +03:00
|
|
|
response, err := gh.GenericAPIRequest(method, path, body, headers, cacheTTL)
|
|
|
|
utils.Check(err)
|
2019-01-28 02:36:07 +03:00
|
|
|
|
2019-10-30 23:34:35 +03:00
|
|
|
if rateLimitWait && response.StatusCode == 403 && response.RateLimitRemaining() == 0 {
|
|
|
|
pauseUntil(response.RateLimitReset())
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
success := response.StatusCode < 300
|
2019-06-15 16:16:16 +03:00
|
|
|
jsonType := true
|
|
|
|
if !success {
|
|
|
|
jsonType, _ = regexp.MatchString(`[/+]json(?:;|$)`, response.Header.Get("Content-Type"))
|
|
|
|
}
|
|
|
|
|
|
|
|
if includeHeaders {
|
|
|
|
fmt.Fprintf(out, "%s %s\r\n", response.Proto, response.Status)
|
|
|
|
response.Header.Write(out)
|
|
|
|
fmt.Fprintf(out, "\r\n")
|
|
|
|
}
|
|
|
|
|
2019-06-15 16:54:07 +03:00
|
|
|
endCursor := ""
|
|
|
|
hasNextPage := false
|
|
|
|
|
2019-06-15 16:16:16 +03:00
|
|
|
if parseJSON && jsonType {
|
2019-06-15 16:54:07 +03:00
|
|
|
hasNextPage, endCursor = utils.JSONPath(out, response.Body, colorize)
|
|
|
|
} else if paginate && isGraphQL {
|
|
|
|
bodyCopy := &bytes.Buffer{}
|
|
|
|
io.Copy(out, io.TeeReader(response.Body, bodyCopy))
|
|
|
|
hasNextPage, endCursor = utils.JSONPath(ioutil.Discard, bodyCopy, false)
|
2019-06-15 16:16:16 +03:00
|
|
|
} else {
|
|
|
|
io.Copy(out, response.Body)
|
|
|
|
}
|
|
|
|
response.Body.Close()
|
2019-01-28 00:34:05 +03:00
|
|
|
|
2019-06-15 16:16:16 +03:00
|
|
|
if !success {
|
2020-01-19 16:43:37 +03:00
|
|
|
if ssoErr := github.ValidateGitHubSSO(response.Response); ssoErr != nil {
|
|
|
|
ui.Errorln()
|
|
|
|
ui.Errorln(ssoErr)
|
|
|
|
}
|
2020-01-19 02:10:42 +03:00
|
|
|
if scopeErr := github.ValidateSufficientOAuthScopes(response.Response); scopeErr != nil {
|
|
|
|
ui.Errorln()
|
|
|
|
ui.Errorln(scopeErr)
|
|
|
|
}
|
2019-06-15 16:16:16 +03:00
|
|
|
os.Exit(22)
|
|
|
|
}
|
|
|
|
|
|
|
|
if paginate {
|
2019-06-15 16:54:07 +03:00
|
|
|
if isGraphQL && hasNextPage && endCursor != "" {
|
|
|
|
if v, ok := params["variables"]; ok {
|
|
|
|
variables := v.(map[string]interface{})
|
|
|
|
variables["endCursor"] = endCursor
|
|
|
|
} else {
|
|
|
|
variables := map[string]interface{}{"endCursor": endCursor}
|
|
|
|
params["variables"] = variables
|
|
|
|
}
|
2019-10-30 23:12:02 +03:00
|
|
|
goto next
|
2019-06-15 16:54:07 +03:00
|
|
|
} else if nextLink := response.Link("next"); nextLink != "" {
|
2019-06-15 16:16:16 +03:00
|
|
|
path = nextLink
|
2019-10-30 23:12:02 +03:00
|
|
|
goto next
|
2019-06-15 16:16:16 +03:00
|
|
|
}
|
|
|
|
}
|
2019-10-30 23:12:02 +03:00
|
|
|
|
|
|
|
break
|
|
|
|
next:
|
|
|
|
if !parseJSON {
|
2019-06-15 16:16:16 +03:00
|
|
|
fmt.Fprintf(out, "\n")
|
|
|
|
}
|
2019-10-20 23:05:06 +03:00
|
|
|
|
2019-10-30 23:34:35 +03:00
|
|
|
if rateLimitWait && response.RateLimitRemaining() == 0 {
|
|
|
|
pauseUntil(response.RateLimitReset())
|
2019-10-20 23:05:06 +03:00
|
|
|
}
|
2019-01-23 01:23:56 +03:00
|
|
|
}
|
|
|
|
}
|
2019-01-24 16:33:20 +03:00
|
|
|
|
2019-10-30 23:34:35 +03:00
|
|
|
func pauseUntil(timestamp int) {
|
|
|
|
rollover := time.Unix(int64(timestamp)+1, 0)
|
|
|
|
duration := time.Until(rollover)
|
|
|
|
if duration > 0 {
|
2019-10-30 23:36:59 +03:00
|
|
|
ui.Errorf("API rate limit exceeded; pausing until %v ...\n", rollover)
|
2019-10-30 23:34:35 +03:00
|
|
|
time.Sleep(duration)
|
2019-01-23 01:23:56 +03:00
|
|
|
}
|
|
|
|
}
|
2019-01-24 16:33:20 +03:00
|
|
|
|
2019-01-26 16:47:55 +03:00
|
|
|
const (
|
|
|
|
trueVal = "true"
|
|
|
|
falseVal = "false"
|
|
|
|
nilVal = "null"
|
|
|
|
)
|
|
|
|
|
|
|
|
func magicValue(value string) interface{} {
|
|
|
|
switch value {
|
|
|
|
case trueVal:
|
|
|
|
return true
|
|
|
|
case falseVal:
|
|
|
|
return false
|
|
|
|
case nilVal:
|
|
|
|
return nil
|
|
|
|
default:
|
|
|
|
if strings.HasPrefix(value, "@") {
|
|
|
|
return string(readFile(value[1:]))
|
|
|
|
} else if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
return i
|
2019-01-24 16:33:20 +03:00
|
|
|
} else {
|
2019-01-26 16:47:55 +03:00
|
|
|
return value
|
2019-01-24 16:33:20 +03:00
|
|
|
}
|
2019-01-26 16:47:55 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func readFile(file string) (content []byte) {
|
|
|
|
var err error
|
|
|
|
if file == "-" {
|
|
|
|
content, err = ioutil.ReadAll(os.Stdin)
|
2019-01-24 16:33:20 +03:00
|
|
|
} else {
|
2019-01-26 16:47:55 +03:00
|
|
|
content, err = ioutil.ReadFile(file)
|
2019-01-24 16:33:20 +03:00
|
|
|
}
|
2019-01-26 16:47:55 +03:00
|
|
|
utils.Check(err)
|
|
|
|
return
|
2019-01-24 16:33:20 +03:00
|
|
|
}
|