|
1 | 1 | package cmd |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
| 5 | + "encoding/json" |
4 | 6 | "fmt" |
| 7 | + "io" |
| 8 | + "os" |
5 | 9 | "strings" |
| 10 | + "time" |
6 | 11 |
|
7 | 12 | "github.com/MakeNowJust/heredoc" |
| 13 | + "github.com/briandowns/spinner" |
| 14 | + "github.com/scalvert/glean-cli/pkg/config" |
| 15 | + "github.com/scalvert/glean-cli/pkg/http" |
| 16 | + "github.com/scalvert/glean-cli/pkg/jsoncolor" |
8 | 17 | "github.com/spf13/cobra" |
| 18 | + "golang.org/x/term" |
9 | 19 | ) |
10 | 20 |
|
11 | | -var apiCmd = &cobra.Command{ |
12 | | - Use: "api", |
13 | | - Short: "Make calls to the Glean API", |
14 | | - Long: heredoc.Doc(` |
15 | | - Send requests to the Glean API. |
16 | | - This command is similar to 'gh api', allowing you to interact with Glean endpoints directly. |
17 | | -
|
18 | | - Usage: |
19 | | - glean api --method POST /endpoint |
20 | | - glean api --method GET /search |
21 | | - `), |
22 | | - |
23 | | - RunE: func(cmd *cobra.Command, args []string) error { |
24 | | - method, _ := cmd.Flags().GetString("method") |
25 | | - endpoint := "" |
26 | | - if len(args) > 0 { |
27 | | - endpoint = args[0] |
28 | | - } |
| 21 | +type apiOptions struct { |
| 22 | + method string |
| 23 | + requestBody string |
| 24 | + inputFile string |
| 25 | + preview bool |
| 26 | + raw bool |
| 27 | + noColor bool |
| 28 | +} |
| 29 | + |
| 30 | +func newApiCmd() *cobra.Command { |
| 31 | + opts := apiOptions{} |
| 32 | + |
| 33 | + cmd := &cobra.Command{ |
| 34 | + Use: "api <endpoint>", |
| 35 | + Short: "Make authenticated requests to the Glean API", |
| 36 | + Long: heredoc.Doc(` |
| 37 | + Makes an authenticated HTTP request to the Glean API and prints the response. |
| 38 | +
|
| 39 | + The endpoint argument should be a path of a Glean API endpoint. For example: |
| 40 | + glean api search |
| 41 | + glean api users/me |
| 42 | +
|
| 43 | + The default HTTP request method is "GET". To use a different method, |
| 44 | + use the --method flag: |
| 45 | + glean api --method POST search |
| 46 | +
|
| 47 | + Request body can be provided via --raw-field, --field, or --input: |
| 48 | + echo '{"query": "rust programming"}' | glean api --method POST search |
| 49 | + glean api --method POST search --raw-field '{"query": "rust programming"}' |
| 50 | + glean api --method POST search --input request.json |
| 51 | +
|
| 52 | + Pass --preview to print the request details without actually sending it. |
| 53 | + Pass --no-color to disable colorized output (useful when piping to jq). |
| 54 | + `), |
| 55 | + Example: heredoc.Doc(` |
| 56 | + # Get the current user |
| 57 | + $ glean api users/me |
| 58 | +
|
| 59 | + # Search with parameters |
| 60 | + $ glean api search --method POST --raw-field '{"query": "rust programming"}' |
| 61 | +
|
| 62 | + # Search with parameters from a file |
| 63 | + $ glean api search --method POST --input search-params.json |
| 64 | +
|
| 65 | + # Preview the request |
| 66 | + $ glean api search --method POST --raw-field '{"query": "test"}' --preview |
| 67 | +
|
| 68 | + # Pipe to jq |
| 69 | + $ glean api search --no-color | jq .results |
| 70 | + `), |
| 71 | + Args: cobra.ExactArgs(1), |
| 72 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 73 | + cfg, err := config.LoadConfig() |
| 74 | + if err != nil { |
| 75 | + return fmt.Errorf("failed to load config: %w", err) |
| 76 | + } |
| 77 | + |
| 78 | + client, err := http.NewClient(cfg) |
| 79 | + if err != nil { |
| 80 | + return err |
| 81 | + } |
| 82 | + |
| 83 | + endpoint := args[0] |
| 84 | + |
| 85 | + var body interface{} |
| 86 | + if opts.requestBody != "" { |
| 87 | + if err := json.Unmarshal([]byte(opts.requestBody), &body); err != nil { |
| 88 | + return fmt.Errorf("invalid JSON in request body: %w", err) |
| 89 | + } |
| 90 | + } else if opts.inputFile != "" { |
| 91 | + data, err := os.ReadFile(opts.inputFile) |
| 92 | + if err != nil { |
| 93 | + return fmt.Errorf("failed to read input file: %w", err) |
| 94 | + } |
| 95 | + if err := json.Unmarshal(data, &body); err != nil { |
| 96 | + return fmt.Errorf("invalid JSON in input file: %w", err) |
| 97 | + } |
| 98 | + } else if !isatty(os.Stdin.Fd()) { |
| 99 | + data, err := io.ReadAll(os.Stdin) |
| 100 | + if err != nil { |
| 101 | + return fmt.Errorf("failed to read from stdin: %w", err) |
| 102 | + } |
| 103 | + if len(data) > 0 { |
| 104 | + if err := json.Unmarshal(data, &body); err != nil { |
| 105 | + return fmt.Errorf("invalid JSON from stdin: %w", err) |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + req := &http.Request{ |
| 111 | + Method: opts.method, |
| 112 | + Path: endpoint, |
| 113 | + Body: body, |
| 114 | + } |
| 115 | + |
| 116 | + if opts.preview { |
| 117 | + return previewRequest(req, opts.noColor) |
| 118 | + } |
| 119 | + |
| 120 | + // Only show spinner if we're in a terminal and not using --raw or --no-color |
| 121 | + useSpinner := isatty(os.Stderr.Fd()) && !opts.raw && !opts.noColor |
| 122 | + |
| 123 | + var s *spinner.Spinner |
| 124 | + if useSpinner { |
| 125 | + s = spinner.New(spinner.CharSets[9], 100*time.Millisecond) |
| 126 | + s.Suffix = " Making request to Glean API..." |
| 127 | + s.Writer = os.Stderr |
| 128 | + s.FinalMSG = "Request complete!\n" |
| 129 | + s.Start() |
| 130 | + defer s.Stop() |
| 131 | + } |
29 | 132 |
|
30 | | - fmt.Printf("Invoking Glean API with method=%s, endpoint=%s\n", strings.ToUpper(method), endpoint) |
31 | | - return nil |
32 | | - }, |
| 133 | + resp, err := client.SendRequest(req) |
| 134 | + if err != nil { |
| 135 | + return err |
| 136 | + } |
| 137 | + |
| 138 | + if opts.raw || opts.noColor { |
| 139 | + // For raw or no-color output, pretty print without colors |
| 140 | + var prettyJSON bytes.Buffer |
| 141 | + if err := json.Indent(&prettyJSON, resp, "", " "); err != nil { |
| 142 | + return fmt.Errorf("failed to format JSON response: %w", err) |
| 143 | + } |
| 144 | + fmt.Println(prettyJSON.String()) |
| 145 | + return nil |
| 146 | + } |
| 147 | + |
| 148 | + // Use colorized JSON output |
| 149 | + if err := jsoncolor.Write(os.Stdout, bytes.NewReader(resp), " "); err != nil { |
| 150 | + return fmt.Errorf("failed to format JSON response: %w", err) |
| 151 | + } |
| 152 | + |
| 153 | + return nil |
| 154 | + }, |
| 155 | + } |
| 156 | + |
| 157 | + cmd.Flags().StringVarP(&opts.method, "method", "X", "GET", "The HTTP method for the request") |
| 158 | + cmd.Flags().StringVar(&opts.requestBody, "raw-field", "", "Add a JSON string as the request body") |
| 159 | + cmd.Flags().StringVarP(&opts.inputFile, "input", "F", "", "The file to use as body for the request (use \"-\" to read from standard input)") |
| 160 | + cmd.Flags().BoolVar(&opts.preview, "preview", false, "Preview the API request without sending it") |
| 161 | + cmd.Flags().BoolVar(&opts.raw, "raw", false, "Print raw API response") |
| 162 | + cmd.Flags().BoolVar(&opts.noColor, "no-color", false, "Disable colorized output") |
| 163 | + |
| 164 | + return cmd |
33 | 165 | } |
34 | 166 |
|
35 | 167 | func init() { |
36 | | - rootCmd.AddCommand(apiCmd) |
| 168 | + rootCmd.AddCommand(newApiCmd()) |
| 169 | +} |
| 170 | + |
| 171 | +func previewRequest(req *http.Request, noColor bool) error { |
| 172 | + cfg, err := config.LoadConfig() |
| 173 | + if err != nil { |
| 174 | + return fmt.Errorf("failed to load config: %w", err) |
| 175 | + } |
| 176 | + |
| 177 | + client, err := http.NewClient(cfg) |
| 178 | + if err != nil { |
| 179 | + return err |
| 180 | + } |
| 181 | + |
| 182 | + fmt.Printf("Request Method: %s\n", req.Method) |
| 183 | + fmt.Printf("Request URL: %s\n", client.GetFullURL(req.Path)) |
| 184 | + |
| 185 | + fmt.Println("\nRequest Headers:") |
| 186 | + fmt.Printf(" Content-Type: application/json\n") |
| 187 | + if cfg.GleanToken != "" { |
| 188 | + fmt.Printf(" Authorization: Bearer %s\n", maskToken(cfg.GleanToken)) |
| 189 | + } |
| 190 | + if cfg.GleanEmail != "" { |
| 191 | + fmt.Printf(" X-Glean-User-Email: %s\n", cfg.GleanEmail) |
| 192 | + fmt.Printf(" X-Scio-Actas: %s\n", cfg.GleanEmail) |
| 193 | + } |
| 194 | + fmt.Printf(" X-Glean-Auth-Type: string\n") |
| 195 | + |
| 196 | + if req.Body != nil { |
| 197 | + fmt.Println("\nRequest Body:") |
| 198 | + bodyBytes, err := json.Marshal(req.Body) |
| 199 | + if err != nil { |
| 200 | + return fmt.Errorf("failed to format request body: %w", err) |
| 201 | + } |
| 202 | + |
| 203 | + if noColor { |
| 204 | + var prettyJSON bytes.Buffer |
| 205 | + if err := json.Indent(&prettyJSON, bodyBytes, " ", " "); err != nil { |
| 206 | + return fmt.Errorf("failed to format request body: %w", err) |
| 207 | + } |
| 208 | + fmt.Println(prettyJSON.String()) |
| 209 | + } else { |
| 210 | + if err := jsoncolor.Write(os.Stdout, bytes.NewReader(bodyBytes), " "); err != nil { |
| 211 | + return fmt.Errorf("failed to format request body: %w", err) |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + return nil |
| 217 | +} |
| 218 | + |
| 219 | +// maskToken masks most of the token characters for display |
| 220 | +func maskToken(token string) string { |
| 221 | + if len(token) <= 8 { |
| 222 | + return strings.Repeat("*", len(token)) |
| 223 | + } |
| 224 | + return token[:4] + strings.Repeat("*", len(token)-8) + token[len(token)-4:] |
| 225 | +} |
37 | 226 |
|
38 | | - apiCmd.Flags().StringP("method", "X", "GET", "HTTP method to use (GET, POST, etc.)") |
| 227 | +// isatty returns true if the given file descriptor is a terminal |
| 228 | +func isatty(fd uintptr) bool { |
| 229 | + return term.IsTerminal(int(fd)) |
39 | 230 | } |
0 commit comments