Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 59 additions & 19 deletions cmd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,19 @@ import (
"golang.org/x/term"
)

// experimentalHeader opts a request into experimental (platform) API
// endpoints. Without it, experimental endpoints return a hidden 404.
const experimentalHeader = "X-Glean-Include-Experimental"

// APIOptions holds configuration for the API command.
type APIOptions struct {
method string
requestBody string
inputFile string
preview bool
raw bool
noColor bool
method string
requestBody string
inputFile string
preview bool
raw bool
noColor bool
experimental bool
}

// NewCmdAPI creates and returns the api command.
Expand All @@ -41,10 +46,17 @@ func NewCmdAPI() *cobra.Command {
Long: heredoc.Doc(`
Makes an authenticated HTTP request to the Glean API and prints the response.

The endpoint argument should be a path of a Glean API endpoint. For example:
The endpoint argument should be a path of a Glean API endpoint. Bare paths
default to the classic REST API under /rest/api/v1/:
glean api search
glean api users/me

Paths starting with /rest/ or /api/ are used verbatim, so the new platform
APIs are reachable directly. Requests to /api/* automatically send the
X-Glean-Include-Experimental header (use --experimental to force it on
classic endpoints):
glean api /api/search --method POST --raw-field '{"query": "test"}'

The default HTTP request method is "GET". To use a different method,
use the --method flag:
glean api --method POST search
Expand All @@ -64,6 +76,9 @@ func NewCmdAPI() *cobra.Command {
# Search with parameters
$ glean api search --method POST --raw-field '{"query": "rust programming"}'

# Platform API search (experimental header sent automatically)
$ glean api /api/search --method POST --raw-field '{"query": "rust programming"}'

# Search with parameters from a file
$ glean api search --method POST --input search-params.json

Expand Down Expand Up @@ -114,7 +129,7 @@ func NewCmdAPI() *cobra.Command {
}

if opts.preview {
return previewRequest(cmd, cfg, opts.method, endpoint, body, opts.noColor)
return previewRequest(cmd, cfg, opts, endpoint, body)
}

useSpinner := term.IsTerminal(int(os.Stderr.Fd())) && !opts.raw && !opts.noColor
Expand All @@ -128,7 +143,7 @@ func NewCmdAPI() *cobra.Command {
defer s.Stop()
}

resp, err := rawAPIRequest(cmd.Context(), cfg, opts.method, endpoint, body)
resp, err := rawAPIRequest(cmd.Context(), cfg, opts, endpoint, body)
if err != nil {
return err
}
Expand All @@ -146,20 +161,39 @@ func NewCmdAPI() *cobra.Command {
cmd.Flags().BoolVar(&opts.preview, "preview", false, "Preview the API request without sending it")
cmd.Flags().BoolVar(&opts.raw, "raw", false, "Print raw API response")
cmd.Flags().BoolVar(&opts.noColor, "no-color", false, "Disable colorized output")
cmd.Flags().BoolVar(&opts.experimental, "experimental", false, "Send the "+experimentalHeader+" header (automatic for /api/* paths)")

return cmd
}

// apiFullURL returns the full REST API URL for an endpoint path.
func apiFullURL(cfg *config.Config, path string) string {
if !strings.HasPrefix(path, "/rest/api/v1/") {
path = "/rest/api/v1/" + strings.TrimPrefix(path, "/")
// resolveAPIPath normalizes an endpoint argument to a server-relative path.
// Paths under /rest/ or /api/ are used verbatim (the platform APIs live at
// /api/*); anything else keeps the historical default prefix /rest/api/v1/.
func resolveAPIPath(path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.HasPrefix(path, "/rest/") || strings.HasPrefix(path, "/api/") {
return path
}
return cfg.GleanServerURL + path
return "/rest/api/v1" + path
}

// apiFullURL returns the full API URL for an endpoint path.
func apiFullURL(cfg *config.Config, path string) string {
return cfg.GleanServerURL + resolveAPIPath(path)
}

// sendExperimentalHeader reports whether the request should carry the
// X-Glean-Include-Experimental header: always for platform (/api/*) paths —
// without it experimental endpoints answer with a hidden 404 that reads like
// a typo'd path — or when forced via --experimental.
func sendExperimentalHeader(opts APIOptions, endpoint string) bool {
return opts.experimental || strings.HasPrefix(resolveAPIPath(endpoint), "/api/")
}

// rawAPIRequest makes an authenticated HTTP request to the Glean API.
func rawAPIRequest(ctx context.Context, cfg *config.Config, method, endpoint string, body map[string]interface{}) ([]byte, error) {
func rawAPIRequest(ctx context.Context, cfg *config.Config, opts APIOptions, endpoint string, body map[string]interface{}) ([]byte, error) {
url := apiFullURL(cfg, endpoint)

var bodyReader io.Reader
Expand All @@ -171,7 +205,7 @@ func rawAPIRequest(ctx context.Context, cfg *config.Config, method, endpoint str
bodyReader = bytes.NewReader(bodyBytes)
}

req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
req, err := http.NewRequestWithContext(ctx, opts.method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
Expand All @@ -183,6 +217,9 @@ func rawAPIRequest(ctx context.Context, cfg *config.Config, method, endpoint str
if authType != "" {
req.Header.Set("X-Glean-Auth-Type", authType)
}
if sendExperimentalHeader(opts, endpoint) {
req.Header.Set(experimentalHeader, "true")
}

httpClient := httputil.NewHTTPClient(30 * time.Second)
httpResp, err := httpClient.Do(req)
Expand Down Expand Up @@ -215,9 +252,9 @@ func rawAPIRequest(ctx context.Context, cfg *config.Config, method, endpoint str
return respBody, nil
}

func previewRequest(cmd *cobra.Command, cfg *config.Config, method, endpoint string, body map[string]interface{}, noColor bool) error {
func previewRequest(cmd *cobra.Command, cfg *config.Config, opts APIOptions, endpoint string, body map[string]interface{}) error {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "Request Method: %s\n", method)
fmt.Fprintf(w, "Request Method: %s\n", opts.method)
fmt.Fprintf(w, "Request URL: %s\n", apiFullURL(cfg, endpoint))
fmt.Fprintf(w, "\nRequest Headers:\n")
fmt.Fprintf(w, " Content-Type: application/json\n")
Expand All @@ -228,6 +265,9 @@ func previewRequest(cmd *cobra.Command, cfg *config.Config, method, endpoint str
if authType != "" {
fmt.Fprintf(w, " X-Glean-Auth-Type: %s\n", authType)
}
if sendExperimentalHeader(opts, endpoint) {
fmt.Fprintf(w, " %s: true\n", experimentalHeader)
}

if body != nil {
fmt.Fprintf(w, "\nRequest Body:\n")
Expand All @@ -236,7 +276,7 @@ func previewRequest(cmd *cobra.Command, cfg *config.Config, method, endpoint str
return fmt.Errorf("failed to format request body: %w", err)
}
return output.Write(w, bodyBytes, output.Options{
NoColor: noColor,
NoColor: opts.noColor,
Format: "json",
})
}
Expand Down
89 changes: 89 additions & 0 deletions cmd/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,95 @@ func TestAPICommandPreviewShowsAuthHeader(t *testing.T) {
assert.NotContains(t, out, "Bearer \n", "auth token must not be empty")
}

func TestResolveAPIPath(t *testing.T) {
tests := []struct {
in string
want string
}{
{"search", "/rest/api/v1/search"},
{"/search", "/rest/api/v1/search"},
{"users/me", "/rest/api/v1/users/me"},
{"/rest/api/v1/search", "/rest/api/v1/search"},
{"rest/api/v1/search", "/rest/api/v1/search"},
{"/api/search", "/api/search"},
{"api/search", "/api/search"},
{"/api/agents/search", "/api/agents/search"},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
assert.Equal(t, tt.want, resolveAPIPath(tt.in))
})
}
}

func TestSendExperimentalHeader(t *testing.T) {
tests := []struct {
name string
endpoint string
force bool
want bool
}{
{"classic path, no flag", "search", false, false},
{"classic path, forced", "search", true, true},
{"platform path, no flag", "/api/search", false, true},
{"platform path without slash", "api/search", false, true},
{"explicit rest path, no flag", "/rest/api/v1/search", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sendExperimentalHeader(APIOptions{experimental: tt.force}, tt.endpoint)
assert.Equal(t, tt.want, got)
})
}
}

func TestAPICommandPreviewExperimentalHeader(t *testing.T) {
t.Setenv("GLEAN_API_TOKEN", "test-token-for-preview")
t.Setenv("GLEAN_SERVER_URL", "https://test.glean.com")

tests := []struct {
name string
args []string
wantHeader bool
wantURL string
}{
{
name: "platform path sends header automatically",
args: []string{"--preview", "--method", "POST", "--raw-field", `{"query":"test"}`, "/api/search"},
wantHeader: true,
wantURL: "https://test.glean.com/api/search",
},
{
name: "classic path omits header",
args: []string{"--preview", "--method", "POST", "--raw-field", `{"query":"test"}`, "search"},
wantHeader: false,
wantURL: "https://test.glean.com/rest/api/v1/search",
},
{
name: "classic path with --experimental sends header",
args: []string{"--preview", "--method", "POST", "--raw-field", `{"query":"test"}`, "--experimental", "search"},
wantHeader: true,
wantURL: "https://test.glean.com/rest/api/v1/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := bytes.NewBufferString("")
cmd := NewCmdAPI()
cmd.SetOut(b)
cmd.SetArgs(tt.args)
require.NoError(t, cmd.Execute())
out := b.String()
assert.Contains(t, out, tt.wantURL)
if tt.wantHeader {
assert.Contains(t, out, "X-Glean-Include-Experimental: true")
} else {
assert.NotContains(t, out, "X-Glean-Include-Experimental")
}
})
}
}

func TestApiCmd(t *testing.T) {
tests := []struct {
name string
Expand Down
20 changes: 11 additions & 9 deletions cmd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,19 @@ glean chat --json '{"messages":[{"author":"USER","messageType":"CONTENT","fragme

schema.Register(schema.CommandSchema{
Command: "api",
Description: "Make a raw authenticated HTTP request to any Glean REST API endpoint.",
Description: "Make a raw authenticated HTTP request to any Glean API endpoint. Bare paths default to /rest/api/v1/; /rest/* and /api/* paths are used verbatim. Requests to /api/* (platform APIs) automatically send the X-Glean-Include-Experimental header.",
Flags: map[string]schema.FlagSchema{
"--method": {Type: "enum", Enum: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, Default: "GET", Description: "HTTP method"},
"--raw-field": {Type: "string", Description: "JSON request body as a string"},
"--input": {Type: "string", Description: "Path to a JSON file to use as request body"},
"--preview": {Type: "boolean", Default: false, Description: "Print request details without sending"},
"--raw": {Type: "boolean", Default: false, Description: "Print raw response without syntax highlighting"},
"--no-color": {Type: "boolean", Default: false, Description: "Disable colorized output"},
"--dry-run": {Type: "boolean", Default: false, Description: "Same as --preview"},
"--method": {Type: "enum", Enum: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, Default: "GET", Description: "HTTP method"},
"--raw-field": {Type: "string", Description: "JSON request body as a string"},
"--input": {Type: "string", Description: "Path to a JSON file to use as request body"},
"--preview": {Type: "boolean", Default: false, Description: "Print request details without sending"},
"--raw": {Type: "boolean", Default: false, Description: "Print raw response without syntax highlighting"},
"--no-color": {Type: "boolean", Default: false, Description: "Disable colorized output"},
"--experimental": {Type: "boolean", Default: false, Description: "Send the X-Glean-Include-Experimental header (automatic for /api/* paths)"},
"--dry-run": {Type: "boolean", Default: false, Description: "Same as --preview"},
},
Example: `glean api search --method POST --raw-field '{"query":"test"}' --no-color | jq .results`,
Example: `glean api search --method POST --raw-field '{"query":"test"}' --no-color | jq .results
glean api /api/search --method POST --raw-field '{"query":"test"}' | jq .results`,
})

schema.Register(schema.CommandSchema{
Expand Down