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