mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-09 15:38:08 -05:00
feat(tools/firestore-update-document): Add firestore-update-document tool (#1191)
## Add firestore-update-document tool Adds a new tool for updating existing documents in Firestore collections. __What it does:__ - Updates documents at any path in Firestore - Supports partial updates with field masks for selective field modification - Handles all Firestore data types (strings, numbers, booleans, timestamps, geopoints, arrays, maps, etc.) - Supports field deletion using updateMask - Uses Firestore's native JSON format for type safety - Can update nested fields within maps using dot notation __Key parameters:__ - `documentPath`: The path of the document to update - `documentData`: The document content in Firestore JSON format - `updateMask`: Optional array of field paths for selective updates - `returnData`: Optional flag to include updated document in response __Special features:__ - When `updateMask` is provided, only specified fields are updated - Can access nested fields with dot notation (e.g., 'address.city', 'user.profile.name') - Without updateMask, performs a merge operation updating all provided fields
This commit is contained in:
@@ -62,6 +62,7 @@ import (
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetrules"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorelistcollections"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequerycollection"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreupdatedocument"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorevalidaterules"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/http"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardelement"
|
||||
|
||||
@@ -1329,7 +1329,7 @@ func TestPrebuiltTools(t *testing.T) {
|
||||
wantToolset: server.ToolsetConfigs{
|
||||
"firestore-database-tools": tools.ToolsetConfig{
|
||||
Name: "firestore-database-tools",
|
||||
ToolNames: []string{"firestore-get-documents", "firestore-add-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-update-document", "firestore-list-collections", "firestore-delete-documents", "firestore-query-collection", "firestore-get-rules", "firestore-validate-rules"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
327
docs/en/resources/tools/firestore/firestore-update-document.md
Normal file
327
docs/en/resources/tools/firestore/firestore-update-document.md
Normal file
@@ -0,0 +1,327 @@
|
||||
---
|
||||
title: "firestore-update-document"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
A "firestore-update-document" tool updates an existing document in Firestore.
|
||||
aliases:
|
||||
- /resources/tools/firestore-update-document
|
||||
---
|
||||
## Description
|
||||
|
||||
The `firestore-update-document` tool allows you to update existing documents in Firestore. It supports all Firestore data types using Firestore's native JSON format. The tool can perform both full document updates (replacing all fields) or selective field updates using an update mask. When using an update mask, fields referenced in the mask but not present in the document data will be deleted from the document, following Firestore's native behavior.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `documentPath` | string | Yes | The path of the document which needs to be updated |
|
||||
| `documentData` | map | Yes | The data to update in the document. Must use [Firestore's native JSON format](https://cloud.google.com/firestore/docs/reference/rest/Shared.Types/ArrayValue#Value) with typed values |
|
||||
| `updateMask` | array | No | The selective fields to update. If not provided, all fields in documentData will be updated. When provided, only the specified fields will be updated. Fields referenced in the mask but not present in documentData will be deleted from the document |
|
||||
| `returnData` | boolean | No | If set to true, the output will include the data of the updated document. Defaults to false to help avoid overloading the context |
|
||||
|
||||
## Output
|
||||
|
||||
The tool returns a map containing:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `documentPath` | string | The full path of the updated document |
|
||||
| `updateTime` | string | The timestamp when the document was updated |
|
||||
| `documentData` | map | The current data of the document after the update (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"}`
|
||||
|
||||
## Update Modes
|
||||
|
||||
### Full Document Update (Merge All)
|
||||
|
||||
When `updateMask` is not provided, the tool performs a merge operation that updates all fields specified in `documentData` while preserving other existing fields in the document.
|
||||
|
||||
### Selective Field Update
|
||||
|
||||
When `updateMask` is provided, only the fields listed in the mask are updated. This allows for precise control over which fields are modified, added, or deleted. To delete a field, include it in the `updateMask` but omit it from `documentData`.
|
||||
|
||||
## Reference
|
||||
|
||||
| **field** | **type** | **required** | **description** |
|
||||
|-------------|:--------------:|:------------:|----------------------------------------------------------|
|
||||
| kind | string | true | Must be "firestore-update-document". |
|
||||
| source | string | true | Name of the Firestore source to update documents in. |
|
||||
| description | string | true | Description of the tool that is passed to the LLM. |
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Document Update (Full Merge)
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
update-user-doc:
|
||||
kind: firestore-update-document
|
||||
source: my-firestore
|
||||
description: Update a user document
|
||||
```
|
||||
Usage:
|
||||
```json
|
||||
{
|
||||
"documentPath": "users/user123",
|
||||
"documentData": {
|
||||
"name": {
|
||||
"stringValue": "Jane Doe"
|
||||
},
|
||||
"lastUpdated": {
|
||||
"timestampValue": "2025-01-15T10:30:00Z"
|
||||
},
|
||||
"status": {
|
||||
"stringValue": "active"
|
||||
},
|
||||
"score": {
|
||||
"integerValue": "150"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selective Field Update with Update Mask
|
||||
|
||||
```json
|
||||
{
|
||||
"documentPath": "users/user123",
|
||||
"documentData": {
|
||||
"email": {
|
||||
"stringValue": "newemail@example.com"
|
||||
},
|
||||
"profile": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"bio": {
|
||||
"stringValue": "Updated bio text"
|
||||
},
|
||||
"avatar": {
|
||||
"stringValue": "https://example.com/new-avatar.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateMask": ["email", "profile.bio", "profile.avatar"]
|
||||
}
|
||||
```
|
||||
|
||||
### Update with Field Deletion
|
||||
|
||||
To delete fields, include them in the `updateMask` but omit them from `documentData`:
|
||||
|
||||
```json
|
||||
{
|
||||
"documentPath": "users/user123",
|
||||
"documentData": {
|
||||
"name": {
|
||||
"stringValue": "John Smith"
|
||||
}
|
||||
},
|
||||
"updateMask": ["name", "temporaryField", "obsoleteData"],
|
||||
"returnData": true
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
- `name` will be updated to "John Smith"
|
||||
- `temporaryField` and `obsoleteData` will be deleted from the document (they are in the mask but not in the data)
|
||||
|
||||
### Complex Update with Nested Data
|
||||
|
||||
```json
|
||||
{
|
||||
"documentPath": "companies/company456",
|
||||
"documentData": {
|
||||
"metadata": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"lastModified": {
|
||||
"timestampValue": "2025-01-15T14:30:00Z"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"stringValue": "admin@company.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"locations": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"city": {
|
||||
"stringValue": "San Francisco"
|
||||
},
|
||||
"coordinates": {
|
||||
"geoPointValue": {
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"city": {
|
||||
"stringValue": "New York"
|
||||
},
|
||||
"coordinates": {
|
||||
"geoPointValue": {
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.0060
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"revenue": {
|
||||
"doubleValue": 5678901.23
|
||||
}
|
||||
},
|
||||
"updateMask": ["metadata", "locations", "revenue"]
|
||||
}
|
||||
```
|
||||
|
||||
### Update with All Data Types
|
||||
|
||||
```json
|
||||
{
|
||||
"documentPath": "test-documents/doc789",
|
||||
"documentData": {
|
||||
"stringField": {
|
||||
"stringValue": "Updated string"
|
||||
},
|
||||
"integerField": {
|
||||
"integerValue": "999"
|
||||
},
|
||||
"doubleField": {
|
||||
"doubleValue": 2.71828
|
||||
},
|
||||
"booleanField": {
|
||||
"booleanValue": false
|
||||
},
|
||||
"nullField": {
|
||||
"nullValue": null
|
||||
},
|
||||
"timestampField": {
|
||||
"timestampValue": "2025-01-15T16:45:00Z"
|
||||
},
|
||||
"geoPointField": {
|
||||
"geoPointValue": {
|
||||
"latitude": 51.5074,
|
||||
"longitude": -0.1278
|
||||
}
|
||||
},
|
||||
"bytesField": {
|
||||
"bytesValue": "VXBkYXRlZCBkYXRh"
|
||||
},
|
||||
"arrayField": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"stringValue": "updated1"
|
||||
},
|
||||
{
|
||||
"integerValue": "200"
|
||||
},
|
||||
{
|
||||
"booleanValue": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"mapField": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"nestedString": {
|
||||
"stringValue": "updated nested value"
|
||||
},
|
||||
"nestedNumber": {
|
||||
"doubleValue": 88.88
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"referenceField": {
|
||||
"referenceValue": "users/updatedUser"
|
||||
}
|
||||
},
|
||||
"returnData": true
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The tool can be configured to require authentication:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
secure-update-doc:
|
||||
kind: firestore-update-document
|
||||
source: prod-firestore
|
||||
description: Update documents with authentication required
|
||||
authRequired:
|
||||
- google-oauth
|
||||
- api-key
|
||||
```
|
||||
## Error Handling
|
||||
Common errors include:
|
||||
- Document not found (when using update with a non-existent document)
|
||||
- Invalid document path
|
||||
- Missing or invalid document data
|
||||
- Permission denied (if Firestore security rules block the operation)
|
||||
- Invalid data type conversions
|
||||
|
||||
## Best Practices
|
||||
1. **Use update masks for precision**: When you only need to update specific fields, use the `updateMask` parameter to avoid unintended changes
|
||||
2. **Always use typed values**: Every field must be wrapped with its appropriate type indicator (e.g., `{"stringValue": "text"}`)
|
||||
3. **Integer values can be strings**: The tool accepts integer values as strings (e.g., `{"integerValue": "1500"}`)
|
||||
4. **Use returnData sparingly**: Only set to true when you need to verify the exact data after the update
|
||||
5. **Validate data before sending**: Ensure your data matches Firestore's native JSON format
|
||||
6. **Handle timestamps properly**: Use RFC3339 format for timestamp strings
|
||||
7. **Base64 encode binary data**: Binary data must be base64 encoded in the `bytesValue` field
|
||||
8. **Consider security rules**: Ensure your Firestore security rules allow document updates
|
||||
9. **Delete fields using update mask**: To delete fields, include them in the `updateMask` but omit them from `documentData`
|
||||
10. **Test with non-production data first**: Always test your updates on non-critical documents first
|
||||
|
||||
## Differences from Add Documents
|
||||
|
||||
- **Purpose**: Updates existing documents vs. creating new ones
|
||||
- **Document must exist**: For standard updates (though not using updateMask will create if missing with given document id)
|
||||
- **Update mask support**: Allows selective field updates
|
||||
- **Field deletion**: Supports removing specific fields by including them in the mask but not in the data
|
||||
- **Returns updateTime**: Instead of createTime
|
||||
|
||||
## Related Tools
|
||||
|
||||
- [`firestore-add-documents`](firestore-add-documents.md) - Add new documents to Firestore
|
||||
- [`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
|
||||
@@ -21,6 +21,18 @@ tools:
|
||||
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-update-document:
|
||||
kind: firestore-update-document
|
||||
source: firestore-source
|
||||
description: |
|
||||
Updates an existing document in Firestore. Supports both full document updates and selective field updates using an update mask. Please follow the best practices:
|
||||
1. Use update masks for precision: When you only need to update specific fields, use the updateMask parameter to avoid unintended changes
|
||||
2. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
|
||||
3. Delete fields using update mask: To delete fields, include them in the updateMask but omit them from documentData
|
||||
4. Integer values can be strings: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
|
||||
5. Use returnData sparingly: Only set to true when you need to verify the exact data after the update
|
||||
6. Handle timestamps properly: Use RFC3339 format for timestamp strings
|
||||
7. Consider security rules: Ensure your Firestore security rules allow document updates
|
||||
firestore-list-collections:
|
||||
kind: firestore-list-collections
|
||||
source: firestore-source
|
||||
@@ -48,6 +60,7 @@ toolsets:
|
||||
firestore-database-tools:
|
||||
- firestore-get-documents
|
||||
- firestore-add-documents
|
||||
- firestore-update-document
|
||||
- firestore-list-collections
|
||||
- firestore-delete-documents
|
||||
- firestore-query-collection
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// 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 firestoreupdatedocument
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
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-update-document"
|
||||
const documentPathKey string = "documentPath"
|
||||
const documentDataKey string = "documentData"
|
||||
const updateMaskKey string = "updateMask"
|
||||
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
|
||||
documentPathParameter := tools.NewStringParameter(
|
||||
documentPathKey,
|
||||
"The path of the document which needs to be updated",
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
updateMaskParameter := tools.NewArrayParameterWithRequired(
|
||||
updateMaskKey,
|
||||
"The selective fields to update. If not provided, all fields in documentData will be updated. When provided, only the specified fields will be updated. Fields referenced in the mask but not present in documentData will be deleted from the document",
|
||||
false, // not required
|
||||
tools.NewStringParameter("field", "Field path to update or delete. Use dot notation to access nested fields within maps (e.g., 'address.city' to update the city field within an address map, or 'user.profile.name' for deeply nested fields). To delete a field, include it in the mask but omit it from documentData. Note: You cannot update individual array elements; you must update the entire array field"),
|
||||
)
|
||||
|
||||
returnDataParameter := tools.NewBooleanParameterWithDefault(
|
||||
returnDocumentDataKey,
|
||||
false,
|
||||
"If set to true the output will have the data of the updated document. This flag if set to false will help avoid overloading the context of the agent.",
|
||||
)
|
||||
|
||||
parameters := tools.Parameters{
|
||||
documentPathParameter,
|
||||
documentDataParameter,
|
||||
updateMaskParameter,
|
||||
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, accessToken tools.AccessToken) (any, error) {
|
||||
mapParams := params.AsMap()
|
||||
|
||||
// Get document path
|
||||
documentPath, ok := mapParams[documentPathKey].(string)
|
||||
if !ok || documentPath == "" {
|
||||
return nil, fmt.Errorf("invalid or missing '%s' parameter", documentPathKey)
|
||||
}
|
||||
|
||||
// Get document data
|
||||
documentDataRaw, ok := mapParams[documentDataKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid or missing '%s' parameter", documentDataKey)
|
||||
}
|
||||
|
||||
// Get update mask if provided
|
||||
var updatePaths []string
|
||||
if updateMaskRaw, ok := mapParams[updateMaskKey]; ok && updateMaskRaw != nil {
|
||||
if updateMaskArray, ok := updateMaskRaw.([]any); ok {
|
||||
// Use ConvertAnySliceToTyped to convert the slice
|
||||
typedSlice, err := tools.ConvertAnySliceToTyped(updateMaskArray, "string")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert update mask: %w", err)
|
||||
}
|
||||
updatePaths, ok = typedSlice.([]string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected type conversion error for update mask")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get return document data flag
|
||||
returnData := false
|
||||
if val, ok := mapParams[returnDocumentDataKey].(bool); ok {
|
||||
returnData = val
|
||||
}
|
||||
|
||||
// Get the document reference
|
||||
docRef := t.Client.Doc(documentPath)
|
||||
|
||||
// Prepare update data
|
||||
var writeResult *firestoreapi.WriteResult
|
||||
var writeErr error
|
||||
|
||||
if len(updatePaths) > 0 {
|
||||
// Use selective field update with update mask
|
||||
updates := make([]firestoreapi.Update, 0, len(updatePaths))
|
||||
|
||||
// Convert document data without delete markers
|
||||
dataMap, err := util.JSONToFirestoreValue(documentDataRaw, t.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert document data: %w", err)
|
||||
}
|
||||
|
||||
// Ensure it's a map
|
||||
dataMapTyped, ok := dataMap.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("document data must be a map")
|
||||
}
|
||||
|
||||
for _, path := range updatePaths {
|
||||
// Get the value for this path from the document data
|
||||
value, exists := getFieldValue(dataMapTyped, path)
|
||||
if !exists {
|
||||
// Field not in document data but in mask - delete it
|
||||
value = firestoreapi.Delete
|
||||
}
|
||||
|
||||
updates = append(updates, firestoreapi.Update{
|
||||
Path: path,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
writeResult, writeErr = docRef.Update(ctx, updates)
|
||||
} else {
|
||||
// Update all fields in the document data (merge)
|
||||
documentData, err := util.JSONToFirestoreValue(documentDataRaw, t.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert document data: %w", err)
|
||||
}
|
||||
writeResult, writeErr = docRef.Set(ctx, documentData, firestoreapi.MergeAll)
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
return nil, fmt.Errorf("failed to update document: %w", writeErr)
|
||||
}
|
||||
|
||||
// Build the response
|
||||
response := map[string]any{
|
||||
"documentPath": docRef.Path,
|
||||
"updateTime": writeResult.UpdateTime.Format("2006-01-02T15:04:05.999999999Z"),
|
||||
}
|
||||
|
||||
// Add document data if requested
|
||||
if returnData {
|
||||
// Fetch the updated document to return the current state
|
||||
snapshot, err := docRef.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve updated document: %w", err)
|
||||
}
|
||||
|
||||
// Convert the document data to simple JSON format
|
||||
simplifiedData := util.FirestoreValueToJSON(snapshot.Data())
|
||||
response["documentData"] = simplifiedData
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
// getFieldValue retrieves a value from a nested map using a dot-separated path
|
||||
func getFieldValue(data map[string]interface{}, path string) (interface{}, bool) {
|
||||
// Split the path by dots for nested field access
|
||||
parts := strings.Split(path, ".")
|
||||
|
||||
current := data
|
||||
for i, part := range parts {
|
||||
if i == len(parts)-1 {
|
||||
// Last part - return the value
|
||||
if value, exists := current[part]; exists {
|
||||
return value, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Navigate deeper into the structure
|
||||
if next, ok := current[part].(map[string]interface{}); ok {
|
||||
current = next
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// 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 firestoreupdatedocument
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/googleapis/genai-toolbox/internal/sources"
|
||||
firestoreds "github.com/googleapis/genai-toolbox/internal/sources/firestore"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
)
|
||||
|
||||
func TestNewConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
yaml: `
|
||||
name: test-update-document
|
||||
kind: firestore-update-document
|
||||
source: test-firestore
|
||||
description: Update a document in Firestore
|
||||
authRequired:
|
||||
- google-oauth
|
||||
`,
|
||||
want: Config{
|
||||
Name: "test-update-document",
|
||||
Kind: "firestore-update-document",
|
||||
Source: "test-firestore",
|
||||
Description: "Update a document in Firestore",
|
||||
AuthRequired: []string{"google-oauth"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "minimal config",
|
||||
yaml: `
|
||||
name: test-update-document
|
||||
kind: firestore-update-document
|
||||
source: test-firestore
|
||||
description: Update a document
|
||||
`,
|
||||
want: Config{
|
||||
Name: "test-update-document",
|
||||
Kind: "firestore-update-document",
|
||||
Source: "test-firestore",
|
||||
Description: "Update a document",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid yaml",
|
||||
yaml: `
|
||||
name: test-update-document
|
||||
kind: [invalid
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
decoder := yaml.NewDecoder(strings.NewReader(tt.yaml))
|
||||
got, err := newConfig(context.Background(), "test-update-document", decoder)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Fatalf("config mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ToolConfigKind(t *testing.T) {
|
||||
cfg := Config{}
|
||||
got := cfg.ToolConfigKind()
|
||||
want := "firestore-update-document"
|
||||
if got != want {
|
||||
t.Fatalf("ToolConfigKind() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Initialize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
sources map[string]sources.Source
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid initialization",
|
||||
config: Config{
|
||||
Name: "test-update-document",
|
||||
Kind: "firestore-update-document",
|
||||
Source: "test-firestore",
|
||||
Description: "Update a document",
|
||||
},
|
||||
sources: map[string]sources.Source{
|
||||
"test-firestore": &firestoreds.Source{},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "source not found",
|
||||
config: Config{
|
||||
Name: "test-update-document",
|
||||
Kind: "firestore-update-document",
|
||||
Source: "missing-source",
|
||||
Description: "Update a document",
|
||||
},
|
||||
sources: map[string]sources.Source{},
|
||||
wantErr: true,
|
||||
errMsg: "no source named \"missing-source\" configured",
|
||||
},
|
||||
{
|
||||
name: "incompatible source",
|
||||
config: Config{
|
||||
Name: "test-update-document",
|
||||
Kind: "firestore-update-document",
|
||||
Source: "wrong-source",
|
||||
Description: "Update a document",
|
||||
},
|
||||
sources: map[string]sources.Source{
|
||||
"wrong-source": &mockIncompatibleSource{},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid source for \"firestore-update-document\" tool",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tool, err := tt.config.Initialize(tt.sources)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error but got none")
|
||||
}
|
||||
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Fatalf("error message %q does not contain %q", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tool == nil {
|
||||
t.Fatalf("expected tool to be non-nil")
|
||||
}
|
||||
|
||||
// Verify tool properties
|
||||
actualTool := tool.(Tool)
|
||||
if actualTool.Name != tt.config.Name {
|
||||
t.Fatalf("tool.Name = %v, want %v", actualTool.Name, tt.config.Name)
|
||||
}
|
||||
if actualTool.Kind != "firestore-update-document" {
|
||||
t.Fatalf("tool.Kind = %v, want %v", actualTool.Kind, "firestore-update-document")
|
||||
}
|
||||
if diff := cmp.Diff(tt.config.AuthRequired, actualTool.AuthRequired); diff != "" {
|
||||
t.Fatalf("AuthRequired mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if actualTool.Parameters == nil {
|
||||
t.Fatalf("expected Parameters to be non-nil")
|
||||
}
|
||||
if len(actualTool.Parameters) != 4 {
|
||||
t.Fatalf("len(Parameters) = %v, want 4", len(actualTool.Parameters))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_ParseParams(t *testing.T) {
|
||||
tool := Tool{
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameter("documentPath", "Document path"),
|
||||
tools.NewMapParameter("documentData", "Document data", ""),
|
||||
tools.NewArrayParameterWithRequired("updateMask", "Update mask", false, tools.NewStringParameter("field", "Field")),
|
||||
tools.NewBooleanParameterWithDefault("returnData", false, "Return data"),
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
claims map[string]map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid params with all fields",
|
||||
data: map[string]any{
|
||||
"documentPath": "users/user1",
|
||||
"documentData": map[string]any{
|
||||
"name": map[string]any{"stringValue": "John"},
|
||||
},
|
||||
"updateMask": []any{"name"},
|
||||
"returnData": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid params without optional fields",
|
||||
data: map[string]any{
|
||||
"documentPath": "users/user1",
|
||||
"documentData": map[string]any{
|
||||
"name": map[string]any{"stringValue": "John"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing required documentPath",
|
||||
data: map[string]any{
|
||||
"documentData": map[string]any{
|
||||
"name": map[string]any{"stringValue": "John"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing required documentData",
|
||||
data: map[string]any{
|
||||
"documentPath": "users/user1",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params, err := tool.ParseParams(tt.data, tt.claims)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
t.Fatalf("expected params to be non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_Manifest(t *testing.T) {
|
||||
tool := Tool{
|
||||
manifest: tools.Manifest{
|
||||
Description: "Test description",
|
||||
Parameters: []tools.ParameterManifest{
|
||||
{
|
||||
Name: "documentPath",
|
||||
Type: "string",
|
||||
Description: "Document path",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
AuthRequired: []string{"google-oauth"},
|
||||
},
|
||||
}
|
||||
|
||||
manifest := tool.Manifest()
|
||||
if manifest.Description != "Test description" {
|
||||
t.Fatalf("manifest.Description = %v, want %v", manifest.Description, "Test description")
|
||||
}
|
||||
if len(manifest.Parameters) != 1 {
|
||||
t.Fatalf("len(manifest.Parameters) = %v, want 1", len(manifest.Parameters))
|
||||
}
|
||||
if diff := cmp.Diff([]string{"google-oauth"}, manifest.AuthRequired); diff != "" {
|
||||
t.Fatalf("AuthRequired mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_McpManifest(t *testing.T) {
|
||||
tool := Tool{
|
||||
mcpManifest: tools.McpManifest{
|
||||
Name: "test-update-document",
|
||||
Description: "Test description",
|
||||
InputSchema: tools.McpToolsSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.ParameterMcpManifest{
|
||||
"documentPath": {
|
||||
Type: "string",
|
||||
Description: "Document path",
|
||||
},
|
||||
},
|
||||
Required: []string{"documentPath"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mcpManifest := tool.McpManifest()
|
||||
if mcpManifest.Name != "test-update-document" {
|
||||
t.Fatalf("mcpManifest.Name = %v, want %v", mcpManifest.Name, "test-update-document")
|
||||
}
|
||||
if mcpManifest.Description != "Test description" {
|
||||
t.Fatalf("mcpManifest.Description = %v, want %v", mcpManifest.Description, "Test description")
|
||||
}
|
||||
if mcpManifest.InputSchema.Type == "" {
|
||||
t.Fatalf("expected InputSchema to be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTool_Authorized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authRequired []string
|
||||
verifiedAuthServices []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "no auth required",
|
||||
authRequired: nil,
|
||||
verifiedAuthServices: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auth required and provided",
|
||||
authRequired: []string{"google-oauth"},
|
||||
verifiedAuthServices: []string{"google-oauth"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auth required but not provided",
|
||||
authRequired: []string{"google-oauth"},
|
||||
verifiedAuthServices: []string{"api-key"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple auth required, one provided",
|
||||
authRequired: []string{"google-oauth", "api-key"},
|
||||
verifiedAuthServices: []string{"google-oauth"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tool := Tool{
|
||||
AuthRequired: tt.authRequired,
|
||||
}
|
||||
got := tool.Authorized(tt.verifiedAuthServices)
|
||||
if got != tt.want {
|
||||
t.Fatalf("Authorized() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFieldValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
path string
|
||||
want interface{}
|
||||
exists bool
|
||||
}{
|
||||
{
|
||||
name: "simple field",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
path: "name",
|
||||
want: "John",
|
||||
exists: true,
|
||||
},
|
||||
{
|
||||
name: "nested field",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
},
|
||||
path: "user.name",
|
||||
want: "John",
|
||||
exists: true,
|
||||
},
|
||||
{
|
||||
name: "deeply nested field",
|
||||
data: map[string]interface{}{
|
||||
"level1": map[string]interface{}{
|
||||
"level2": map[string]interface{}{
|
||||
"level3": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "level1.level2.level3",
|
||||
want: "value",
|
||||
exists: true,
|
||||
},
|
||||
{
|
||||
name: "non-existent field",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
path: "age",
|
||||
want: nil,
|
||||
exists: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent nested field",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
},
|
||||
path: "user.age",
|
||||
want: nil,
|
||||
exists: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, exists := getFieldValue(tt.data, tt.path)
|
||||
if exists != tt.exists {
|
||||
t.Fatalf("exists = %v, want %v", exists, tt.exists)
|
||||
}
|
||||
if tt.exists {
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Fatalf("value mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockIncompatibleSource is a mock source that doesn't implement compatibleSource
|
||||
type mockIncompatibleSource struct{}
|
||||
|
||||
func (m *mockIncompatibleSource) SourceKind() string {
|
||||
return "mock"
|
||||
}
|
||||
@@ -222,12 +222,12 @@ func isValidDocumentPath(path string) bool {
|
||||
|
||||
// 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
|
||||
}
|
||||
rawSegments := strings.Split(path, "/")
|
||||
var segments []string
|
||||
for _, s := range rawSegments {
|
||||
if s != "" {
|
||||
segments = append(segments, s)
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestFirestoreToolEndpoints(t *testing.T) {
|
||||
runFirestoreQueryCollectionTest(t, testCollectionName)
|
||||
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
|
||||
runFirestoreAddDocumentsTest(t, testCollectionName)
|
||||
runFirestoreUpdateDocumentTest(t, testCollectionName, testDocID1)
|
||||
runFirestoreDeleteDocumentsTest(t, docPath3)
|
||||
runFirestoreGetRulesTest(t)
|
||||
runFirestoreValidateRulesTest(t)
|
||||
@@ -576,6 +577,11 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
|
||||
"source": "my-instance",
|
||||
"description": "Add documents to Firestore",
|
||||
},
|
||||
"firestore-update-doc": map[string]any{
|
||||
"kind": "firestore-update-document",
|
||||
"source": "my-instance",
|
||||
"description": "Update a document in Firestore",
|
||||
},
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
@@ -584,6 +590,237 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func runFirestoreUpdateDocumentTest(t *testing.T, collectionName string, docID string) {
|
||||
docPath := fmt.Sprintf("%s/%s", collectionName, docID)
|
||||
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
api string
|
||||
requestBody io.Reader
|
||||
wantKeys []string
|
||||
validateContent bool
|
||||
expectedContent map[string]interface{}
|
||||
isErr bool
|
||||
}{
|
||||
{
|
||||
name: "update document with simple fields",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"name": {"stringValue": "Alice Updated"},
|
||||
"status": {"stringValue": "active"}
|
||||
}
|
||||
}`, docPath))),
|
||||
wantKeys: []string{"documentPath", "updateTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "update document with selective fields using updateMask",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"age": {"integerValue": "31"},
|
||||
"email": {"stringValue": "alice@example.com"}
|
||||
},
|
||||
"updateMask": ["age"]
|
||||
}`, docPath))),
|
||||
wantKeys: []string{"documentPath", "updateTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "update document with field deletion",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"name": {"stringValue": "Alice Final"}
|
||||
},
|
||||
"updateMask": ["name", "status"]
|
||||
}`, docPath))),
|
||||
wantKeys: []string{"documentPath", "updateTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "update document with complex types",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"location": {
|
||||
"geoPointValue": {
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.0060
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{"stringValue": "updated"},
|
||||
{"stringValue": "test"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"lastModified": {"timestampValue": "2025-01-15T10:00:00Z"},
|
||||
"version": {"integerValue": "2"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, docPath))),
|
||||
wantKeys: []string{"documentPath", "updateTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "update document with returnData",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"testField": {"stringValue": "test value"},
|
||||
"testNumber": {"integerValue": "42"}
|
||||
},
|
||||
"returnData": true
|
||||
}`, docPath))),
|
||||
wantKeys: []string{"documentPath", "updateTime", "documentData"},
|
||||
validateContent: true,
|
||||
expectedContent: map[string]interface{}{
|
||||
"testField": "test value",
|
||||
"testNumber": float64(42), // JSON numbers are decoded as float64
|
||||
},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "update nested fields with updateMask",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"profile": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"bio": {"stringValue": "Updated bio"},
|
||||
"avatar": {"stringValue": "avatar.jpg"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateMask": ["profile.bio", "profile.avatar"]
|
||||
}`, docPath))),
|
||||
wantKeys: []string{"documentPath", "updateTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing documentPath parameter",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/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-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"documentPath": "%s"}`, docPath))),
|
||||
isErr: true,
|
||||
},
|
||||
{
|
||||
name: "update non-existent document",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(`{
|
||||
"documentPath": "non-existent-collection/non-existent-doc",
|
||||
"documentData": {
|
||||
"field": {"stringValue": "value"}
|
||||
}
|
||||
}`)),
|
||||
wantKeys: []string{"documentPath", "updateTime"}, // Set with MergeAll creates if doesn't exist
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid field in updateMask",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-update-doc/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"documentPath": "%s",
|
||||
"documentData": {
|
||||
"field1": {"stringValue": "value1"}
|
||||
},
|
||||
"updateMask": ["field1", "nonExistentField"]
|
||||
}`, docPath))),
|
||||
isErr: true, // Should fail because nonExistentField is not in documentData
|
||||
},
|
||||
}
|
||||
|
||||
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.validateContent {
|
||||
docData, ok := resultJSON["documentData"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("documentData is not a map: %v", resultJSON["documentData"])
|
||||
}
|
||||
|
||||
// Check that expected fields are present with correct values
|
||||
for key, expectedValue := range tc.expectedContent {
|
||||
actualValue, exists := docData[key]
|
||||
if !exists {
|
||||
t.Fatalf("expected field %q not found in documentData", key)
|
||||
}
|
||||
if actualValue != expectedValue {
|
||||
t.Fatalf("field %q mismatch: expected %v, got %v", key, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runFirestoreAddDocumentsTest(t *testing.T, collectionName string) {
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user