Skip to content

Commit b1aaab1

Browse files
authored
feat(api): add raw Firestore REST API subcommand (#56)
Adds `firestore api <path>`, a gh-api-style escape hatch for Firestore operations the CLI does not wrap yet (named databases, :runQuery, admin endpoints, etc.). Relative paths expand under the project's documents collection; paths starting with `projects/` are sent verbatim. Supports {project}/{database} placeholders, -X/--method, and --input (file or STDIN). Auth reuses ADC; falls back to the emulator when --host is set. Co-authored-by: Gustavo Hoirisch <355877+gugahoi@users.noreply.github.com>
1 parent 83b2ae6 commit b1aaab1

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

pkg/cmd/api/api.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"strings"
12+
13+
"github.com/gugahoi/firestore/pkg/cmd/keys"
14+
"github.com/spf13/cobra"
15+
"golang.org/x/oauth2/google"
16+
)
17+
18+
const (
19+
defaultDatabase = "(default)"
20+
liveBaseURL = "https://firestore.googleapis.com/v1/"
21+
datastoreScope = "https://www.googleapis.com/auth/datastore"
22+
)
23+
24+
func NewApiCmd() *cobra.Command {
25+
var method, input string
26+
27+
cmd := &cobra.Command{
28+
Use: "api <path>",
29+
Short: "make an authenticated request to the Firestore REST API",
30+
Long: `Make an authenticated request to the Firestore REST API.
31+
32+
This is an escape hatch for interactions the CLI does not wrap yet (named
33+
databases, :runQuery, :batchGet, field masks, admin endpoints, ...). It works
34+
like "gh api": you give a path, it makes the request with the project and
35+
credentials you already have configured.
36+
37+
Path resolution:
38+
- A relative path is expanded under the documents collection of the current
39+
project, i.e. "users/u1" becomes
40+
projects/<project>/databases/(default)/documents/users/u1
41+
- A path starting with "projects/" is sent verbatim (after the /v1/ prefix),
42+
for admin, query and other non-document endpoints.
43+
44+
Placeholders (substituted in either form):
45+
- {project} -> the --project (-p) / PROJECT_ID value
46+
- {database} -> (default)
47+
48+
Method:
49+
- Defaults to GET, or POST when a body is supplied with --input.
50+
- Override with -X/--method.
51+
52+
Body:
53+
- --input <file> reads the request body from a file.
54+
- --input - reads the request body from STDIN.
55+
- A Content-Type of application/json is set automatically when a body is sent.
56+
57+
Authentication:
58+
- Uses Application Default Credentials (run 'firestore auth login' first).
59+
- When --host (or FIRESTORE_EMULATOR_HOST) points at an emulator, requests go
60+
to that host and no real credentials are required.`,
61+
Example: ` # Get a document (relative path)
62+
firestore api users/u1
63+
64+
# Create a document from STDIN
65+
echo '{"fields":{"name":{"stringValue":"ada"}}}' | firestore api 'users?documentId=u1' -X POST --input -
66+
67+
# Run a structured query (verbatim path with placeholders)
68+
firestore api 'projects/{project}/databases/{database}/documents:runQuery' -X POST --input query.json
69+
70+
# Delete a document
71+
firestore api users/u1 -X DELETE`,
72+
Args: cobra.ExactArgs(1),
73+
RunE: func(cmd *cobra.Command, args []string) error {
74+
projectID, _ := cmd.Context().Value(keys.ProjectIDKey).(string)
75+
return apiCall(cmd.Context(), projectID, args[0], method, input)
76+
},
77+
}
78+
79+
cmd.Flags().StringVarP(&method, "method", "X", "", "HTTP method (default GET, or POST when --input is given)")
80+
cmd.Flags().StringVar(&input, "input", "", "request body: a file path, or '-' to read STDIN")
81+
82+
return cmd
83+
}
84+
85+
func apiCall(ctx context.Context, projectID, path, method, input string) error {
86+
body, err := readBody(input)
87+
if err != nil {
88+
return err
89+
}
90+
91+
base, emulator := baseURL()
92+
url := base + resolvePath(projectID, defaultDatabase, path)
93+
94+
var reader io.Reader
95+
if body != nil {
96+
reader = bytes.NewReader(body)
97+
}
98+
99+
req, err := http.NewRequestWithContext(ctx, defaultMethod(method, body != nil), url, reader)
100+
if err != nil {
101+
return fmt.Errorf("failed to build request: %w", err)
102+
}
103+
if body != nil {
104+
req.Header.Set("Content-Type", "application/json")
105+
}
106+
107+
if emulator {
108+
req.Header.Set("Authorization", "Bearer owner")
109+
} else {
110+
ts, err := google.DefaultTokenSource(ctx, datastoreScope)
111+
if err != nil {
112+
return fmt.Errorf("failed to find credentials (run 'firestore auth login'): %w", err)
113+
}
114+
tok, err := ts.Token()
115+
if err != nil {
116+
return fmt.Errorf("failed to get auth token: %w", err)
117+
}
118+
tok.SetAuthHeader(req)
119+
}
120+
121+
resp, err := http.DefaultClient.Do(req)
122+
if err != nil {
123+
return fmt.Errorf("request failed: %w", err)
124+
}
125+
defer resp.Body.Close()
126+
127+
respBody, err := io.ReadAll(resp.Body)
128+
if err != nil {
129+
return fmt.Errorf("failed to read response: %w", err)
130+
}
131+
132+
if resp.StatusCode >= 400 {
133+
return fmt.Errorf("HTTP %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
134+
}
135+
136+
printResponse(respBody)
137+
return nil
138+
}
139+
140+
// resolvePath expands {project}/{database} placeholders and turns a relative
141+
// path into a fully-qualified documents path. Paths already starting with
142+
// "projects/" are returned verbatim.
143+
func resolvePath(projectID, database, path string) string {
144+
path = strings.TrimPrefix(path, "/")
145+
path = strings.ReplaceAll(path, "{project}", projectID)
146+
path = strings.ReplaceAll(path, "{database}", database)
147+
if strings.HasPrefix(path, "projects/") {
148+
return path
149+
}
150+
return fmt.Sprintf("projects/%s/databases/%s/documents/%s", projectID, database, path)
151+
}
152+
153+
// defaultMethod returns the explicit method (upper-cased) if given, otherwise
154+
// POST when a body is present and GET otherwise.
155+
func defaultMethod(method string, hasBody bool) string {
156+
if method != "" {
157+
return strings.ToUpper(method)
158+
}
159+
if hasBody {
160+
return http.MethodPost
161+
}
162+
return http.MethodGet
163+
}
164+
165+
func baseURL() (string, bool) {
166+
if h := os.Getenv("FIRESTORE_EMULATOR_HOST"); h != "" {
167+
return "http://" + h + "/v1/", true
168+
}
169+
return liveBaseURL, false
170+
}
171+
172+
func readBody(input string) ([]byte, error) {
173+
switch input {
174+
case "":
175+
return nil, nil
176+
case "-":
177+
b, err := io.ReadAll(os.Stdin)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to read STDIN: %w", err)
180+
}
181+
return b, nil
182+
default:
183+
b, err := os.ReadFile(input)
184+
if err != nil {
185+
return nil, fmt.Errorf("failed to read input file: %w", err)
186+
}
187+
return b, nil
188+
}
189+
}
190+
191+
// printResponse pretty-prints JSON, falling back to raw output for non-JSON.
192+
func printResponse(b []byte) {
193+
var v any
194+
if err := json.Unmarshal(b, &v); err != nil {
195+
os.Stdout.Write(b)
196+
if len(b) > 0 && b[len(b)-1] != '\n' {
197+
fmt.Println()
198+
}
199+
return
200+
}
201+
enc := json.NewEncoder(os.Stdout)
202+
enc.SetEscapeHTML(false)
203+
enc.SetIndent("", " ")
204+
enc.Encode(v)
205+
}

