From 019f88845c9c0e82df51d777dda961d9cd428e5e Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 19 Jul 2026 13:24:43 -0700 Subject: [PATCH] feat(api): route /api/* platform paths and send experimental header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveAPIPath: /rest/* and /api/* pass verbatim; bare paths keep the /rest/api/v1/ default prefix - auto-send X-Glean-Include-Experimental on /api/* requests; --experimental flag forces it on classic endpoints; --preview displays it - update api schema registry entry WIP: not yet verified (go module cache unavailable — see session notes) Co-Authored-By: Claude Fable 5 --- cmd/api.go | 78 ++++++++++++++++++++++++++++++++----------- cmd/api_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/schema.go | 20 ++++++----- 3 files changed, 159 insertions(+), 28 deletions(-) diff --git a/cmd/api.go b/cmd/api.go index 4bd795e..dc0e199 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 } @@ -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 @@ -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) } @@ -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) @@ -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") @@ -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") @@ -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", }) } diff --git a/cmd/api_test.go b/cmd/api_test.go index 7db651f..1cdb3d0 100644 --- a/cmd/api_test.go +++ b/cmd/api_test.go @@ -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 diff --git a/cmd/schema.go b/cmd/schema.go index b64dc04..27ea185 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -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{