1package api
2
3import (
4	"bytes"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"io/ioutil"
10	"net/http"
11	"os"
12	"regexp"
13	"sort"
14	"strconv"
15	"strings"
16	"syscall"
17	"time"
18
19	"github.com/MakeNowJust/heredoc"
20	"github.com/cli/cli/v2/api"
21	"github.com/cli/cli/v2/internal/config"
22	"github.com/cli/cli/v2/internal/ghinstance"
23	"github.com/cli/cli/v2/internal/ghrepo"
24	"github.com/cli/cli/v2/pkg/cmdutil"
25	"github.com/cli/cli/v2/pkg/export"
26	"github.com/cli/cli/v2/pkg/iostreams"
27	"github.com/cli/cli/v2/pkg/jsoncolor"
28	"github.com/spf13/cobra"
29)
30
31type ApiOptions struct {
32	IO *iostreams.IOStreams
33
34	Hostname            string
35	RequestMethod       string
36	RequestMethodPassed bool
37	RequestPath         string
38	RequestInputFile    string
39	MagicFields         []string
40	RawFields           []string
41	RequestHeaders      []string
42	Previews            []string
43	ShowResponseHeaders bool
44	Paginate            bool
45	Silent              bool
46	Template            string
47	CacheTTL            time.Duration
48	FilterOutput        string
49
50	Config     func() (config.Config, error)
51	HttpClient func() (*http.Client, error)
52	BaseRepo   func() (ghrepo.Interface, error)
53	Branch     func() (string, error)
54}
55
56func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
57	opts := ApiOptions{
58		IO:         f.IOStreams,
59		Config:     f.Config,
60		HttpClient: f.HttpClient,
61		BaseRepo:   f.BaseRepo,
62		Branch:     f.Branch,
63	}
64
65	cmd := &cobra.Command{
66		Use:   "api <endpoint>",
67		Short: "Make an authenticated GitHub API request",
68		Long: heredoc.Docf(`
69			Makes an authenticated HTTP request to the GitHub API and prints the response.
70
71			The endpoint argument should either be a path of a GitHub API v3 endpoint, or
72			"graphql" to access the GitHub API v4.
73
74			Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint argument will
75			get replaced with values from the repository of the current directory. Note that in
76			some shells, for example PowerShell, you may need to enclose any value that contains
77			"{...}" in quotes to prevent the shell from applying special meaning to curly braces.
78
79			The default HTTP request method is "GET" normally and "POST" if any parameters
80			were added. Override the method with %[1]s--method%[1]s.
81
82			Pass one or more %[1]s-f/--raw-field%[1]s values in "key=value" format to add static string
83			parameters to the request payload. To add non-string or otherwise dynamic values, see
84			%[1]s--field%[1]s below. Note that adding request parameters will automatically switch the
85			request method to POST. To send the parameters as a GET query string instead, use
86			%[1]s--method GET%[1]s.
87
88			The %[1]s-F/--field%[1]s flag has magic type conversion based on the format of the value:
89
90			- literal values "true", "false", "null", and integer numbers get converted to
91			  appropriate JSON types;
92			- placeholder values "{owner}", "{repo}", and "{branch}" get populated with values
93			  from the repository of the current directory;
94			- if the value starts with "@", the rest of the value is interpreted as a
95			  filename to read the value from. Pass "-" to read from standard input.
96
97			For GraphQL requests, all fields other than "query" and "operationName" are
98			interpreted as GraphQL variables.
99
100			Raw request body may be passed from the outside via a file specified by %[1]s--input%[1]s.
101			Pass "-" to read from standard input. In this mode, parameters specified via
102			%[1]s--field%[1]s flags are serialized into URL query parameters.
103
104			In %[1]s--paginate%[1]s mode, all pages of results will sequentially be requested until
105			there are no more pages of results. For GraphQL requests, this requires that the
106			original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the
107			%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection.
108		`, "`"),
109		Example: heredoc.Doc(`
110			# list releases in the current repository
111			$ gh api repos/{owner}/{repo}/releases
112
113			# post an issue comment
114			$ gh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI'
115
116			# add parameters to a GET request
117			$ gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
118
119			# set a custom HTTP header
120			$ gh api -H 'Accept: application/vnd.github.v3.raw+json' ...
121
122			# opt into GitHub API previews
123			$ gh api --preview baptiste,nebula ...
124
125			# print only specific fields from the response
126			$ gh api repos/{owner}/{repo}/issues --jq '.[].title'
127
128			# use a template for the output
129			$ gh api repos/{owner}/{repo}/issues --template \
130			  '{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
131
132			# list releases with GraphQL
133			$ gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
134			  query($name: String!, $owner: String!) {
135			    repository(owner: $owner, name: $name) {
136			      releases(last: 3) {
137			        nodes { tagName }
138			      }
139			    }
140			  }
141			'
142
143			# list all repositories for a user
144			$ gh api graphql --paginate -f query='
145			  query($endCursor: String) {
146			    viewer {
147			      repositories(first: 100, after: $endCursor) {
148			        nodes { nameWithOwner }
149			        pageInfo {
150			          hasNextPage
151			          endCursor
152			        }
153			      }
154			    }
155			  }
156			'
157		`),
158		Annotations: map[string]string{
159			"help:environment": heredoc.Doc(`
160				GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for
161				github.com API requests.
162
163				GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an
164				authentication token for API requests to GitHub Enterprise.
165
166				GH_HOST: make the request to a GitHub host other than github.com.
167			`),
168		},
169		Args: cobra.ExactArgs(1),
170		RunE: func(c *cobra.Command, args []string) error {
171			opts.RequestPath = args[0]
172			opts.RequestMethodPassed = c.Flags().Changed("method")
173
174			if c.Flags().Changed("hostname") {
175				if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
176					return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err)
177				}
178			}
179
180			if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" {
181				return cmdutil.FlagErrorf("the `--paginate` option is not supported for non-GET requests")
182			}
183
184			if err := cmdutil.MutuallyExclusive(
185				"the `--paginate` option is not supported with `--input`",
186				opts.Paginate,
187				opts.RequestInputFile != "",
188			); err != nil {
189				return err
190			}
191
192			if err := cmdutil.MutuallyExclusive(
193				"only one of `--template`, `--jq`, or `--silent` may be used",
194				opts.Silent,
195				opts.FilterOutput != "",
196				opts.Template != "",
197			); err != nil {
198				return err
199			}
200
201			if runF != nil {
202				return runF(&opts)
203			}
204			return apiRun(&opts)
205		},
206	}
207
208	cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "The GitHub hostname for the request (default \"github.com\")")
209	cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request")
210	cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format")
211	cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
212	cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format")
213	cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews")
214	cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
215	cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
216	cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)")
217	cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
218	cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format the response using a Go template")
219	cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax")
220	cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"")
221	return cmd
222}
223
224func apiRun(opts *ApiOptions) error {
225	params, err := parseFields(opts)
226	if err != nil {
227		return err
228	}
229
230	isGraphQL := opts.RequestPath == "graphql"
231	requestPath, err := fillPlaceholders(opts.RequestPath, opts)
232	if err != nil {
233		return fmt.Errorf("unable to expand placeholder in path: %w", err)
234	}
235	method := opts.RequestMethod
236	requestHeaders := opts.RequestHeaders
237	var requestBody interface{} = params
238
239	if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") {
240		method = "POST"
241	}
242
243	if opts.Paginate && !isGraphQL {
244		requestPath = addPerPage(requestPath, 100, params)
245	}
246
247	if opts.RequestInputFile != "" {
248		file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In)
249		if err != nil {
250			return err
251		}
252		defer file.Close()
253		requestPath = addQuery(requestPath, params)
254		requestBody = file
255		if size >= 0 {
256			requestHeaders = append([]string{fmt.Sprintf("Content-Length: %d", size)}, requestHeaders...)
257		}
258	}
259
260	if len(opts.Previews) > 0 {
261		requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews))
262	}
263
264	httpClient, err := opts.HttpClient()
265	if err != nil {
266		return err
267	}
268	if opts.CacheTTL > 0 {
269		httpClient = api.NewCachedClient(httpClient, opts.CacheTTL)
270	}
271
272	headersOutputStream := opts.IO.Out
273	if opts.Silent {
274		opts.IO.Out = ioutil.Discard
275	} else {
276		err := opts.IO.StartPager()
277		if err != nil {
278			return err
279		}
280		defer opts.IO.StopPager()
281	}
282
283	cfg, err := opts.Config()
284	if err != nil {
285		return err
286	}
287
288	host, err := cfg.DefaultHost()
289	if err != nil {
290		return err
291	}
292
293	if opts.Hostname != "" {
294		host = opts.Hostname
295	}
296
297	template := export.NewTemplate(opts.IO, opts.Template)
298
299	hasNextPage := true
300	for hasNextPage {
301		resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
302		if err != nil {
303			return err
304		}
305
306		endCursor, err := processResponse(resp, opts, headersOutputStream, &template)
307		if err != nil {
308			return err
309		}
310
311		if !opts.Paginate {
312			break
313		}
314
315		if isGraphQL {
316			hasNextPage = endCursor != ""
317			if hasNextPage {
318				params["endCursor"] = endCursor
319			}
320		} else {
321			requestPath, hasNextPage = findNextPage(resp)
322			requestBody = nil // prevent repeating GET parameters
323		}
324
325		if hasNextPage && opts.ShowResponseHeaders {
326			fmt.Fprint(opts.IO.Out, "\n")
327		}
328	}
329
330	return template.End()
331}
332
333func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer, template *export.Template) (endCursor string, err error) {
334	if opts.ShowResponseHeaders {
335		fmt.Fprintln(headersOutputStream, resp.Proto, resp.Status)
336		printHeaders(headersOutputStream, resp.Header, opts.IO.ColorEnabled())
337		fmt.Fprint(headersOutputStream, "\r\n")
338	}
339
340	if resp.StatusCode == 204 {
341		return
342	}
343	var responseBody io.Reader = resp.Body
344	defer resp.Body.Close()
345
346	isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type"))
347
348	var serverError string
349	if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) {
350		responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode)
351		if err != nil {
352			return
353		}
354	}
355
356	var bodyCopy *bytes.Buffer
357	isGraphQLPaginate := isJSON && resp.StatusCode == 200 && opts.Paginate && opts.RequestPath == "graphql"
358	if isGraphQLPaginate {
359		bodyCopy = &bytes.Buffer{}
360		responseBody = io.TeeReader(responseBody, bodyCopy)
361	}
362
363	if opts.FilterOutput != "" {
364		// TODO: reuse parsed query across pagination invocations
365		err = export.FilterJSON(opts.IO.Out, responseBody, opts.FilterOutput)
366		if err != nil {
367			return
368		}
369	} else if opts.Template != "" {
370		// TODO: reuse parsed template across pagination invocations
371		err = template.Execute(responseBody)
372		if err != nil {
373			return
374		}
375	} else if isJSON && opts.IO.ColorEnabled() {
376		err = jsoncolor.Write(opts.IO.Out, responseBody, "  ")
377	} else {
378		_, err = io.Copy(opts.IO.Out, responseBody)
379	}
380	if err != nil {
381		if errors.Is(err, syscall.EPIPE) {
382			err = nil
383		} else {
384			return
385		}
386	}
387
388	if serverError == "" && resp.StatusCode > 299 {
389		serverError = fmt.Sprintf("HTTP %d", resp.StatusCode)
390	}
391	if serverError != "" {
392		fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError)
393		if msg := api.ScopesSuggestion(resp); msg != "" {
394			fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg)
395		}
396		err = cmdutil.SilentError
397		return
398	}
399
400	if isGraphQLPaginate {
401		endCursor = findEndCursor(bodyCopy)
402	}
403
404	return
405}
406
407var placeholderRE = regexp.MustCompile(`(\:(owner|repo|branch)\b|\{[a-z]+\})`)
408
409// fillPlaceholders replaces placeholders with values from the current repository
410func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
411	var err error
412	return placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
413		var name string
414		if m[0] == ':' {
415			name = m[1:]
416		} else {
417			name = m[1 : len(m)-1]
418		}
419
420		switch name {
421		case "owner":
422			if baseRepo, e := opts.BaseRepo(); e == nil {
423				return baseRepo.RepoOwner()
424			} else {
425				err = e
426			}
427		case "repo":
428			if baseRepo, e := opts.BaseRepo(); e == nil {
429				return baseRepo.RepoName()
430			} else {
431				err = e
432			}
433		case "branch":
434			if branch, e := opts.Branch(); e == nil {
435				return branch
436			} else {
437				err = e
438			}
439		}
440		return m
441	}), err
442}
443
444func printHeaders(w io.Writer, headers http.Header, colorize bool) {
445	var names []string
446	for name := range headers {
447		if name == "Status" {
448			continue
449		}
450		names = append(names, name)
451	}
452	sort.Strings(names)
453
454	var headerColor, headerColorReset string
455	if colorize {
456		headerColor = "\x1b[1;34m" // bright blue
457		headerColorReset = "\x1b[m"
458	}
459	for _, name := range names {
460		fmt.Fprintf(w, "%s%s%s: %s\r\n", headerColor, name, headerColorReset, strings.Join(headers[name], ", "))
461	}
462}
463
464func parseFields(opts *ApiOptions) (map[string]interface{}, error) {
465	params := make(map[string]interface{})
466	for _, f := range opts.RawFields {
467		key, value, err := parseField(f)
468		if err != nil {
469			return params, err
470		}
471		params[key] = value
472	}
473	for _, f := range opts.MagicFields {
474		key, strValue, err := parseField(f)
475		if err != nil {
476			return params, err
477		}
478		value, err := magicFieldValue(strValue, opts)
479		if err != nil {
480			return params, fmt.Errorf("error parsing %q value: %w", key, err)
481		}
482		params[key] = value
483	}
484	return params, nil
485}
486
487func parseField(f string) (string, string, error) {
488	idx := strings.IndexRune(f, '=')
489	if idx == -1 {
490		return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign", f)
491	}
492	return f[0:idx], f[idx+1:], nil
493}
494
495func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) {
496	if strings.HasPrefix(v, "@") {
497		return opts.IO.ReadUserFile(v[1:])
498	}
499
500	if n, err := strconv.Atoi(v); err == nil {
501		return n, nil
502	}
503
504	switch v {
505	case "true":
506		return true, nil
507	case "false":
508		return false, nil
509	case "null":
510		return nil, nil
511	default:
512		return fillPlaceholders(v, opts)
513	}
514}
515
516func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) {
517	if fn == "-" {
518		return stdin, -1, nil
519	}
520
521	r, err := os.Open(fn)
522	if err != nil {
523		return r, -1, err
524	}
525
526	s, err := os.Stat(fn)
527	if err != nil {
528		return r, -1, err
529	}
530
531	return r, s.Size(), nil
532}
533
534func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) {
535	bodyCopy := &bytes.Buffer{}
536	b, err := ioutil.ReadAll(io.TeeReader(r, bodyCopy))
537	if err != nil {
538		return r, "", err
539	}
540
541	var parsedBody struct {
542		Message string
543		Errors  json.RawMessage
544	}
545	err = json.Unmarshal(b, &parsedBody)
546	if err != nil {
547		return bodyCopy, "", err
548	}
549
550	if len(parsedBody.Errors) > 0 && parsedBody.Errors[0] == '"' {
551		var stringError string
552		if err := json.Unmarshal(parsedBody.Errors, &stringError); err != nil {
553			return bodyCopy, "", err
554		}
555		if stringError != "" {
556			if parsedBody.Message != "" {
557				return bodyCopy, fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil
558			}
559			return bodyCopy, stringError, nil
560		}
561	}
562
563	if parsedBody.Message != "" {
564		return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil
565	}
566
567	if len(parsedBody.Errors) == 0 || parsedBody.Errors[0] != '[' {
568		return bodyCopy, "", nil
569	}
570
571	var errorObjects []json.RawMessage
572	if err := json.Unmarshal(parsedBody.Errors, &errorObjects); err != nil {
573		return bodyCopy, "", err
574	}
575
576	var objectError struct {
577		Message string
578	}
579	var errors []string
580	for _, rawErr := range errorObjects {
581		if len(rawErr) == 0 {
582			continue
583		}
584		if rawErr[0] == '{' {
585			err := json.Unmarshal(rawErr, &objectError)
586			if err != nil {
587				return bodyCopy, "", err
588			}
589			errors = append(errors, objectError.Message)
590		} else if rawErr[0] == '"' {
591			var stringError string
592			err := json.Unmarshal(rawErr, &stringError)
593			if err != nil {
594				return bodyCopy, "", err
595			}
596			errors = append(errors, stringError)
597		}
598	}
599
600	if len(errors) > 0 {
601		return bodyCopy, strings.Join(errors, "\n"), nil
602	}
603
604	return bodyCopy, "", nil
605}
606
607func previewNamesToMIMETypes(names []string) string {
608	types := []string{fmt.Sprintf("application/vnd.github.%s-preview+json", names[0])}
609	for _, p := range names[1:] {
610		types = append(types, fmt.Sprintf("application/vnd.github.%s-preview", p))
611	}
612	return strings.Join(types, ", ")
613}
614