pkg/cmd/api/api_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package api
2+
3+
import "testing"
4+
5+
func TestResolvePath(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
path string
9+
want string
10+
}{
11+
{"relative", "users/u1", "projects/proj/databases/(default)/documents/users/u1"},
12+
{"relative with leading slash", "/users/u1", "projects/proj/databases/(default)/documents/users/u1"},
13+
{"relative with query", "users?documentId=u1", "projects/proj/databases/(default)/documents/users?documentId=u1"},
14+
{"verbatim passthrough", "projects/other/databases/(default)/documents/users/u1", "projects/other/databases/(default)/documents/users/u1"},
15+
{"project placeholder", "projects/{project}/databases/{database}/documents:runQuery", "projects/proj/databases/(default)/documents:runQuery"},
16+
}
17+
for _, tt := range tests {
18+
t.Run(tt.name, func(t *testing.T) {
19+
if got := resolvePath("proj", "(default)", tt.path); got != tt.want {
20+
t.Errorf("resolvePath(%q) = %q, want %q", tt.path, got, tt.want)
21+
}
22+
})
23+
}
24+
}
25+
26+
func TestDefaultMethod(t *testing.T) {
27+
tests := []struct {
28+
method string
29+
hasBody bool
30+
want string
31+
}{
32+
{"", false, "GET"},
33+
{"", true, "POST"},
34+
{"delete", false, "DELETE"},
35+
{"PATCH", true, "PATCH"},
36+
}
37+
for _, tt := range tests {
38+
if got := defaultMethod(tt.method, tt.hasBody); got != tt.want {
39+
t.Errorf("defaultMethod(%q, %v) = %q, want %q", tt.method, tt.hasBody, got, tt.want)
40+
}
41+
}
42+
}

pkg/cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"cloud.google.com/go/firestore"
99

10+
"github.com/gugahoi/firestore/pkg/cmd/api"
1011
"github.com/gugahoi/firestore/pkg/cmd/auth"
1112
"github.com/gugahoi/firestore/pkg/cmd/browse"
1213
"github.com/gugahoi/firestore/pkg/cmd/collection"
@@ -77,6 +78,7 @@ var host string
7778
func init() {
7879
rootCmd.PersistentFlags().StringVarP(&projectId, "project", "p", "", "Google Cloud Project")
7980
rootCmd.PersistentFlags().StringVar(&host, "host", "", "Firestore host (e.g., localhost:8080 for emulator)")
81+
rootCmd.AddCommand(api.NewApiCmd())
8082
rootCmd.AddCommand(auth.NewAuthCmd())
8183
rootCmd.AddCommand(browse.NewBrowseCmd())
8284
rootCmd.AddCommand(document.NewDocumentCmd())

0 commit comments

Comments
 (0)