github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/pkg/cmd/api/api.go (about)

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