feat: add buzz api command for authenticated Beeminder API requests#228
feat: add buzz api command for authenticated Beeminder API requests#228
buzz api command for authenticated Beeminder API requests#228Conversation
Co-authored-by: narthur <4655422+narthur@users.noreply.github.com>
buzz api command for authenticated Beeminder API requests
🚀 Pre-release Build ReadyTest builds are ready! Install directly using the # Install the pre-release
bin install https://github.com/PinePeakDigital/buzz/releases/tag/pr-228-latest buzz-pr-228# Run the pre-release
buzz-pr-228# Uninstall the pre-release
bin remove buzz-pr-228Direct Download LinksOr download binaries directly from the pre-release page:
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new buzz api command that provides a general-purpose interface for making authenticated HTTP requests to the Beeminder API. The command uses credentials stored in ~/.buzzrc and automatically injects the auth token, eliminating the need for manual authentication token handling.
Changes:
- Adds a new
buzz api <method> <path> [key=value ...]command supporting GET, POST, PUT, DELETE, and PATCH methods with automatic auth token injection and username substitution via{user}placeholder - Implements testable
makeAPIRequest()function with proper HTTP method routing (query strings for GET/DELETE, form body for POST/PUT/PATCH) - Includes comprehensive test coverage for core functionality including GET/POST methods, parameter handling, user placeholder substitution, and JSON response handling
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| main.go | Added makeAPIRequest() core function and handleApiCommand() handler; integrated api command into dispatch switch; updated help text and command list; added necessary imports (io, net/http, net/url) |
| api_test.go | Added 7 unit tests covering GET/POST request dispatch, {user} placeholder substitution, parameter injection to query strings and request bodies, invalid parameter format handling, and non-JSON response handling |
main.go
Outdated
| // Replace {user} placeholder with the configured username | ||
| path = strings.ReplaceAll(path, "{user}", config.Username) |
There was a problem hiding this comment.
The username substitution in the path doesn't use URL escaping, which could cause issues if the username contains special URL characters. Other parts of the codebase use url.PathEscape() for similar path substitutions (see beeminder.go:520, 577, 650). Consider using strings.ReplaceAll(path, "{user}", url.PathEscape(config.Username)) instead.
| // Replace {user} placeholder with the configured username | |
| path = strings.ReplaceAll(path, "{user}", config.Username) | |
| // Replace {user} placeholder with the configured username, escaping it for use in the URL path | |
| path = strings.ReplaceAll(path, "{user}", url.PathEscape(config.Username)) |
There was a problem hiding this comment.
Fixed in 7b7a166 — {user} is now replaced using url.PathEscape(config.Username), consistent with how other path substitutions work in beeminder.go.
| t.Errorf("value missing from body; body = %q", capturedBody) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
The tests cover GET and POST methods but don't test PUT, DELETE, or PATCH methods. Since the command supports all five HTTP methods (line 1707-1709 in main.go), consider adding tests for the remaining three methods to ensure they correctly send parameters in the request body rather than query string.
| // TestMakeAPIRequestPUT tests that PUT params are sent in the request body | |
| func TestMakeAPIRequestPUT(t *testing.T) { | |
| var capturedBody string | |
| var capturedQuery string | |
| mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodPut { | |
| t.Errorf("expected PUT, got %s", r.Method) | |
| } | |
| capturedQuery = r.URL.RawQuery | |
| if err := r.ParseForm(); err != nil { | |
| t.Fatalf("failed to parse form: %v", err) | |
| } | |
| capturedBody = r.PostForm.Encode() | |
| w.Header().Set("Content-Type", "application/json") | |
| fmt.Fprintln(w, `{"id":"dp1"}`) | |
| })) | |
| defer mockServer.Close() | |
| config := &Config{ | |
| Username: "alice", | |
| AuthToken: "tok123", | |
| BaseURL: mockServer.URL, | |
| } | |
| _, status, err := makeAPIRequest(config, "PUT", | |
| "/api/v1/users/alice/goals/pushups/datapoints.json", | |
| []string{"value=5", "comment=test"}) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| if status != http.StatusOK { | |
| t.Errorf("status = %d, want 200", status) | |
| } | |
| if !strings.Contains(capturedBody, "auth_token=tok123") { | |
| t.Errorf("auth_token missing from body; body = %q", capturedBody) | |
| } | |
| if !strings.Contains(capturedBody, "value=5") { | |
| t.Errorf("value missing from body; body = %q", capturedBody) | |
| } | |
| if strings.Contains(capturedQuery, "value=5") { | |
| t.Errorf("value should not be in query string; query = %q", capturedQuery) | |
| } | |
| } | |
| // TestMakeAPIRequestDELETE tests that DELETE params are sent in the request body | |
| func TestMakeAPIRequestDELETE(t *testing.T) { | |
| var capturedBody string | |
| var capturedQuery string | |
| mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodDelete { | |
| t.Errorf("expected DELETE, got %s", r.Method) | |
| } | |
| capturedQuery = r.URL.RawQuery | |
| if err := r.ParseForm(); err != nil { | |
| t.Fatalf("failed to parse form: %v", err) | |
| } | |
| capturedBody = r.PostForm.Encode() | |
| w.Header().Set("Content-Type", "application/json") | |
| fmt.Fprintln(w, `{"id":"dp1"}`) | |
| })) | |
| defer mockServer.Close() | |
| config := &Config{ | |
| Username: "alice", | |
| AuthToken: "tok123", | |
| BaseURL: mockServer.URL, | |
| } | |
| _, status, err := makeAPIRequest(config, "DELETE", | |
| "/api/v1/users/alice/goals/pushups/datapoints.json", | |
| []string{"value=5", "comment=test"}) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| if status != http.StatusOK { | |
| t.Errorf("status = %d, want 200", status) | |
| } | |
| if !strings.Contains(capturedBody, "auth_token=tok123") { | |
| t.Errorf("auth_token missing from body; body = %q", capturedBody) | |
| } | |
| if !strings.Contains(capturedBody, "value=5") { | |
| t.Errorf("value missing from body; body = %q", capturedBody) | |
| } | |
| if strings.Contains(capturedQuery, "value=5") { | |
| t.Errorf("value should not be in query string; query = %q", capturedQuery) | |
| } | |
| } | |
| // TestMakeAPIRequestPATCH tests that PATCH params are sent in the request body | |
| func TestMakeAPIRequestPATCH(t *testing.T) { | |
| var capturedBody string | |
| var capturedQuery string | |
| mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodPatch { | |
| t.Errorf("expected PATCH, got %s", r.Method) | |
| } | |
| capturedQuery = r.URL.RawQuery | |
| if err := r.ParseForm(); err != nil { | |
| t.Fatalf("failed to parse form: %v", err) | |
| } | |
| capturedBody = r.PostForm.Encode() | |
| w.Header().Set("Content-Type", "application/json") | |
| fmt.Fprintln(w, `{"id":"dp1"}`) | |
| })) | |
| defer mockServer.Close() | |
| config := &Config{ | |
| Username: "alice", | |
| AuthToken: "tok123", | |
| BaseURL: mockServer.URL, | |
| } | |
| _, status, err := makeAPIRequest(config, "PATCH", | |
| "/api/v1/users/alice/goals/pushups/datapoints.json", | |
| []string{"value=5", "comment=test"}) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| if status != http.StatusOK { | |
| t.Errorf("status = %d, want 200", status) | |
| } | |
| if !strings.Contains(capturedBody, "auth_token=tok123") { | |
| t.Errorf("auth_token missing from body; body = %q", capturedBody) | |
| } | |
| if !strings.Contains(capturedBody, "value=5") { | |
| t.Errorf("value missing from body; body = %q", capturedBody) | |
| } | |
| if strings.Contains(capturedQuery, "value=5") { | |
| t.Errorf("value should not be in query string; query = %q", capturedQuery) | |
| } | |
| } |
There was a problem hiding this comment.
Added tests for PUT, DELETE, and PATCH in 7b7a166. The DELETE test verifies params go to the query string; PUT and PATCH tests verify params are sent in the body (not the query string).
| t.Errorf("path = %q, want /api/v1/users/bob/goals.json", capturedPath) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
There is no test coverage for usernames containing special URL characters (e.g., spaces, ampersands, slashes) when using the {user} placeholder. Given that the username substitution doesn't use URL escaping (see comment on main.go:1633), adding a test for this would help verify the fix when it's implemented.
| // TestMakeAPIRequestUserPlaceholderSpecialChars tests {user} replacement when the username contains special URL characters. | |
| func TestMakeAPIRequestUserPlaceholderSpecialChars(t *testing.T) { | |
| var capturedPath string | |
| mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| capturedPath = r.URL.Path | |
| fmt.Fprintln(w, `{}`) | |
| })) | |
| defer mockServer.Close() | |
| config := &Config{ | |
| Username: "bob smith&sons/extra", | |
| AuthToken: "tok", | |
| BaseURL: mockServer.URL, | |
| } | |
| if _, _, err := makeAPIRequest(config, "GET", "/api/v1/users/{user}/goals.json", nil); err != nil { | |
| t.Fatalf("unexpected error: %v", err) | |
| } | |
| expectedPath := "/api/v1/users/bob smith&sons/extra/goals.json" | |
| if capturedPath != expectedPath { | |
| t.Errorf("path = %q, want %q", capturedPath, expectedPath) | |
| } | |
| } |
There was a problem hiding this comment.
Added TestMakeAPIRequestUserPlaceholderSpecialChars in 7b7a166 — it verifies that a username with a space is encoded as bob%20smith in the request URI after the url.PathEscape fix.
|
@copilot apply changes based on the comments in this thread |
…/special-chars tests Co-authored-by: narthur <4655422+narthur@users.noreply.github.com>
handleApiCommand()function to main.gomakeAPIRequest()helper (testable core logic) to main.go"api"case to main switch statementprintHelp()with api command docsapiio,net/http,net/url) to main.goapi_test.gourl.PathEscapefor{user}substitution in pathOriginal prompt
buzz apicommand for using the beeminder api via the buzz cli, using buzz cli auth #227🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.