github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/api/api.go (about)

     1  package api
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"os"
    10  	"regexp"
    11  	"sort"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/MakeNowJust/heredoc"
    17  	"github.com/ungtb10d/cli/v2/api"
    18  	"github.com/ungtb10d/cli/v2/internal/config"
    19  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    20  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    21  	"github.com/ungtb10d/cli/v2/pkg/cmd/factory"
    22  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    23  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    24  	"github.com/ungtb10d/cli/v2/pkg/jsoncolor"
    25  	"github.com/cli/go-gh/pkg/jq"
    26  	"github.com/cli/go-gh/pkg/template"
    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
    74  			argument will get replaced with values from the repository of the current
    75  			directory or the repository specified in the GH_REPO environment variable.
    76  			Note that in some shells, for example PowerShell, you may need to enclose
    77  			any value that contains "{...}" in quotes to prevent the shell from
    78  			applying special meaning to curly braces.
    79  
    80  			The default HTTP request method is "GET" normally and "POST" if any parameters
    81  			were added. Override the method with %[1]s--method%[1]s.
    82  
    83  			Pass one or more %[1]s-f/--raw-field%[1]s values in "key=value" format to add static string
    84  			parameters to the request payload. To add non-string or otherwise dynamic values, see
    85  			%[1]s--field%[1]s below. Note that adding request parameters will automatically switch the
    86  			request method to POST. To send the parameters as a GET query string instead, use
    87  			%[1]s--method GET%[1]s.
    88  
    89  			The %[1]s-F/--field%[1]s flag has magic type conversion based on the format of the value:
    90  
    91  			- literal values "true", "false", "null", and integer numbers get converted to
    92  			  appropriate JSON types;
    93  			- placeholder values "{owner}", "{repo}", and "{branch}" get populated with values
    94  			  from the repository of the current directory;
    95  			- if the value starts with "@", the rest of the value is interpreted as a
    96  			  filename to read the value from. Pass "-" to read from standard input.
    97  
    98  			For GraphQL requests, all fields other than "query" and "operationName" are
    99  			interpreted as GraphQL variables.
   100  
   101  			Raw request body may be passed from the outside via a file specified by %[1]s--input%[1]s.
   102  			Pass "-" to read from standard input. In this mode, parameters specified via
   103  			%[1]s--field%[1]s flags are serialized into URL query parameters.
   104  
   105  			In %[1]s--paginate%[1]s mode, all pages of results will sequentially be requested until
   106  			there are no more pages of results. For GraphQL requests, this requires that the
   107  			original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the
   108  			%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection.
   109  		`, "`"),
   110  		Example: heredoc.Doc(`
   111  			# list releases in the current repository
   112  			$ gh api repos/{owner}/{repo}/releases
   113  
   114  			# post an issue comment
   115  			$ gh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI'
   116  
   117  			# add parameters to a GET request
   118  			$ gh api -X GET search/issues -f q='repo:ungtb10d/cli is:open remote'
   119  
   120  			# set a custom HTTP header
   121  			$ gh api -H 'Accept: application/vnd.github.v3.raw+json' ...
   122  
   123  			# opt into GitHub API previews
   124  			$ gh api --preview baptiste,nebula ...
   125  
   126  			# print only specific fields from the response
   127  			$ gh api repos/{owner}/{repo}/issues --jq '.[].title'
   128  
   129  			# use a template for the output
   130  			$ gh api repos/{owner}/{repo}/issues --template \
   131  			  '{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
   132  
   133  			# list releases with GraphQL
   134  			$ gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
   135  			  query($name: String!, $owner: String!) {
   136  			    repository(owner: $owner, name: $name) {
   137  			      releases(last: 3) {
   138  			        nodes { tagName }
   139  			      }
   140  			    }
   141  			  }
   142  			'
   143  
   144  			# list all repositories for a user
   145  			$ gh api graphql --paginate -f query='
   146  			  query($endCursor: String) {
   147  			    viewer {
   148  			      repositories(first: 100, after: $endCursor) {
   149  			        nodes { nameWithOwner }
   150  			        pageInfo {
   151  			          hasNextPage
   152  			          endCursor
   153  			        }
   154  			      }
   155  			    }
   156  			  }
   157  			'
   158  		`),
   159  		Annotations: map[string]string{
   160  			"help:environment": heredoc.Doc(`
   161  				GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for
   162  				github.com API requests.
   163  
   164  				GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an
   165  				authentication token for API requests to GitHub Enterprise.
   166  
   167  				GH_HOST: make the request to a GitHub host other than github.com.
   168  			`),
   169  		},
   170  		Args: cobra.ExactArgs(1),
   171  		PreRun: func(c *cobra.Command, args []string) {
   172  			opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "")
   173  		},
   174  		RunE: func(c *cobra.Command, args []string) error {
   175  			opts.RequestPath = args[0]
   176  			opts.RequestMethodPassed = c.Flags().Changed("method")
   177  
   178  			if c.Flags().Changed("hostname") {
   179  				if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
   180  					return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err)
   181  				}
   182  			}
   183  
   184  			if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" {
   185  				return cmdutil.FlagErrorf("the `--paginate` option is not supported for non-GET requests")
   186  			}
   187  
   188  			if err := cmdutil.MutuallyExclusive(
   189  				"the `--paginate` option is not supported with `--input`",
   190  				opts.Paginate,
   191  				opts.RequestInputFile != "",
   192  			); err != nil {
   193  				return err
   194  			}
   195  
   196  			if err := cmdutil.MutuallyExclusive(
   197  				"only one of `--template`, `--jq`, or `--silent` may be used",
   198  				opts.Silent,
   199  				opts.FilterOutput != "",
   200  				opts.Template != "",
   201  			); err != nil {
   202  				return err
   203  			}
   204  
   205  			if runF != nil {
   206  				return runF(&opts)
   207  			}
   208  			return apiRun(&opts)
   209  		},
   210  	}
   211  
   212  	cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "The GitHub hostname for the request (default \"github.com\")")
   213  	cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request")
   214  	cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format")
   215  	cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
   216  	cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format")
   217  	cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "GitHub API preview `names` to request (without the \"-preview\" suffix)")
   218  	cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response status line and headers in the output")
   219  	cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
   220  	cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)")
   221  	cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
   222  	cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format JSON output using a Go template; see \"gh help formatting\"")
   223  	cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax")
   224  	cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"")
   225  	return cmd
   226  }
   227  
   228  func apiRun(opts *ApiOptions) error {
   229  	params, err := parseFields(opts)
   230  	if err != nil {
   231  		return err
   232  	}
   233  
   234  	isGraphQL := opts.RequestPath == "graphql"
   235  	requestPath, err := fillPlaceholders(opts.RequestPath, opts)
   236  	if err != nil {
   237  		return fmt.Errorf("unable to expand placeholder in path: %w", err)
   238  	}
   239  	method := opts.RequestMethod
   240  	requestHeaders := opts.RequestHeaders
   241  	var requestBody interface{} = params
   242  
   243  	if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") {
   244  		method = "POST"
   245  	}
   246  
   247  	if opts.Paginate && !isGraphQL {
   248  		requestPath = addPerPage(requestPath, 100, params)
   249  	}
   250  
   251  	if opts.RequestInputFile != "" {
   252  		file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In)
   253  		if err != nil {
   254  			return err
   255  		}
   256  		defer file.Close()
   257  		requestPath = addQuery(requestPath, params)
   258  		requestBody = file
   259  		if size >= 0 {
   260  			requestHeaders = append([]string{fmt.Sprintf("Content-Length: %d", size)}, requestHeaders...)
   261  		}
   262  	}
   263  
   264  	if len(opts.Previews) > 0 {
   265  		requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews))
   266  	}
   267  
   268  	httpClient, err := opts.HttpClient()
   269  	if err != nil {
   270  		return err
   271  	}
   272  	if opts.CacheTTL > 0 {
   273  		httpClient = api.NewCachedHTTPClient(httpClient, opts.CacheTTL)
   274  	}
   275  
   276  	if !opts.Silent {
   277  		if err := opts.IO.StartPager(); err == nil {
   278  			defer opts.IO.StopPager()
   279  		} else {
   280  			fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
   281  		}
   282  	}
   283  
   284  	var bodyWriter io.Writer = opts.IO.Out
   285  	var headersWriter io.Writer = opts.IO.Out
   286  	if opts.Silent {
   287  		bodyWriter = io.Discard
   288  	}
   289  
   290  	cfg, err := opts.Config()
   291  	if err != nil {
   292  		return err
   293  	}
   294  
   295  	host, _ := cfg.DefaultHost()
   296  
   297  	if opts.Hostname != "" {
   298  		host = opts.Hostname
   299  	}
   300  
   301  	tmpl := template.New(bodyWriter, opts.IO.TerminalWidth(), opts.IO.ColorEnabled())
   302  	err = tmpl.Parse(opts.Template)
   303  	if err != nil {
   304  		return err
   305  	}
   306  
   307  	hasNextPage := true
   308  	for hasNextPage {
   309  		resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
   310  		if err != nil {
   311  			return err
   312  		}
   313  
   314  		endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl)
   315  		if err != nil {
   316  			return err
   317  		}
   318  
   319  		if !opts.Paginate {
   320  			break
   321  		}
   322  
   323  		if isGraphQL {
   324  			hasNextPage = endCursor != ""
   325  			if hasNextPage {
   326  				params["endCursor"] = endCursor
   327  			}
   328  		} else {
   329  			requestPath, hasNextPage = findNextPage(resp)
   330  			requestBody = nil // prevent repeating GET parameters
   331  		}
   332  
   333  		if hasNextPage && opts.ShowResponseHeaders {
   334  			fmt.Fprint(opts.IO.Out, "\n")
   335  		}
   336  	}
   337  
   338  	return tmpl.Flush()
   339  }
   340  
   341  func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template) (endCursor string, err error) {
   342  	if opts.ShowResponseHeaders {
   343  		fmt.Fprintln(headersWriter, resp.Proto, resp.Status)
   344  		printHeaders(headersWriter, resp.Header, opts.IO.ColorEnabled())
   345  		fmt.Fprint(headersWriter, "\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 != "" && serverError == "" {
   372  		// TODO: reuse parsed query across pagination invocations
   373  		err = jq.Evaluate(responseBody, bodyWriter, opts.FilterOutput)
   374  		if err != nil {
   375  			return
   376  		}
   377  	} else if opts.Template != "" && serverError == "" {
   378  		err = template.Execute(responseBody)
   379  		if err != nil {
   380  			return
   381  		}
   382  	} else if isJSON && opts.IO.ColorEnabled() {
   383  		err = jsoncolor.Write(bodyWriter, responseBody, "  ")
   384  	} else {
   385  		_, err = io.Copy(bodyWriter, responseBody)
   386  	}
   387  	if err != nil {
   388  		return
   389  	}
   390  
   391  	if serverError == "" && resp.StatusCode > 299 {
   392  		serverError = fmt.Sprintf("HTTP %d", resp.StatusCode)
   393  	}
   394  	if serverError != "" {
   395  		fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError)
   396  		if msg := api.ScopesSuggestion(resp); msg != "" {
   397  			fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg)
   398  		}
   399  		if u := factory.SSOURL(); u != "" {
   400  			fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u)
   401  		}
   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|\{[a-z]+\})`)
   414  
   415  // fillPlaceholders replaces placeholders with values from the current repository
   416  func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
   417  	var err error
   418  	return placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
   419  		var name string
   420  		if m[0] == ':' {
   421  			name = m[1:]
   422  		} else {
   423  			name = m[1 : len(m)-1]
   424  		}
   425  
   426  		switch name {
   427  		case "owner":
   428  			if baseRepo, e := opts.BaseRepo(); e == nil {
   429  				return baseRepo.RepoOwner()
   430  			} else {
   431  				err = e
   432  			}
   433  		case "repo":
   434  			if baseRepo, e := opts.BaseRepo(); e == nil {
   435  				return baseRepo.RepoName()
   436  			} else {
   437  				err = e
   438  			}
   439  		case "branch":
   440  			if branch, e := opts.Branch(); e == nil {
   441  				return branch
   442  			} else {
   443  				err = e
   444  			}
   445  		}
   446  		return m
   447  	}), err
   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 := io.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 bodyCopy, "", err
   554  	}
   555  
   556  	if len(parsedBody.Errors) > 0 && parsedBody.Errors[0] == '"' {
   557  		var stringError string
   558  		if err := json.Unmarshal(parsedBody.Errors, &stringError); err != nil {
   559  			return bodyCopy, "", err
   560  		}
   561  		if stringError != "" {
   562  			if parsedBody.Message != "" {
   563  				return bodyCopy, fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil
   564  			}
   565  			return bodyCopy, stringError, nil
   566  		}
   567  	}
   568  
   569  	if parsedBody.Message != "" {
   570  		return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil
   571  	}
   572  
   573  	if len(parsedBody.Errors) == 0 || parsedBody.Errors[0] != '[' {
   574  		return bodyCopy, "", nil
   575  	}
   576  
   577  	var errorObjects []json.RawMessage
   578  	if err := json.Unmarshal(parsedBody.Errors, &errorObjects); err != nil {
   579  		return bodyCopy, "", err
   580  	}
   581  
   582  	var objectError struct {
   583  		Message string
   584  	}
   585  	var errors []string
   586  	for _, rawErr := range errorObjects {
   587  		if len(rawErr) == 0 {
   588  			continue
   589  		}
   590  		if rawErr[0] == '{' {
   591  			err := json.Unmarshal(rawErr, &objectError)
   592  			if err != nil {
   593  				return bodyCopy, "", err
   594  			}
   595  			errors = append(errors, objectError.Message)
   596  		} else if rawErr[0] == '"' {
   597  			var stringError string
   598  			err := json.Unmarshal(rawErr, &stringError)
   599  			if err != nil {
   600  				return bodyCopy, "", err
   601  			}
   602  			errors = append(errors, stringError)
   603  		}
   604  	}
   605  
   606  	if len(errors) > 0 {
   607  		return bodyCopy, strings.Join(errors, "\n"), nil
   608  	}
   609  
   610  	return bodyCopy, "", nil
   611  }
   612  
   613  func previewNamesToMIMETypes(names []string) string {
   614  	types := []string{fmt.Sprintf("application/vnd.github.%s-preview+json", names[0])}
   615  	for _, p := range names[1:] {
   616  		types = append(types, fmt.Sprintf("application/vnd.github.%s-preview", p))
   617  	}
   618  	return strings.Join(types, ", ")
   619  }