Skip to content

Commit 9022f7b

Browse files
committed
Adds full implementation of API command
1 parent c29827f commit 9022f7b

File tree

6 files changed

+466
-24
lines changed

6 files changed

+466
-24
lines changed

cmd/api.go

Lines changed: 214 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,230 @@
11
package cmd
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
7+
"io"
8+
"os"
59
"strings"
10+
"time"
611

712
"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"
817
"github.com/spf13/cobra"
18+
"golang.org/x/term"
919
)
1020

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+
}
29132

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
33165
}
34166

35167
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+
}
37226

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))
39230
}

glean

153 KB
Binary file not shown.

go.mod

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ require (
1212

1313
require (
1414
github.com/alessio/shellescape v1.4.1 // indirect
15+
github.com/briandowns/spinner v1.23.2 // indirect
1516
github.com/danieljoos/wincred v1.2.0 // indirect
1617
github.com/davecgh/go-spew v1.1.1 // indirect
18+
github.com/fatih/color v1.7.0 // indirect
1719
github.com/godbus/dbus/v5 v5.1.0 // indirect
1820
github.com/inconshreveable/mousetrap v1.1.0 // indirect
21+
github.com/mattn/go-colorable v0.1.2 // indirect
22+
github.com/mattn/go-isatty v0.0.8 // indirect
1923
github.com/pmezard/go-difflib v1.0.0 // indirect
2024
github.com/spf13/pflag v1.0.5 // indirect
21-
golang.org/x/sys v0.8.0 // indirect
25+
golang.org/x/sys v0.29.0 // indirect
26+
golang.org/x/term v0.28.0 // indirect
2227
gopkg.in/yaml.v3 v3.0.1 // indirect
2328
)

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
22
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
33
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
44
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
5+
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
6+
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
57
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
68
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
79
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
810
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
911
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
13+
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
1014
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
1115
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
1216
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
1317
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
1418
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
1519
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
20+
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
21+
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
22+
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
23+
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
1624
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1725
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1826
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -25,8 +33,13 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
2533
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2634
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
2735
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
36+
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
2837
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
2938
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
39+
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
40+
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
41+
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
42+
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
3043
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3144
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
3245
gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=

0 commit comments

Comments
 (0)