mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-15 02:18:10 -05:00
Compare commits
74 Commits
config-sou
...
trehanshak
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef01b0413 | ||
|
|
d4ae67f995 | ||
|
|
f632e9e46e | ||
|
|
81a46552ca | ||
|
|
b25ecd352b | ||
|
|
bb003699f3 | ||
|
|
695338f08b | ||
|
|
ec846feedc | ||
|
|
cb06b7af17 | ||
|
|
ef46db8896 | ||
|
|
775cbd9c03 | ||
|
|
373a19a9c6 | ||
|
|
6c9f0a061e | ||
|
|
0442d51db5 | ||
|
|
5e7a50fc8d | ||
|
|
802edf6c01 | ||
|
|
5d621af96f | ||
|
|
332110050e | ||
|
|
aef1100ece | ||
|
|
d69a0adc89 | ||
|
|
8d7925e207 | ||
|
|
8a7eadb07b | ||
|
|
538c8ac317 | ||
|
|
8532561b7d | ||
|
|
c6fafc0b7b | ||
|
|
c6e5d52b40 | ||
|
|
ceb2ac3f5d | ||
|
|
45872e1fb8 | ||
|
|
eca3b13841 | ||
|
|
636c29d106 | ||
|
|
e75decdb3a | ||
|
|
53f6cbb4f6 | ||
|
|
3607981bd4 | ||
|
|
fd5547f583 | ||
|
|
4fc9161f9c | ||
|
|
a5e92331f8 | ||
|
|
550c12ae44 | ||
|
|
534a71713a | ||
|
|
4d1fb18ffb | ||
|
|
3b95b2de06 | ||
|
|
a2b0ed2462 | ||
|
|
da9cf2f8ef | ||
|
|
6ecd8c7982 | ||
|
|
215423429e | ||
|
|
04a4a8e88f | ||
|
|
ae8fe0cd36 | ||
|
|
3aee57c1fe | ||
|
|
5044bf8836 | ||
|
|
ceb118ae2a | ||
|
|
f598c6ad92 | ||
|
|
962011aa64 | ||
|
|
75c590a2e1 | ||
|
|
516f0ea0b1 | ||
|
|
56cd2d79b6 | ||
|
|
ee9d9038ff | ||
|
|
f0dde117ee | ||
|
|
1e38297977 | ||
|
|
8b5ba22017 | ||
|
|
5bcf376ca1 | ||
|
|
a3110d1678 | ||
|
|
9d9adaac93 | ||
|
|
3522a723b8 | ||
|
|
42e09bff01 | ||
|
|
ca324966bc | ||
|
|
93d0a9b4c0 | ||
|
|
32d478cd20 | ||
|
|
0ec910ca9b | ||
|
|
d06377d561 | ||
|
|
02884910dc | ||
|
|
e5a4851605 | ||
|
|
ffedbe1739 | ||
|
|
4593f9eb31 | ||
|
|
48012638af | ||
|
|
185c29e07e |
@@ -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"
|
||||
|
||||
@@ -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-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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
326
docs/en/resources/tools/firestore/firestore-update-document.md
Normal file
326
docs/en/resources/tools/firestore/firestore-update-document.md
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: "firestore-update-document"
|
||||
type: docs
|
||||
weight: 2
|
||||
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. It also supports field deletion when using selective updates.
|
||||
|
||||
## 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 |
|
||||
| `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"}`
|
||||
|
||||
### Special Operations
|
||||
- **Delete Field**: `{"deleteValue": true}` (only works when `updateMask` is specified)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
```json
|
||||
{
|
||||
"documentPath": "users/user123",
|
||||
"documentData": {
|
||||
"name": {
|
||||
"stringValue": "John Smith"
|
||||
},
|
||||
"temporaryField": {
|
||||
"deleteValue": true
|
||||
},
|
||||
"obsoleteData": {
|
||||
"deleteValue": true
|
||||
}
|
||||
},
|
||||
"updateMask": ["name", "temporaryField", "obsoleteData"],
|
||||
"returnData": true
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
- Field not found in document data when using update mask
|
||||
|
||||
## 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. **Use deleteValue for field removal**: When using update mask, set `{"deleteValue": true}` to remove fields
|
||||
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 Set with MergeAll will create if missing)
|
||||
- **Update mask support**: Allows selective field updates
|
||||
- **Field deletion**: Supports removing specific fields with `deleteValue`
|
||||
- **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. Use deleteValue for field removal: When using update mask, set {"deleteValue": true} to remove fields
|
||||
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,326 @@
|
||||
// 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"}
|
||||
- Delete field: {"deleteValue": true} (only works when updateMask is specified)`,
|
||||
"", // 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, and you can use deleteValue to delete specific fields",
|
||||
false, // not required
|
||||
tools.NewStringParameter("field", "Field path to update. 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). 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) (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 err error
|
||||
|
||||
documentData, err := util.JSONToFirestoreValue(documentDataRaw, t.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert document data: %w", err)
|
||||
}
|
||||
|
||||
if len(updatePaths) > 0 {
|
||||
// Use selective field update with update mask
|
||||
updates := make([]firestoreapi.Update, 0, len(updatePaths))
|
||||
|
||||
// Process the document data to handle special values
|
||||
dataMap, err := processDocumentDataForDeleteMarkers(documentDataRaw, t.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process document data: %w", err)
|
||||
}
|
||||
|
||||
for _, path := range updatePaths {
|
||||
// Get the value for this path from the document data
|
||||
value, exists := getFieldValue(dataMap, path)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("field '%s' not found in document data", path)
|
||||
}
|
||||
|
||||
// The value will already be firestore.Delete if it was a deleteValue
|
||||
updates = append(updates, firestoreapi.Update{
|
||||
Path: path,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
writeResult, err = docRef.Update(ctx, updates)
|
||||
} else {
|
||||
// Update all fields in the document data (merge)
|
||||
writeResult, err = docRef.Set(ctx, documentData, firestoreapi.MergeAll)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update document: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// processDocumentDataForDeleteMarkers processes the document data for update operations,
|
||||
// handling special values like deleteValue
|
||||
func processDocumentDataForDeleteMarkers(data interface{}, client *firestoreapi.Client) (map[string]interface{}, error) {
|
||||
// Convert with delete support enabled
|
||||
processed, err := util.JSONToFirestoreValue(data, client, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure it's a map
|
||||
if processedMap, ok := processed.(map[string]interface{}); ok {
|
||||
return processedMap, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("processed data is not a map")
|
||||
}
|
||||
|
||||
// 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,471 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -28,17 +28,33 @@ import (
|
||||
// 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 allowDelete is true, deleteValue markers will be converted to firestore.Delete
|
||||
func JSONToFirestoreValue(value interface{}, client *firestore.Client, allowDelete ...bool) (interface{}, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check if delete values are allowed (optional parameter)
|
||||
allowDeleteValue := false
|
||||
if len(allowDelete) > 0 {
|
||||
allowDeleteValue = allowDelete[0]
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
// Check for typed values
|
||||
if len(v) == 1 {
|
||||
for key, val := range v {
|
||||
switch key {
|
||||
case "deleteValue":
|
||||
// Handle delete marker if allowed
|
||||
if allowDeleteValue {
|
||||
if b, ok := val.(bool); ok && b {
|
||||
return firestore.Delete, nil
|
||||
}
|
||||
}
|
||||
// If not allowed or not true, treat as regular map
|
||||
return convertPlainMap(v, client, allowDeleteValue)
|
||||
case "nullValue":
|
||||
return nil, nil
|
||||
case "booleanValue":
|
||||
@@ -109,7 +125,7 @@ func JSONToFirestoreValue(value interface{}, client *firestore.Client) (interfac
|
||||
if values, ok := arrayMap["values"].([]interface{}); ok {
|
||||
result := make([]interface{}, len(values))
|
||||
for i, item := range values {
|
||||
converted, err := JSONToFirestoreValue(item, client)
|
||||
converted, err := JSONToFirestoreValue(item, client, allowDeleteValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("array item %d: %w", i, err)
|
||||
}
|
||||
@@ -125,7 +141,7 @@ func JSONToFirestoreValue(value interface{}, client *firestore.Client) (interfac
|
||||
if fields, ok := mapMap["fields"].(map[string]interface{}); ok {
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range fields {
|
||||
converted, err := JSONToFirestoreValue(v, client)
|
||||
converted, err := JSONToFirestoreValue(v, client, allowDeleteValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map field %q: %w", k, err)
|
||||
}
|
||||
@@ -147,12 +163,12 @@ func JSONToFirestoreValue(value interface{}, client *firestore.Client) (interfac
|
||||
return nil, fmt.Errorf("reference value must be a string")
|
||||
default:
|
||||
// If not a typed value, treat as regular map
|
||||
return convertPlainMap(v, client)
|
||||
return convertPlainMap(v, client, allowDeleteValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regular map without type annotation
|
||||
return convertPlainMap(v, client)
|
||||
return convertPlainMap(v, client, allowDeleteValue)
|
||||
default:
|
||||
// Plain values (for backward compatibility)
|
||||
return value, nil
|
||||
@@ -160,10 +176,10 @@ func JSONToFirestoreValue(value interface{}, client *firestore.Client) (interfac
|
||||
}
|
||||
|
||||
// convertPlainMap converts a plain map to Firestore format
|
||||
func convertPlainMap(m map[string]interface{}, client *firestore.Client) (map[string]interface{}, error) {
|
||||
func convertPlainMap(m map[string]interface{}, client *firestore.Client, allowDelete bool) (map[string]interface{}, error) {
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range m {
|
||||
converted, err := JSONToFirestoreValue(v, client)
|
||||
converted, err := JSONToFirestoreValue(v, client, allowDelete)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", k, err)
|
||||
}
|
||||
|
||||
@@ -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,238 @@ 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"},
|
||||
"status": {"deleteValue": true}
|
||||
},
|
||||
"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