feat(tools/firestore-add-documents): Add firestore-add-documents tool (#1107)

## Add firestore-add-documents tool

Adds a new tool for creating documents in Firestore collections.

__What it does:__

- Adds documents to any Firestore collection
- Auto-generates unique document IDs
- Supports all Firestore data types (strings, numbers, booleans,
timestamps, geopoints, arrays, maps, etc.)
- Uses Firestore's native JSON format for type safety

__Key parameters:__

- `collectionPath`: Where to add the document
- `documentData`: The document content in Firestore JSON format
- `returnData`: Optional flag to include created document in response

---------

Co-authored-by: Averi Kitsch <akitsch@google.com>
This commit is contained in:
trehanshakuntG
2025-08-19 11:24:21 +05:30
committed by GitHub
parent 2ad0ccf83d
commit ee4a70a0e8
10 changed files with 1580 additions and 23 deletions

View File

@@ -56,6 +56,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/dataplex/dataplexsearchaspecttypes"
_ "github.com/googleapis/genai-toolbox/internal/tools/dataplex/dataplexsearchentries"
_ "github.com/googleapis/genai-toolbox/internal/tools/dgraph"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreadddocuments"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoredeletedocuments"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetdocuments"
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetrules"

View File

@@ -1260,7 +1260,7 @@ func TestPrebuiltTools(t *testing.T) {
wantToolset: server.ToolsetConfigs{
"firestore-database-tools": tools.ToolsetConfig{
Name: "firestore-database-tools",
ToolNames: []string{"firestore-get-documents", "firestore-list-collections", "firestore-delete-documents", "firestore-query-collection", "firestore-get-rules", "firestore-validate-rules"},
ToolNames: []string{"firestore-get-documents", "firestore-add-documents", "firestore-list-collections", "firestore-delete-documents", "firestore-query-collection", "firestore-get-rules", "firestore-validate-rules"},
},
},
},

View File

@@ -0,0 +1,274 @@
---
title: "firestore-add-documents"
type: docs
weight: 1
description: >
A "firestore-add-documents" tool adds document to a given collection path.
aliases:
- /resources/tools/firestore-add-documents
---
## Description
The `firestore-add-documents` tool allows you to add new documents to a Firestore collection. It supports all Firestore data types using Firestore's native JSON format. The tool automatically generates a unique document ID for each new document.
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `collectionPath` | string | Yes | The path of the collection where the document will be added |
| `documentData` | map | Yes | The data to be added as a document to the given collection. Must use [Firestore's native JSON format](https://cloud.google.com/firestore/docs/reference/rest/Shared.Types/ArrayValue#Value) with typed values |
| `returnData` | boolean | No | If set to true, the output will include the data of the created document. Defaults to false to help avoid overloading the context |
## Output
The tool returns a map containing:
| Field | Type | Description |
|-------|------|-------------|
| `documentPath` | string | The full resource name of the created document (e.g., `projects/{projectId}/databases/{databaseId}/documents/{document_path}`) |
| `createTime` | string | The timestamp when the document was created |
| `documentData` | map | The data that was added (only included when `returnData` is true) |
## Data Type Format
The tool requires Firestore's native JSON format for document data. Each field must be wrapped with its type indicator:
### Basic Types
- **String**: `{"stringValue": "your string"}`
- **Integer**: `{"integerValue": "123"}` or `{"integerValue": 123}`
- **Double**: `{"doubleValue": 123.45}`
- **Boolean**: `{"booleanValue": true}`
- **Null**: `{"nullValue": null}`
- **Bytes**: `{"bytesValue": "base64EncodedString"}`
- **Timestamp**: `{"timestampValue": "2025-01-07T10:00:00Z"}` (RFC3339 format)
### Complex Types
- **GeoPoint**: `{"geoPointValue": {"latitude": 34.052235, "longitude": -118.243683}}`
- **Array**: `{"arrayValue": {"values": [{"stringValue": "item1"}, {"integerValue": "2"}]}}`
- **Map**: `{"mapValue": {"fields": {"key1": {"stringValue": "value1"}, "key2": {"booleanValue": true}}}}`
- **Reference**: `{"referenceValue": "collection/document"}`
## Examples
### Basic Document Creation
```yaml
tools:
add-company-doc:
kind: firestore-add-documents
source: my-firestore
description: Add a new company document
```
Usage:
```json
{
"collectionPath": "companies",
"documentData": {
"name": {
"stringValue": "Acme Corporation"
},
"establishmentDate": {
"timestampValue": "2000-01-15T10:30:00Z"
},
"location": {
"geoPointValue": {
"latitude": 34.052235,
"longitude": -118.243683
}
},
"active": {
"booleanValue": true
},
"employeeCount": {
"integerValue": "1500"
},
"annualRevenue": {
"doubleValue": 1234567.89
}
}
}
```
### With Nested Maps and Arrays
```json
{
"collectionPath": "companies",
"documentData": {
"name": {
"stringValue": "Tech Innovations Inc"
},
"contactInfo": {
"mapValue": {
"fields": {
"email": {
"stringValue": "info@techinnovations.com"
},
"phone": {
"stringValue": "+1-555-123-4567"
},
"address": {
"mapValue": {
"fields": {
"street": {
"stringValue": "123 Innovation Drive"
},
"city": {
"stringValue": "San Francisco"
},
"state": {
"stringValue": "CA"
},
"zipCode": {
"stringValue": "94105"
}
}
}
}
}
}
},
"products": {
"arrayValue": {
"values": [
{
"stringValue": "Product A"
},
{
"stringValue": "Product B"
},
{
"mapValue": {
"fields": {
"productName": {
"stringValue": "Product C Premium"
},
"version": {
"integerValue": "3"
},
"features": {
"arrayValue": {
"values": [
{
"stringValue": "Advanced Analytics"
},
{
"stringValue": "Real-time Sync"
}
]
}
}
}
}
}
]
}
}
},
"returnData": true
}
```
### Complete Example with All Data Types
```json
{
"collectionPath": "test-documents",
"documentData": {
"stringField": {
"stringValue": "Hello World"
},
"integerField": {
"integerValue": "42"
},
"doubleField": {
"doubleValue": 3.14159
},
"booleanField": {
"booleanValue": true
},
"nullField": {
"nullValue": null
},
"timestampField": {
"timestampValue": "2025-01-07T15:30:00Z"
},
"geoPointField": {
"geoPointValue": {
"latitude": 37.7749,
"longitude": -122.4194
}
},
"bytesField": {
"bytesValue": "SGVsbG8gV29ybGQh"
},
"arrayField": {
"arrayValue": {
"values": [
{
"stringValue": "item1"
},
{
"integerValue": "2"
},
{
"booleanValue": false
}
]
}
},
"mapField": {
"mapValue": {
"fields": {
"nestedString": {
"stringValue": "nested value"
},
"nestedNumber": {
"doubleValue": 99.99
}
}
}
}
}
}
```
## Authentication
The tool can be configured to require authentication:
```yaml
tools:
secure-add-docs:
kind: firestore-add-documents
source: prod-firestore
description: Add documents with authentication required
authRequired:
- google-oauth
- api-key
```
## Error Handling
Common errors include:
- Invalid collection path
- Missing or invalid document data
- Permission denied (if Firestore security rules block the operation)
- Invalid data type conversions
## Best Practices
1. **Always use typed values**: Every field must be wrapped with its appropriate type indicator (e.g., `{"stringValue": "text"}`)
2. **Integer values can be strings**: The tool accepts integer values as strings (e.g., `{"integerValue": "1500"}`)
3. **Use returnData sparingly**: Only set to true when you need to verify the exact data that was written
4. **Validate data before sending**: Ensure your data matches Firestore's native JSON format
5. **Handle timestamps properly**: Use RFC3339 format for timestamp strings
6. **Base64 encode binary data**: Binary data must be base64 encoded in the `bytesValue` field
7. **Consider security rules**: Ensure your Firestore security rules allow document creation in the target collection
## Related Tools
- [`firestore-get-documents`](firestore-get-documents.md) - Retrieve documents by their paths
- [`firestore-query-collection`](firestore-query-collection.md) - Query documents in a collection
- [`firestore-delete-documents`](firestore-delete-documents.md) - Delete documents from Firestore

2
go.mod
View File

@@ -47,6 +47,7 @@ require (
go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/oauth2 v0.30.0
google.golang.org/api v0.247.0
google.golang.org/genproto v0.0.0-20250603155806-513f23925822
modernc.org/sqlite v1.38.2
)
@@ -149,7 +150,6 @@ require (
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.35.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
google.golang.org/grpc v1.74.2 // indirect

View File

@@ -9,6 +9,18 @@ tools:
kind: firestore-get-documents
source: firestore-source
description: Gets multiple documents from Firestore by their paths
firestore-add-documents:
kind: firestore-add-documents
source: firestore-source
description: |
Adds a new document to a Firestore collection. Please follow the best practices :
1. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
2. Integer values can be strings in the documentData: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
3. Use returnData sparingly: Only set to true when you need to verify the exact data that was written
4. Validate data before sending: Ensure your data matches Firestore's native JSON format
5. Handle timestamps properly: Use RFC3339 format for timestamp strings
6. Base64 encode binary data: Binary data must be base64 encoded in the bytesValue field
7. Consider security rules: Ensure your Firestore security rules allow document creation in the target collection
firestore-list-collections:
kind: firestore-list-collections
source: firestore-source
@@ -35,6 +47,7 @@ tools:
toolsets:
firestore-database-tools:
- firestore-get-documents
- firestore-add-documents
- firestore-list-collections
- firestore-delete-documents
- firestore-query-collection

View File

@@ -0,0 +1,220 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package firestoreadddocuments
import (
"context"
"fmt"
firestoreapi "cloud.google.com/go/firestore"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
firestoreds "github.com/googleapis/genai-toolbox/internal/sources/firestore"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/tools/firestore/util"
)
const kind string = "firestore-add-documents"
const collectionPathKey string = "collectionPath"
const documentDataKey string = "documentData"
const returnDocumentDataKey string = "returnData"
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type compatibleSource interface {
FirestoreClient() *firestoreapi.Client
}
// validate compatible sources are still compatible
var _ compatibleSource = &firestoreds.Source{}
var compatibleSources = [...]string{firestoreds.SourceKind}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Source string `yaml:"source" validate:"required"`
Description string `yaml:"description" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
}
// validate interface
var _ tools.ToolConfig = Config{}
func (cfg Config) ToolConfigKind() string {
return kind
}
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
// verify source exists
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
// verify the source is compatible
s, ok := rawS.(compatibleSource)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources)
}
// Create parameters
collectionPathParameter := tools.NewStringParameter(
collectionPathKey,
"The path of the collection where the document will be added to",
)
documentDataParameter := tools.NewMapParameter(
documentDataKey,
`The document data in Firestore's native JSON format. Each field must be wrapped with a type indicator:
- Strings: {"stringValue": "text"}
- Integers: {"integerValue": "123"} or {"integerValue": 123}
- Doubles: {"doubleValue": 123.45}
- Booleans: {"booleanValue": true}
- Timestamps: {"timestampValue": "2025-01-07T10:00:00Z"}
- GeoPoints: {"geoPointValue": {"latitude": 34.05, "longitude": -118.24}}
- Arrays: {"arrayValue": {"values": [{"stringValue": "item1"}, {"integerValue": "2"}]}}
- Maps: {"mapValue": {"fields": {"key1": {"stringValue": "value1"}, "key2": {"booleanValue": true}}}}
- Null: {"nullValue": null}
- Bytes: {"bytesValue": "base64EncodedString"}
- References: {"referenceValue": "collection/document"}`,
"", // Empty string for generic map that accepts any value type
)
returnDataParameter := tools.NewBooleanParameterWithDefault(
returnDocumentDataKey,
false,
"If set to true the output will have the data of the created document. This flag if set to false will help avoid overloading the context of the agent.",
)
parameters := tools.Parameters{
collectionPathParameter,
documentDataParameter,
returnDataParameter,
}
mcpManifest := tools.McpManifest{
Name: cfg.Name,
Description: cfg.Description,
InputSchema: parameters.McpManifest(),
}
// finish tool setup
t := Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
AuthRequired: cfg.AuthRequired,
Client: s.FirestoreClient(),
manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}
return t, nil
}
// validate interface
var _ tools.Tool = Tool{}
type Tool struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
AuthRequired []string `yaml:"authRequired"`
Parameters tools.Parameters `yaml:"parameters"`
Client *firestoreapi.Client
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
mapParams := params.AsMap()
// Get collection path
collectionPath, ok := mapParams[collectionPathKey].(string)
if !ok || collectionPath == "" {
return nil, fmt.Errorf("invalid or missing '%s' parameter", collectionPathKey)
}
// Get document data
documentDataRaw, ok := mapParams[documentDataKey]
if !ok {
return nil, fmt.Errorf("invalid or missing '%s' parameter", documentDataKey)
}
// Convert the document data from JSON format to Firestore format
// The client is passed to handle referenceValue types
documentData, err := util.JSONToFirestoreValue(documentDataRaw, t.Client)
if err != nil {
return nil, fmt.Errorf("failed to convert document data: %w", err)
}
// Get return document data flag
returnData := false
if val, ok := mapParams[returnDocumentDataKey].(bool); ok {
returnData = val
}
// Get the collection reference
collection := t.Client.Collection(collectionPath)
// Add the document to the collection
docRef, writeResult, err := collection.Add(ctx, documentData)
if err != nil {
return nil, fmt.Errorf("failed to add document: %w", err)
}
// Build the response
response := map[string]any{
"documentPath": docRef.Path,
"createTime": writeResult.UpdateTime.Format("2006-01-02T15:04:05.999999999Z"),
}
// Add document data if requested
if returnData {
// Convert the document data back to simple JSON format
simplifiedData := util.FirestoreValueToJSON(documentData)
response["documentData"] = simplifiedData
}
return response, nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.Parameters, data, claims)
}
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}

View File

@@ -0,0 +1,156 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package firestoreadddocuments_test
import (
"testing"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/testutils"
"github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreadddocuments"
)
func TestParseFromYamlFirestoreAddDocuments(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example",
in: `
tools:
add_docs_tool:
kind: firestore-add-documents
source: my-firestore-instance
description: Add documents to Firestore collections
`,
want: server.ToolConfigs{
"add_docs_tool": firestoreadddocuments.Config{
Name: "add_docs_tool",
Kind: "firestore-add-documents",
Source: "my-firestore-instance",
Description: "Add documents to Firestore collections",
AuthRequired: []string{},
},
},
},
{
desc: "with auth requirements",
in: `
tools:
secure_add_docs:
kind: firestore-add-documents
source: prod-firestore
description: Add documents with authentication
authRequired:
- google-auth-service
- api-key-service
`,
want: server.ToolConfigs{
"secure_add_docs": firestoreadddocuments.Config{
Name: "secure_add_docs",
Kind: "firestore-add-documents",
Source: "prod-firestore",
Description: "Add documents with authentication",
AuthRequired: []string{"google-auth-service", "api-key-service"},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}
func TestParseFromYamlMultipleTools(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
in := `
tools:
add_user_docs:
kind: firestore-add-documents
source: users-firestore
description: Add user documents
authRequired:
- user-auth
add_product_docs:
kind: firestore-add-documents
source: products-firestore
description: Add product documents
add_order_docs:
kind: firestore-add-documents
source: orders-firestore
description: Add order documents
authRequired:
- user-auth
- admin-auth
`
want := server.ToolConfigs{
"add_user_docs": firestoreadddocuments.Config{
Name: "add_user_docs",
Kind: "firestore-add-documents",
Source: "users-firestore",
Description: "Add user documents",
AuthRequired: []string{"user-auth"},
},
"add_product_docs": firestoreadddocuments.Config{
Name: "add_product_docs",
Kind: "firestore-add-documents",
Source: "products-firestore",
Description: "Add product documents",
AuthRequired: []string{},
},
"add_order_docs": firestoreadddocuments.Config{
Name: "add_order_docs",
Kind: "firestore-add-documents",
Source: "orders-firestore",
Description: "Add order documents",
AuthRequired: []string{"user-auth", "admin-auth"},
},
}
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err = yaml.UnmarshalContext(ctx, testutils.FormatYaml(in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
}

View File

@@ -0,0 +1,233 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package util
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"cloud.google.com/go/firestore"
"google.golang.org/genproto/googleapis/type/latlng"
)
// JSONToFirestoreValue converts a JSON value with type information to a Firestore-compatible value
// The input should be a map with a single key indicating the type (e.g., "stringValue", "integerValue")
// If a client is provided, referenceValue types will be converted to *firestore.DocumentRef
func JSONToFirestoreValue(value interface{}, client *firestore.Client) (interface{}, error) {
if value == nil {
return nil, nil
}
switch v := value.(type) {
case map[string]interface{}:
// Check for typed values
if len(v) == 1 {
for key, val := range v {
switch key {
case "nullValue":
return nil, nil
case "booleanValue":
return val, nil
case "stringValue":
return val, nil
case "integerValue":
// Convert to int64
switch num := val.(type) {
case float64:
return int64(num), nil
case int:
return int64(num), nil
case int64:
return num, nil
case string:
// Parse string representation using strconv for better performance
i, err := strconv.ParseInt(strings.TrimSpace(num), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer value: %v", val)
}
return i, nil
}
return nil, fmt.Errorf("invalid integer value: %v", val)
case "doubleValue":
// Convert to float64
switch num := val.(type) {
case float64:
return num, nil
case int:
return float64(num), nil
case int64:
return float64(num), nil
}
return nil, fmt.Errorf("invalid double value: %v", val)
case "bytesValue":
// Decode base64 string to bytes
if str, ok := val.(string); ok {
return base64.StdEncoding.DecodeString(str)
}
return nil, fmt.Errorf("bytes value must be a base64 encoded string")
case "timestampValue":
// Parse timestamp
if str, ok := val.(string); ok {
t, err := time.Parse(time.RFC3339Nano, str)
if err != nil {
return nil, fmt.Errorf("invalid timestamp format: %w", err)
}
return t, nil
}
return nil, fmt.Errorf("timestamp value must be a string")
case "geoPointValue":
// Convert to LatLng
if geoMap, ok := val.(map[string]interface{}); ok {
lat, latOk := geoMap["latitude"].(float64)
lng, lngOk := geoMap["longitude"].(float64)
if latOk && lngOk {
return &latlng.LatLng{
Latitude: lat,
Longitude: lng,
}, nil
}
}
return nil, fmt.Errorf("invalid geopoint value format")
case "arrayValue":
// Convert array
if arrayMap, ok := val.(map[string]interface{}); ok {
if values, ok := arrayMap["values"].([]interface{}); ok {
result := make([]interface{}, len(values))
for i, item := range values {
converted, err := JSONToFirestoreValue(item, client)
if err != nil {
return nil, fmt.Errorf("array item %d: %w", i, err)
}
result[i] = converted
}
return result, nil
}
}
return nil, fmt.Errorf("invalid array value format")
case "mapValue":
// Convert map
if mapMap, ok := val.(map[string]interface{}); ok {
if fields, ok := mapMap["fields"].(map[string]interface{}); ok {
result := make(map[string]interface{})
for k, v := range fields {
converted, err := JSONToFirestoreValue(v, client)
if err != nil {
return nil, fmt.Errorf("map field %q: %w", k, err)
}
result[k] = converted
}
return result, nil
}
}
return nil, fmt.Errorf("invalid map value format")
case "referenceValue":
// Convert to DocumentRef if client is provided
if strVal, ok := val.(string); ok {
if client != nil && isValidDocumentPath(strVal) {
return client.Doc(strVal), nil
}
// Return the path as string if no client or invalid path
return strVal, nil
}
return nil, fmt.Errorf("reference value must be a string")
default:
// If not a typed value, treat as regular map
return convertPlainMap(v, client)
}
}
}
// Regular map without type annotation
return convertPlainMap(v, client)
default:
// Plain values (for backward compatibility)
return value, nil
}
}
// convertPlainMap converts a plain map to Firestore format
func convertPlainMap(m map[string]interface{}, client *firestore.Client) (map[string]interface{}, error) {
result := make(map[string]interface{})
for k, v := range m {
converted, err := JSONToFirestoreValue(v, client)
if err != nil {
return nil, fmt.Errorf("field %q: %w", k, err)
}
result[k] = converted
}
return result, nil
}
// FirestoreValueToJSON converts a Firestore value to a simplified JSON representation
// This removes type information and returns plain values
func FirestoreValueToJSON(value interface{}) interface{} {
if value == nil {
return nil
}
switch v := value.(type) {
case time.Time:
return v.Format(time.RFC3339Nano)
case *latlng.LatLng:
return map[string]interface{}{
"latitude": v.Latitude,
"longitude": v.Longitude,
}
case []byte:
return base64.StdEncoding.EncodeToString(v)
case []interface{}:
result := make([]interface{}, len(v))
for i, item := range v {
result[i] = FirestoreValueToJSON(item)
}
return result
case map[string]interface{}:
result := make(map[string]interface{})
for k, val := range v {
result[k] = FirestoreValueToJSON(val)
}
return result
case *firestore.DocumentRef:
return v.Path
default:
return value
}
}
// isValidDocumentPath checks if a string is a valid Firestore document path
// Valid paths have an even number of segments (collection/doc/collection/doc...)
func isValidDocumentPath(path string) bool {
if path == "" {
return false
}
// Split the path by '/' and check if it has an even number of segments
segments := splitPath(path)
return len(segments) > 0 && len(segments)%2 == 0
}
// splitPath splits a path by '/' while handling empty segments correctly
func splitPath(path string) []string {
rawSegments := strings.Split(path, "/")
var segments []string
for _, s := range rawSegments {
if s != "" {
segments = append(segments, s)
}
}
return segments
}

View File

@@ -0,0 +1,423 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package util
import (
"bytes"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"google.golang.org/genproto/googleapis/type/latlng"
)
func TestJSONToFirestoreValue_ComplexDocument(t *testing.T) {
// This is the exact JSON format provided by the user
jsonData := `{
"name": {
"stringValue": "Acme Corporation"
},
"establishmentDate": {
"timestampValue": "2000-01-15T10:30:00Z"
},
"location": {
"geoPointValue": {
"latitude": 34.052235,
"longitude": -118.243683
}
},
"active": {
"booleanValue": true
},
"employeeCount": {
"integerValue": "1500"
},
"annualRevenue": {
"doubleValue": 1234567.89
},
"website": {
"stringValue": "https://www.acmecorp.com"
},
"contactInfo": {
"mapValue": {
"fields": {
"email": {
"stringValue": "info@acmecorp.com"
},
"phone": {
"stringValue": "+1-555-123-4567"
},
"address": {
"mapValue": {
"fields": {
"street": {
"stringValue": "123 Business Blvd"
},
"city": {
"stringValue": "Los Angeles"
},
"state": {
"stringValue": "CA"
},
"zipCode": {
"stringValue": "90012"
}
}
}
}
}
}
},
"products": {
"arrayValue": {
"values": [
{
"stringValue": "Product A"
},
{
"stringValue": "Product B"
},
{
"mapValue": {
"fields": {
"productName": {
"stringValue": "Product C Deluxe"
},
"version": {
"integerValue": "2"
},
"features": {
"arrayValue": {
"values": [
{
"stringValue": "Feature X"
},
{
"stringValue": "Feature Y"
}
]
}
}
}
}
}
]
}
},
"notes": {
"nullValue": null
},
"lastUpdated": {
"timestampValue": "2025-07-30T11:47:59.000Z"
},
"binaryData": {
"bytesValue": "SGVsbG8gV29ybGQh"
}
}`
// Parse JSON
var data interface{}
err := json.Unmarshal([]byte(jsonData), &data)
if err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
// Convert to Firestore format
result, err := JSONToFirestoreValue(data, nil)
if err != nil {
t.Fatalf("Failed to convert JSON to Firestore value: %v", err)
}
// Verify the result is a map
resultMap, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("Result should be a map, got %T", result)
}
// Verify string values
if resultMap["name"] != "Acme Corporation" {
t.Errorf("Expected name 'Acme Corporation', got %v", resultMap["name"])
}
if resultMap["website"] != "https://www.acmecorp.com" {
t.Errorf("Expected website 'https://www.acmecorp.com', got %v", resultMap["website"])
}
// Verify timestamp
establishmentDate, ok := resultMap["establishmentDate"].(time.Time)
if !ok {
t.Fatalf("establishmentDate should be time.Time, got %T", resultMap["establishmentDate"])
}
expectedDate, _ := time.Parse(time.RFC3339, "2000-01-15T10:30:00Z")
if !establishmentDate.Equal(expectedDate) {
t.Errorf("Expected date %v, got %v", expectedDate, establishmentDate)
}
// Verify geopoint
location, ok := resultMap["location"].(*latlng.LatLng)
if !ok {
t.Fatalf("location should be *latlng.LatLng, got %T", resultMap["location"])
}
if location.Latitude != 34.052235 {
t.Errorf("Expected latitude 34.052235, got %v", location.Latitude)
}
if location.Longitude != -118.243683 {
t.Errorf("Expected longitude -118.243683, got %v", location.Longitude)
}
// Verify boolean
if resultMap["active"] != true {
t.Errorf("Expected active true, got %v", resultMap["active"])
}
// Verify integer (should be int64)
employeeCount, ok := resultMap["employeeCount"].(int64)
if !ok {
t.Fatalf("employeeCount should be int64, got %T", resultMap["employeeCount"])
}
if employeeCount != int64(1500) {
t.Errorf("Expected employeeCount 1500, got %v", employeeCount)
}
// Verify double
annualRevenue, ok := resultMap["annualRevenue"].(float64)
if !ok {
t.Fatalf("annualRevenue should be float64, got %T", resultMap["annualRevenue"])
}
if annualRevenue != 1234567.89 {
t.Errorf("Expected annualRevenue 1234567.89, got %v", annualRevenue)
}
// Verify nested map
contactInfo, ok := resultMap["contactInfo"].(map[string]interface{})
if !ok {
t.Fatalf("contactInfo should be a map, got %T", resultMap["contactInfo"])
}
if contactInfo["email"] != "info@acmecorp.com" {
t.Errorf("Expected email 'info@acmecorp.com', got %v", contactInfo["email"])
}
if contactInfo["phone"] != "+1-555-123-4567" {
t.Errorf("Expected phone '+1-555-123-4567', got %v", contactInfo["phone"])
}
// Verify nested nested map
address, ok := contactInfo["address"].(map[string]interface{})
if !ok {
t.Fatalf("address should be a map, got %T", contactInfo["address"])
}
if address["street"] != "123 Business Blvd" {
t.Errorf("Expected street '123 Business Blvd', got %v", address["street"])
}
if address["city"] != "Los Angeles" {
t.Errorf("Expected city 'Los Angeles', got %v", address["city"])
}
if address["state"] != "CA" {
t.Errorf("Expected state 'CA', got %v", address["state"])
}
if address["zipCode"] != "90012" {
t.Errorf("Expected zipCode '90012', got %v", address["zipCode"])
}
// Verify array
products, ok := resultMap["products"].([]interface{})
if !ok {
t.Fatalf("products should be an array, got %T", resultMap["products"])
}
if len(products) != 3 {
t.Errorf("Expected 3 products, got %d", len(products))
}
if products[0] != "Product A" {
t.Errorf("Expected products[0] 'Product A', got %v", products[0])
}
if products[1] != "Product B" {
t.Errorf("Expected products[1] 'Product B', got %v", products[1])
}
// Verify complex item in array
product3, ok := products[2].(map[string]interface{})
if !ok {
t.Fatalf("products[2] should be a map, got %T", products[2])
}
if product3["productName"] != "Product C Deluxe" {
t.Errorf("Expected productName 'Product C Deluxe', got %v", product3["productName"])
}
version, ok := product3["version"].(int64)
if !ok {
t.Fatalf("version should be int64, got %T", product3["version"])
}
if version != int64(2) {
t.Errorf("Expected version 2, got %v", version)
}
features, ok := product3["features"].([]interface{})
if !ok {
t.Fatalf("features should be an array, got %T", product3["features"])
}
if len(features) != 2 {
t.Errorf("Expected 2 features, got %d", len(features))
}
if features[0] != "Feature X" {
t.Errorf("Expected features[0] 'Feature X', got %v", features[0])
}
if features[1] != "Feature Y" {
t.Errorf("Expected features[1] 'Feature Y', got %v", features[1])
}
// Verify null value
if resultMap["notes"] != nil {
t.Errorf("Expected notes to be nil, got %v", resultMap["notes"])
}
// Verify bytes
binaryData, ok := resultMap["binaryData"].([]byte)
if !ok {
t.Fatalf("binaryData should be []byte, got %T", resultMap["binaryData"])
}
expectedBytes, _ := base64.StdEncoding.DecodeString("SGVsbG8gV29ybGQh")
if !bytes.Equal(binaryData, expectedBytes) {
t.Errorf("Expected bytes %v, got %v", expectedBytes, binaryData)
}
}
func TestJSONToFirestoreValue_IntegerFromString(t *testing.T) {
// Test that integerValue as string gets converted to int64
data := map[string]interface{}{
"integerValue": "1500",
}
result, err := JSONToFirestoreValue(data, nil)
if err != nil {
t.Fatalf("Failed to convert: %v", err)
}
intVal, ok := result.(int64)
if !ok {
t.Fatalf("Result should be int64, got %T", result)
}
if intVal != int64(1500) {
t.Errorf("Expected 1500, got %v", intVal)
}
}
func TestFirestoreValueToJSON_RoundTrip(t *testing.T) {
// Test round-trip conversion
original := map[string]interface{}{
"name": "Test",
"count": int64(42),
"price": 19.99,
"active": true,
"tags": []interface{}{"tag1", "tag2"},
"metadata": map[string]interface{}{
"created": time.Now(),
},
"nullField": nil,
}
// Convert to JSON representation
jsonRepresentation := FirestoreValueToJSON(original)
// Verify types are simplified
jsonMap, ok := jsonRepresentation.(map[string]interface{})
if !ok {
t.Fatalf("Expected map, got %T", jsonRepresentation)
}
// Time should be converted to string
metadata, ok := jsonMap["metadata"].(map[string]interface{})
if !ok {
t.Fatalf("metadata should be a map, got %T", jsonMap["metadata"])
}
_, ok = metadata["created"].(string)
if !ok {
t.Errorf("created should be a string, got %T", metadata["created"])
}
}
func TestJSONToFirestoreValue_InvalidFormats(t *testing.T) {
tests := []struct {
name string
input interface{}
wantErr bool
errMsg string
}{
{
name: "invalid integer value",
input: map[string]interface{}{
"integerValue": "not-a-number",
},
wantErr: true,
errMsg: "invalid integer value",
},
{
name: "invalid timestamp",
input: map[string]interface{}{
"timestampValue": "not-a-timestamp",
},
wantErr: true,
errMsg: "invalid timestamp format",
},
{
name: "invalid geopoint - missing latitude",
input: map[string]interface{}{
"geoPointValue": map[string]interface{}{
"longitude": -118.243683,
},
},
wantErr: true,
errMsg: "invalid geopoint value format",
},
{
name: "invalid array format",
input: map[string]interface{}{
"arrayValue": "not-an-array",
},
wantErr: true,
errMsg: "invalid array value format",
},
{
name: "invalid map format",
input: map[string]interface{}{
"mapValue": "not-a-map",
},
wantErr: true,
errMsg: "invalid map value format",
},
{
name: "invalid bytes - not base64",
input: map[string]interface{}{
"bytesValue": "!!!not-base64!!!",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := JSONToFirestoreValue(tt.input, nil)
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("Expected error containing '%s', got '%v'", tt.errMsg, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}

View File

@@ -22,6 +22,7 @@ import (
"io"
"net/http"
"os"
"reflect"
"regexp"
"strings"
"testing"
@@ -129,9 +130,10 @@ func TestFirestoreToolEndpoints(t *testing.T) {
// Run specific Firestore tool tests
runFirestoreGetDocumentsTest(t, docPath1, docPath2)
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
runFirestoreDeleteDocumentsTest(t, docPath3)
runFirestoreQueryCollectionTest(t, testCollectionName)
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
runFirestoreAddDocumentsTest(t, testCollectionName)
runFirestoreDeleteDocumentsTest(t, docPath3)
runFirestoreGetRulesTest(t)
runFirestoreValidateRulesTest(t)
}
@@ -569,6 +571,11 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
"source": "my-instance",
"description": "Validate Firestore security rules",
},
"firestore-add-docs": map[string]any{
"kind": "firestore-add-documents",
"source": "my-instance",
"description": "Add documents to Firestore",
},
}
return map[string]any{
@@ -577,6 +584,210 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
}
}
func runFirestoreAddDocumentsTest(t *testing.T, collectionName string) {
invokeTcs := []struct {
name string
api string
requestBody io.Reader
wantKeys []string
validateDocData bool
expectedDocData map[string]interface{}
isErr bool
}{
{
name: "add document with simple types",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collectionPath": "%s",
"documentData": {
"name": {"stringValue": "Test User"},
"age": {"integerValue": "42"},
"score": {"doubleValue": 99.5},
"active": {"booleanValue": true},
"notes": {"nullValue": null}
}
}`, collectionName))),
wantKeys: []string{"documentPath", "createTime"},
isErr: false,
},
{
name: "add document with complex types",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collectionPath": "%s",
"documentData": {
"location": {
"geoPointValue": {
"latitude": 37.7749,
"longitude": -122.4194
}
},
"timestamp": {
"timestampValue": "2025-01-07T10:00:00Z"
},
"tags": {
"arrayValue": {
"values": [
{"stringValue": "tag1"},
{"stringValue": "tag2"}
]
}
},
"metadata": {
"mapValue": {
"fields": {
"version": {"integerValue": "1"},
"type": {"stringValue": "test"}
}
}
}
}
}`, collectionName))),
wantKeys: []string{"documentPath", "createTime"},
isErr: false,
},
{
name: "add document with returnData",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collectionPath": "%s",
"documentData": {
"name": {"stringValue": "Return Test"},
"value": {"integerValue": "123"}
},
"returnData": true
}`, collectionName))),
wantKeys: []string{"documentPath", "createTime", "documentData"},
validateDocData: true,
expectedDocData: map[string]interface{}{
"name": "Return Test",
"value": float64(123), // JSON numbers are decoded as float64
},
isErr: false,
},
{
name: "add document with nested maps and arrays",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
"collectionPath": "%s",
"documentData": {
"company": {
"mapValue": {
"fields": {
"name": {"stringValue": "Tech Corp"},
"employees": {
"arrayValue": {
"values": [
{
"mapValue": {
"fields": {
"name": {"stringValue": "John"},
"role": {"stringValue": "Developer"}
}
}
},
{
"mapValue": {
"fields": {
"name": {"stringValue": "Jane"},
"role": {"stringValue": "Manager"}
}
}
}
]
}
}
}
}
}
}
}`, collectionName))),
wantKeys: []string{"documentPath", "createTime"},
isErr: false,
},
{
name: "missing collectionPath parameter",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(`{"documentData": {"test": {"stringValue": "value"}}}`)),
isErr: true,
},
{
name: "missing documentData parameter",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"collectionPath": "%s"}`, collectionName))),
isErr: true,
},
{
name: "invalid documentData format",
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"collectionPath": "%s", "documentData": "not an object"}`, collectionName))),
isErr: true,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Add("Content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if tc.isErr {
return
}
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
t.Fatalf("error parsing response body: %v", err)
}
got, ok := body["result"].(string)
if !ok {
t.Fatalf("unable to find result in response body")
}
// Parse the result string as JSON
var resultJSON map[string]interface{}
err = json.Unmarshal([]byte(got), &resultJSON)
if err != nil {
t.Fatalf("error parsing result as JSON: %v", err)
}
// Check if all wanted keys exist
for _, key := range tc.wantKeys {
if _, exists := resultJSON[key]; !exists {
t.Fatalf("expected key %q not found in result: %s", key, got)
}
}
// Validate document data if required
if tc.validateDocData {
docData, ok := resultJSON["documentData"].(map[string]interface{})
if !ok {
t.Fatalf("documentData is not a map: %v", resultJSON["documentData"])
}
// Use reflect.DeepEqual to compare the document data
if !reflect.DeepEqual(docData, tc.expectedDocData) {
t.Fatalf("documentData mismatch:\nexpected: %v\nactual: %v", tc.expectedDocData, docData)
}
}
})
}
}
func setupFirestoreTestData(t *testing.T, ctx context.Context, client *firestoreapi.Client,
collectionName, subCollectionName, docID1, docID2, docID3 string) func(*testing.T) {
// Create test documents
@@ -619,30 +830,56 @@ func setupFirestoreTestData(t *testing.T, ctx context.Context, client *firestore
t.Fatalf("Failed to create subcollection document: %v", err)
}
// Return cleanup function
// Return cleanup function that deletes ALL collections and documents in the database
return func(t *testing.T) {
// Delete subcollection documents first
subDocs := client.Collection(collectionName).Doc(docID1).Collection(subCollectionName).Documents(ctx)
for {
doc, err := subDocs.Next()
// Helper function to recursively delete all documents in a collection
var deleteCollection func(*firestoreapi.CollectionRef) error
deleteCollection = func(collection *firestoreapi.CollectionRef) error {
// Get all documents in the collection
docs, err := collection.Documents(ctx).GetAll()
if err != nil {
break
return fmt.Errorf("failed to list documents in collection %s: %w", collection.Path, err)
}
if _, err := doc.Ref.Delete(ctx); err != nil {
t.Errorf("Failed to delete subcollection document: %v", err)
// Delete each document and its subcollections
for _, doc := range docs {
// First, get all subcollections of this document
subcollections, err := doc.Ref.Collections(ctx).GetAll()
if err != nil {
return fmt.Errorf("failed to list subcollections of document %s: %w", doc.Ref.Path, err)
}
// Recursively delete each subcollection
for _, subcoll := range subcollections {
if err := deleteCollection(subcoll); err != nil {
return fmt.Errorf("failed to delete subcollection %s: %w", subcoll.Path, err)
}
}
// Delete the document itself
if _, err := doc.Ref.Delete(ctx); err != nil {
return fmt.Errorf("failed to delete document %s: %w", doc.Ref.Path, err)
}
}
return nil
}
// Get all root collections in the database
rootCollections, err := client.Collections(ctx).GetAll()
if err != nil {
t.Errorf("Failed to list root collections: %v", err)
return
}
// Delete each root collection and all its contents
for _, collection := range rootCollections {
if err := deleteCollection(collection); err != nil {
t.Errorf("Failed to delete collection %s and its contents: %v", collection.ID, err)
}
}
// Delete main collection documents
if _, err := client.Collection(collectionName).Doc(docID1).Delete(ctx); err != nil {
t.Errorf("Failed to delete test document 1: %v", err)
}
if _, err := client.Collection(collectionName).Doc(docID2).Delete(ctx); err != nil {
t.Errorf("Failed to delete test document 2: %v", err)
}
if _, err := client.Collection(collectionName).Doc(docID3).Delete(ctx); err != nil {
t.Errorf("Failed to delete test document 3: %v", err)
}
t.Logf("Successfully deleted all collections and documents in the database")
}
}
@@ -911,7 +1148,7 @@ func runFirestoreQueryCollectionTest(t *testing.T, collectionName string) {
"orderBy": "{\"field\": \"age\", \"direction\": \"DESCENDING\"}",
"limit": 2
}`, collectionName))),
wantRegex: `"age":30.*"age":25`, // Should be ordered by age descending (Charlie=35, Alice=30, Bob=25)
wantRegex: `"age":35.*"age":30`, // Should be ordered by age descending (Charlie=35, Alice=30, Bob=25)
isErr: false,
},
{