mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-10 16:08:16 -05:00
feat(tools/firestore-add-documents): Add firestore-add-documents tool (#1107)
## Add firestore-add-documents tool Adds a new tool for creating documents in Firestore collections. __What it does:__ - Adds documents to any Firestore collection - Auto-generates unique document IDs - Supports all Firestore data types (strings, numbers, booleans, timestamps, geopoints, arrays, maps, etc.) - Uses Firestore's native JSON format for type safety __Key parameters:__ - `collectionPath`: Where to add the document - `documentData`: The document content in Firestore JSON format - `returnData`: Optional flag to include created document in response --------- Co-authored-by: Averi Kitsch <akitsch@google.com>
This commit is contained in:
@@ -56,6 +56,7 @@ import (
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/dataplex/dataplexsearchaspecttypes"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/dataplex/dataplexsearchentries"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/dgraph"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreadddocuments"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoredeletedocuments"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetdocuments"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetrules"
|
||||
|
||||
@@ -1260,7 +1260,7 @@ func TestPrebuiltTools(t *testing.T) {
|
||||
wantToolset: server.ToolsetConfigs{
|
||||
"firestore-database-tools": tools.ToolsetConfig{
|
||||
Name: "firestore-database-tools",
|
||||
ToolNames: []string{"firestore-get-documents", "firestore-list-collections", "firestore-delete-documents", "firestore-query-collection", "firestore-get-rules", "firestore-validate-rules"},
|
||||
ToolNames: []string{"firestore-get-documents", "firestore-add-documents", "firestore-list-collections", "firestore-delete-documents", "firestore-query-collection", "firestore-get-rules", "firestore-validate-rules"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
274
docs/en/resources/tools/firestore/firestore-add-documents.md
Normal file
274
docs/en/resources/tools/firestore/firestore-add-documents.md
Normal file
@@ -0,0 +1,274 @@
|
||||
---
|
||||
title: "firestore-add-documents"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
A "firestore-add-documents" tool adds document to a given collection path.
|
||||
aliases:
|
||||
- /resources/tools/firestore-add-documents
|
||||
---
|
||||
## Description
|
||||
|
||||
The `firestore-add-documents` tool allows you to add new documents to a Firestore collection. It supports all Firestore data types using Firestore's native JSON format. The tool automatically generates a unique document ID for each new document.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `collectionPath` | string | Yes | The path of the collection where the document will be added |
|
||||
| `documentData` | map | Yes | The data to be added as a document to the given collection. Must use [Firestore's native JSON format](https://cloud.google.com/firestore/docs/reference/rest/Shared.Types/ArrayValue#Value) with typed values |
|
||||
| `returnData` | boolean | No | If set to true, the output will include the data of the created document. Defaults to false to help avoid overloading the context |
|
||||
|
||||
## Output
|
||||
|
||||
The tool returns a map containing:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `documentPath` | string | The full resource name of the created document (e.g., `projects/{projectId}/databases/{databaseId}/documents/{document_path}`) |
|
||||
| `createTime` | string | The timestamp when the document was created |
|
||||
| `documentData` | map | The data that was added (only included when `returnData` is true) |
|
||||
|
||||
## Data Type Format
|
||||
|
||||
The tool requires Firestore's native JSON format for document data. Each field must be wrapped with its type indicator:
|
||||
|
||||
### Basic Types
|
||||
- **String**: `{"stringValue": "your string"}`
|
||||
- **Integer**: `{"integerValue": "123"}` or `{"integerValue": 123}`
|
||||
- **Double**: `{"doubleValue": 123.45}`
|
||||
- **Boolean**: `{"booleanValue": true}`
|
||||
- **Null**: `{"nullValue": null}`
|
||||
- **Bytes**: `{"bytesValue": "base64EncodedString"}`
|
||||
- **Timestamp**: `{"timestampValue": "2025-01-07T10:00:00Z"}` (RFC3339 format)
|
||||
|
||||
### Complex Types
|
||||
- **GeoPoint**: `{"geoPointValue": {"latitude": 34.052235, "longitude": -118.243683}}`
|
||||
- **Array**: `{"arrayValue": {"values": [{"stringValue": "item1"}, {"integerValue": "2"}]}}`
|
||||
- **Map**: `{"mapValue": {"fields": {"key1": {"stringValue": "value1"}, "key2": {"booleanValue": true}}}}`
|
||||
- **Reference**: `{"referenceValue": "collection/document"}`
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Document Creation
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
add-company-doc:
|
||||
kind: firestore-add-documents
|
||||
source: my-firestore
|
||||
description: Add a new company document
|
||||
```
|
||||
|
||||
Usage:
|
||||
```json
|
||||
{
|
||||
"collectionPath": "companies",
|
||||
"documentData": {
|
||||
"name": {
|
||||
"stringValue": "Acme Corporation"
|
||||
},
|
||||
"establishmentDate": {
|
||||
"timestampValue": "2000-01-15T10:30:00Z"
|
||||
},
|
||||
"location": {
|
||||
"geoPointValue": {
|
||||
"latitude": 34.052235,
|
||||
"longitude": -118.243683
|
||||
}
|
||||
},
|
||||
"active": {
|
||||
"booleanValue": true
|
||||
},
|
||||
"employeeCount": {
|
||||
"integerValue": "1500"
|
||||
},
|
||||
"annualRevenue": {
|
||||
"doubleValue": 1234567.89
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Nested Maps and Arrays
|
||||
|
||||
```json
|
||||
{
|
||||
"collectionPath": "companies",
|
||||
"documentData": {
|
||||
"name": {
|
||||
"stringValue": "Tech Innovations Inc"
|
||||
},
|
||||
"contactInfo": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"email": {
|
||||
"stringValue": "info@techinnovations.com"
|
||||
},
|
||||
"phone": {
|
||||
"stringValue": "+1-555-123-4567"
|
||||
},
|
||||
"address": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"street": {
|
||||
"stringValue": "123 Innovation Drive"
|
||||
},
|
||||
"city": {
|
||||
"stringValue": "San Francisco"
|
||||
},
|
||||
"state": {
|
||||
"stringValue": "CA"
|
||||
},
|
||||
"zipCode": {
|
||||
"stringValue": "94105"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"stringValue": "Product A"
|
||||
},
|
||||
{
|
||||
"stringValue": "Product B"
|
||||
},
|
||||
{
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"productName": {
|
||||
"stringValue": "Product C Premium"
|
||||
},
|
||||
"version": {
|
||||
"integerValue": "3"
|
||||
},
|
||||
"features": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"stringValue": "Advanced Analytics"
|
||||
},
|
||||
{
|
||||
"stringValue": "Real-time Sync"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"returnData": true
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example with All Data Types
|
||||
|
||||
```json
|
||||
{
|
||||
"collectionPath": "test-documents",
|
||||
"documentData": {
|
||||
"stringField": {
|
||||
"stringValue": "Hello World"
|
||||
},
|
||||
"integerField": {
|
||||
"integerValue": "42"
|
||||
},
|
||||
"doubleField": {
|
||||
"doubleValue": 3.14159
|
||||
},
|
||||
"booleanField": {
|
||||
"booleanValue": true
|
||||
},
|
||||
"nullField": {
|
||||
"nullValue": null
|
||||
},
|
||||
"timestampField": {
|
||||
"timestampValue": "2025-01-07T15:30:00Z"
|
||||
},
|
||||
"geoPointField": {
|
||||
"geoPointValue": {
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194
|
||||
}
|
||||
},
|
||||
"bytesField": {
|
||||
"bytesValue": "SGVsbG8gV29ybGQh"
|
||||
},
|
||||
"arrayField": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"stringValue": "item1"
|
||||
},
|
||||
{
|
||||
"integerValue": "2"
|
||||
},
|
||||
{
|
||||
"booleanValue": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"mapField": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"nestedString": {
|
||||
"stringValue": "nested value"
|
||||
},
|
||||
"nestedNumber": {
|
||||
"doubleValue": 99.99
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The tool can be configured to require authentication:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
secure-add-docs:
|
||||
kind: firestore-add-documents
|
||||
source: prod-firestore
|
||||
description: Add documents with authentication required
|
||||
authRequired:
|
||||
- google-oauth
|
||||
- api-key
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common errors include:
|
||||
- Invalid collection path
|
||||
- Missing or invalid document data
|
||||
- Permission denied (if Firestore security rules block the operation)
|
||||
- Invalid data type conversions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use typed values**: Every field must be wrapped with its appropriate type indicator (e.g., `{"stringValue": "text"}`)
|
||||
2. **Integer values can be strings**: The tool accepts integer values as strings (e.g., `{"integerValue": "1500"}`)
|
||||
3. **Use returnData sparingly**: Only set to true when you need to verify the exact data that was written
|
||||
4. **Validate data before sending**: Ensure your data matches Firestore's native JSON format
|
||||
5. **Handle timestamps properly**: Use RFC3339 format for timestamp strings
|
||||
6. **Base64 encode binary data**: Binary data must be base64 encoded in the `bytesValue` field
|
||||
7. **Consider security rules**: Ensure your Firestore security rules allow document creation in the target collection
|
||||
|
||||
## Related Tools
|
||||
|
||||
- [`firestore-get-documents`](firestore-get-documents.md) - Retrieve documents by their paths
|
||||
- [`firestore-query-collection`](firestore-query-collection.md) - Query documents in a collection
|
||||
- [`firestore-delete-documents`](firestore-delete-documents.md) - Delete documents from Firestore
|
||||
2
go.mod
2
go.mod
@@ -47,6 +47,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.37.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
google.golang.org/api v0.247.0
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
@@ -149,7 +150,6 @@ require (
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
|
||||
@@ -9,6 +9,18 @@ tools:
|
||||
kind: firestore-get-documents
|
||||
source: firestore-source
|
||||
description: Gets multiple documents from Firestore by their paths
|
||||
firestore-add-documents:
|
||||
kind: firestore-add-documents
|
||||
source: firestore-source
|
||||
description: |
|
||||
Adds a new document to a Firestore collection. Please follow the best practices :
|
||||
1. Always use typed values in the documentData: Every field must be wrapped with its appropriate type indicator (e.g., {"stringValue": "text"})
|
||||
2. Integer values can be strings in the documentData: The tool accepts integer values as strings (e.g., {"integerValue": "1500"})
|
||||
3. Use returnData sparingly: Only set to true when you need to verify the exact data that was written
|
||||
4. Validate data before sending: Ensure your data matches Firestore's native JSON format
|
||||
5. Handle timestamps properly: Use RFC3339 format for timestamp strings
|
||||
6. Base64 encode binary data: Binary data must be base64 encoded in the bytesValue field
|
||||
7. Consider security rules: Ensure your Firestore security rules allow document creation in the target collection
|
||||
firestore-list-collections:
|
||||
kind: firestore-list-collections
|
||||
source: firestore-source
|
||||
@@ -35,6 +47,7 @@ tools:
|
||||
toolsets:
|
||||
firestore-database-tools:
|
||||
- firestore-get-documents
|
||||
- firestore-add-documents
|
||||
- firestore-list-collections
|
||||
- firestore-delete-documents
|
||||
- firestore-query-collection
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package firestoreadddocuments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
firestoreapi "cloud.google.com/go/firestore"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/googleapis/genai-toolbox/internal/sources"
|
||||
firestoreds "github.com/googleapis/genai-toolbox/internal/sources/firestore"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools/firestore/util"
|
||||
)
|
||||
|
||||
const kind string = "firestore-add-documents"
|
||||
const collectionPathKey string = "collectionPath"
|
||||
const documentDataKey string = "documentData"
|
||||
const returnDocumentDataKey string = "returnData"
|
||||
|
||||
func init() {
|
||||
if !tools.Register(kind, newConfig) {
|
||||
panic(fmt.Sprintf("tool kind %q already registered", kind))
|
||||
}
|
||||
}
|
||||
|
||||
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
|
||||
actual := Config{Name: name}
|
||||
if err := decoder.DecodeContext(ctx, &actual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
type compatibleSource interface {
|
||||
FirestoreClient() *firestoreapi.Client
|
||||
}
|
||||
|
||||
// validate compatible sources are still compatible
|
||||
var _ compatibleSource = &firestoreds.Source{}
|
||||
|
||||
var compatibleSources = [...]string{firestoreds.SourceKind}
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name" validate:"required"`
|
||||
Kind string `yaml:"kind" validate:"required"`
|
||||
Source string `yaml:"source" validate:"required"`
|
||||
Description string `yaml:"description" validate:"required"`
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.ToolConfig = Config{}
|
||||
|
||||
func (cfg Config) ToolConfigKind() string {
|
||||
return kind
|
||||
}
|
||||
|
||||
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
|
||||
// verify source exists
|
||||
rawS, ok := srcs[cfg.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
|
||||
}
|
||||
|
||||
// verify the source is compatible
|
||||
s, ok := rawS.(compatibleSource)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources)
|
||||
}
|
||||
|
||||
// Create parameters
|
||||
collectionPathParameter := tools.NewStringParameter(
|
||||
collectionPathKey,
|
||||
"The path of the collection where the document will be added to",
|
||||
)
|
||||
|
||||
documentDataParameter := tools.NewMapParameter(
|
||||
documentDataKey,
|
||||
`The document data in Firestore's native JSON format. Each field must be wrapped with a type indicator:
|
||||
- Strings: {"stringValue": "text"}
|
||||
- Integers: {"integerValue": "123"} or {"integerValue": 123}
|
||||
- Doubles: {"doubleValue": 123.45}
|
||||
- Booleans: {"booleanValue": true}
|
||||
- Timestamps: {"timestampValue": "2025-01-07T10:00:00Z"}
|
||||
- GeoPoints: {"geoPointValue": {"latitude": 34.05, "longitude": -118.24}}
|
||||
- Arrays: {"arrayValue": {"values": [{"stringValue": "item1"}, {"integerValue": "2"}]}}
|
||||
- Maps: {"mapValue": {"fields": {"key1": {"stringValue": "value1"}, "key2": {"booleanValue": true}}}}
|
||||
- Null: {"nullValue": null}
|
||||
- Bytes: {"bytesValue": "base64EncodedString"}
|
||||
- References: {"referenceValue": "collection/document"}`,
|
||||
"", // Empty string for generic map that accepts any value type
|
||||
)
|
||||
|
||||
returnDataParameter := tools.NewBooleanParameterWithDefault(
|
||||
returnDocumentDataKey,
|
||||
false,
|
||||
"If set to true the output will have the data of the created document. This flag if set to false will help avoid overloading the context of the agent.",
|
||||
)
|
||||
|
||||
parameters := tools.Parameters{
|
||||
collectionPathParameter,
|
||||
documentDataParameter,
|
||||
returnDataParameter,
|
||||
}
|
||||
|
||||
mcpManifest := tools.McpManifest{
|
||||
Name: cfg.Name,
|
||||
Description: cfg.Description,
|
||||
InputSchema: parameters.McpManifest(),
|
||||
}
|
||||
|
||||
// finish tool setup
|
||||
t := Tool{
|
||||
Name: cfg.Name,
|
||||
Kind: kind,
|
||||
Parameters: parameters,
|
||||
AuthRequired: cfg.AuthRequired,
|
||||
Client: s.FirestoreClient(),
|
||||
manifest: tools.Manifest{Description: cfg.Description, Parameters: parameters.Manifest(), AuthRequired: cfg.AuthRequired},
|
||||
mcpManifest: mcpManifest,
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.Tool = Tool{}
|
||||
|
||||
type Tool struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
Parameters tools.Parameters `yaml:"parameters"`
|
||||
|
||||
Client *firestoreapi.Client
|
||||
manifest tools.Manifest
|
||||
mcpManifest tools.McpManifest
|
||||
}
|
||||
|
||||
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
|
||||
mapParams := params.AsMap()
|
||||
|
||||
// Get collection path
|
||||
collectionPath, ok := mapParams[collectionPathKey].(string)
|
||||
if !ok || collectionPath == "" {
|
||||
return nil, fmt.Errorf("invalid or missing '%s' parameter", collectionPathKey)
|
||||
}
|
||||
|
||||
// Get document data
|
||||
documentDataRaw, ok := mapParams[documentDataKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid or missing '%s' parameter", documentDataKey)
|
||||
}
|
||||
|
||||
// Convert the document data from JSON format to Firestore format
|
||||
// The client is passed to handle referenceValue types
|
||||
documentData, err := util.JSONToFirestoreValue(documentDataRaw, t.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert document data: %w", err)
|
||||
}
|
||||
|
||||
// Get return document data flag
|
||||
returnData := false
|
||||
if val, ok := mapParams[returnDocumentDataKey].(bool); ok {
|
||||
returnData = val
|
||||
}
|
||||
|
||||
// Get the collection reference
|
||||
collection := t.Client.Collection(collectionPath)
|
||||
|
||||
// Add the document to the collection
|
||||
docRef, writeResult, err := collection.Add(ctx, documentData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add document: %w", err)
|
||||
}
|
||||
|
||||
// Build the response
|
||||
response := map[string]any{
|
||||
"documentPath": docRef.Path,
|
||||
"createTime": writeResult.UpdateTime.Format("2006-01-02T15:04:05.999999999Z"),
|
||||
}
|
||||
|
||||
// Add document data if requested
|
||||
if returnData {
|
||||
// Convert the document data back to simple JSON format
|
||||
simplifiedData := util.FirestoreValueToJSON(documentData)
|
||||
response["documentData"] = simplifiedData
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
|
||||
return tools.ParseParams(t.Parameters, data, claims)
|
||||
}
|
||||
|
||||
func (t Tool) Manifest() tools.Manifest {
|
||||
return t.manifest
|
||||
}
|
||||
|
||||
func (t Tool) McpManifest() tools.McpManifest {
|
||||
return t.mcpManifest
|
||||
}
|
||||
|
||||
func (t Tool) Authorized(verifiedAuthServices []string) bool {
|
||||
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package firestoreadddocuments_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/googleapis/genai-toolbox/internal/server"
|
||||
"github.com/googleapis/genai-toolbox/internal/testutils"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreadddocuments"
|
||||
)
|
||||
|
||||
func TestParseFromYamlFirestoreAddDocuments(t *testing.T) {
|
||||
ctx, err := testutils.ContextWithNewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
tcs := []struct {
|
||||
desc string
|
||||
in string
|
||||
want server.ToolConfigs
|
||||
}{
|
||||
{
|
||||
desc: "basic example",
|
||||
in: `
|
||||
tools:
|
||||
add_docs_tool:
|
||||
kind: firestore-add-documents
|
||||
source: my-firestore-instance
|
||||
description: Add documents to Firestore collections
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"add_docs_tool": firestoreadddocuments.Config{
|
||||
Name: "add_docs_tool",
|
||||
Kind: "firestore-add-documents",
|
||||
Source: "my-firestore-instance",
|
||||
Description: "Add documents to Firestore collections",
|
||||
AuthRequired: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "with auth requirements",
|
||||
in: `
|
||||
tools:
|
||||
secure_add_docs:
|
||||
kind: firestore-add-documents
|
||||
source: prod-firestore
|
||||
description: Add documents with authentication
|
||||
authRequired:
|
||||
- google-auth-service
|
||||
- api-key-service
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"secure_add_docs": firestoreadddocuments.Config{
|
||||
Name: "secure_add_docs",
|
||||
Kind: "firestore-add-documents",
|
||||
Source: "prod-firestore",
|
||||
Description: "Add documents with authentication",
|
||||
AuthRequired: []string{"google-auth-service", "api-key-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to unmarshal: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
|
||||
t.Fatalf("incorrect parse: diff %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFromYamlMultipleTools(t *testing.T) {
|
||||
ctx, err := testutils.ContextWithNewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
in := `
|
||||
tools:
|
||||
add_user_docs:
|
||||
kind: firestore-add-documents
|
||||
source: users-firestore
|
||||
description: Add user documents
|
||||
authRequired:
|
||||
- user-auth
|
||||
add_product_docs:
|
||||
kind: firestore-add-documents
|
||||
source: products-firestore
|
||||
description: Add product documents
|
||||
add_order_docs:
|
||||
kind: firestore-add-documents
|
||||
source: orders-firestore
|
||||
description: Add order documents
|
||||
authRequired:
|
||||
- user-auth
|
||||
- admin-auth
|
||||
`
|
||||
want := server.ToolConfigs{
|
||||
"add_user_docs": firestoreadddocuments.Config{
|
||||
Name: "add_user_docs",
|
||||
Kind: "firestore-add-documents",
|
||||
Source: "users-firestore",
|
||||
Description: "Add user documents",
|
||||
AuthRequired: []string{"user-auth"},
|
||||
},
|
||||
"add_product_docs": firestoreadddocuments.Config{
|
||||
Name: "add_product_docs",
|
||||
Kind: "firestore-add-documents",
|
||||
Source: "products-firestore",
|
||||
Description: "Add product documents",
|
||||
AuthRequired: []string{},
|
||||
},
|
||||
"add_order_docs": firestoreadddocuments.Config{
|
||||
Name: "add_order_docs",
|
||||
Kind: "firestore-add-documents",
|
||||
Source: "orders-firestore",
|
||||
Description: "Add order documents",
|
||||
AuthRequired: []string{"user-auth", "admin-auth"},
|
||||
},
|
||||
}
|
||||
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err = yaml.UnmarshalContext(ctx, testutils.FormatYaml(in), &got)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to unmarshal: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(want, got.Tools); diff != "" {
|
||||
t.Fatalf("incorrect parse: diff %v", diff)
|
||||
}
|
||||
}
|
||||
233
internal/tools/firestore/util/converter.go
Normal file
233
internal/tools/firestore/util/converter.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/firestore"
|
||||
"google.golang.org/genproto/googleapis/type/latlng"
|
||||
)
|
||||
|
||||
// JSONToFirestoreValue converts a JSON value with type information to a Firestore-compatible value
|
||||
// The input should be a map with a single key indicating the type (e.g., "stringValue", "integerValue")
|
||||
// If a client is provided, referenceValue types will be converted to *firestore.DocumentRef
|
||||
func JSONToFirestoreValue(value interface{}, client *firestore.Client) (interface{}, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
// Check for typed values
|
||||
if len(v) == 1 {
|
||||
for key, val := range v {
|
||||
switch key {
|
||||
case "nullValue":
|
||||
return nil, nil
|
||||
case "booleanValue":
|
||||
return val, nil
|
||||
case "stringValue":
|
||||
return val, nil
|
||||
case "integerValue":
|
||||
// Convert to int64
|
||||
switch num := val.(type) {
|
||||
case float64:
|
||||
return int64(num), nil
|
||||
case int:
|
||||
return int64(num), nil
|
||||
case int64:
|
||||
return num, nil
|
||||
case string:
|
||||
// Parse string representation using strconv for better performance
|
||||
i, err := strconv.ParseInt(strings.TrimSpace(num), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer value: %v", val)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid integer value: %v", val)
|
||||
case "doubleValue":
|
||||
// Convert to float64
|
||||
switch num := val.(type) {
|
||||
case float64:
|
||||
return num, nil
|
||||
case int:
|
||||
return float64(num), nil
|
||||
case int64:
|
||||
return float64(num), nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid double value: %v", val)
|
||||
case "bytesValue":
|
||||
// Decode base64 string to bytes
|
||||
if str, ok := val.(string); ok {
|
||||
return base64.StdEncoding.DecodeString(str)
|
||||
}
|
||||
return nil, fmt.Errorf("bytes value must be a base64 encoded string")
|
||||
case "timestampValue":
|
||||
// Parse timestamp
|
||||
if str, ok := val.(string); ok {
|
||||
t, err := time.Parse(time.RFC3339Nano, str)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid timestamp format: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
return nil, fmt.Errorf("timestamp value must be a string")
|
||||
case "geoPointValue":
|
||||
// Convert to LatLng
|
||||
if geoMap, ok := val.(map[string]interface{}); ok {
|
||||
lat, latOk := geoMap["latitude"].(float64)
|
||||
lng, lngOk := geoMap["longitude"].(float64)
|
||||
if latOk && lngOk {
|
||||
return &latlng.LatLng{
|
||||
Latitude: lat,
|
||||
Longitude: lng,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("invalid geopoint value format")
|
||||
case "arrayValue":
|
||||
// Convert array
|
||||
if arrayMap, ok := val.(map[string]interface{}); ok {
|
||||
if values, ok := arrayMap["values"].([]interface{}); ok {
|
||||
result := make([]interface{}, len(values))
|
||||
for i, item := range values {
|
||||
converted, err := JSONToFirestoreValue(item, client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("array item %d: %w", i, err)
|
||||
}
|
||||
result[i] = converted
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("invalid array value format")
|
||||
case "mapValue":
|
||||
// Convert map
|
||||
if mapMap, ok := val.(map[string]interface{}); ok {
|
||||
if fields, ok := mapMap["fields"].(map[string]interface{}); ok {
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range fields {
|
||||
converted, err := JSONToFirestoreValue(v, client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map field %q: %w", k, err)
|
||||
}
|
||||
result[k] = converted
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("invalid map value format")
|
||||
case "referenceValue":
|
||||
// Convert to DocumentRef if client is provided
|
||||
if strVal, ok := val.(string); ok {
|
||||
if client != nil && isValidDocumentPath(strVal) {
|
||||
return client.Doc(strVal), nil
|
||||
}
|
||||
// Return the path as string if no client or invalid path
|
||||
return strVal, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reference value must be a string")
|
||||
default:
|
||||
// If not a typed value, treat as regular map
|
||||
return convertPlainMap(v, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regular map without type annotation
|
||||
return convertPlainMap(v, client)
|
||||
default:
|
||||
// Plain values (for backward compatibility)
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// convertPlainMap converts a plain map to Firestore format
|
||||
func convertPlainMap(m map[string]interface{}, client *firestore.Client) (map[string]interface{}, error) {
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range m {
|
||||
converted, err := JSONToFirestoreValue(v, client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", k, err)
|
||||
}
|
||||
result[k] = converted
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FirestoreValueToJSON converts a Firestore value to a simplified JSON representation
|
||||
// This removes type information and returns plain values
|
||||
func FirestoreValueToJSON(value interface{}) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
return v.Format(time.RFC3339Nano)
|
||||
case *latlng.LatLng:
|
||||
return map[string]interface{}{
|
||||
"latitude": v.Latitude,
|
||||
"longitude": v.Longitude,
|
||||
}
|
||||
case []byte:
|
||||
return base64.StdEncoding.EncodeToString(v)
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
for i, item := range v {
|
||||
result[i] = FirestoreValueToJSON(item)
|
||||
}
|
||||
return result
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{})
|
||||
for k, val := range v {
|
||||
result[k] = FirestoreValueToJSON(val)
|
||||
}
|
||||
return result
|
||||
case *firestore.DocumentRef:
|
||||
return v.Path
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// isValidDocumentPath checks if a string is a valid Firestore document path
|
||||
// Valid paths have an even number of segments (collection/doc/collection/doc...)
|
||||
func isValidDocumentPath(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Split the path by '/' and check if it has an even number of segments
|
||||
segments := splitPath(path)
|
||||
return len(segments) > 0 && len(segments)%2 == 0
|
||||
}
|
||||
|
||||
// splitPath splits a path by '/' while handling empty segments correctly
|
||||
func splitPath(path string) []string {
|
||||
rawSegments := strings.Split(path, "/")
|
||||
var segments []string
|
||||
for _, s := range rawSegments {
|
||||
if s != "" {
|
||||
segments = append(segments, s)
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
423
internal/tools/firestore/util/converter_test.go
Normal file
423
internal/tools/firestore/util/converter_test.go
Normal file
@@ -0,0 +1,423 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/genproto/googleapis/type/latlng"
|
||||
)
|
||||
|
||||
func TestJSONToFirestoreValue_ComplexDocument(t *testing.T) {
|
||||
// This is the exact JSON format provided by the user
|
||||
jsonData := `{
|
||||
"name": {
|
||||
"stringValue": "Acme Corporation"
|
||||
},
|
||||
"establishmentDate": {
|
||||
"timestampValue": "2000-01-15T10:30:00Z"
|
||||
},
|
||||
"location": {
|
||||
"geoPointValue": {
|
||||
"latitude": 34.052235,
|
||||
"longitude": -118.243683
|
||||
}
|
||||
},
|
||||
"active": {
|
||||
"booleanValue": true
|
||||
},
|
||||
"employeeCount": {
|
||||
"integerValue": "1500"
|
||||
},
|
||||
"annualRevenue": {
|
||||
"doubleValue": 1234567.89
|
||||
},
|
||||
"website": {
|
||||
"stringValue": "https://www.acmecorp.com"
|
||||
},
|
||||
"contactInfo": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"email": {
|
||||
"stringValue": "info@acmecorp.com"
|
||||
},
|
||||
"phone": {
|
||||
"stringValue": "+1-555-123-4567"
|
||||
},
|
||||
"address": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"street": {
|
||||
"stringValue": "123 Business Blvd"
|
||||
},
|
||||
"city": {
|
||||
"stringValue": "Los Angeles"
|
||||
},
|
||||
"state": {
|
||||
"stringValue": "CA"
|
||||
},
|
||||
"zipCode": {
|
||||
"stringValue": "90012"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"stringValue": "Product A"
|
||||
},
|
||||
{
|
||||
"stringValue": "Product B"
|
||||
},
|
||||
{
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"productName": {
|
||||
"stringValue": "Product C Deluxe"
|
||||
},
|
||||
"version": {
|
||||
"integerValue": "2"
|
||||
},
|
||||
"features": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"stringValue": "Feature X"
|
||||
},
|
||||
{
|
||||
"stringValue": "Feature Y"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"nullValue": null
|
||||
},
|
||||
"lastUpdated": {
|
||||
"timestampValue": "2025-07-30T11:47:59.000Z"
|
||||
},
|
||||
"binaryData": {
|
||||
"bytesValue": "SGVsbG8gV29ybGQh"
|
||||
}
|
||||
}`
|
||||
|
||||
// Parse JSON
|
||||
var data interface{}
|
||||
err := json.Unmarshal([]byte(jsonData), &data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
// Convert to Firestore format
|
||||
result, err := JSONToFirestoreValue(data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to convert JSON to Firestore value: %v", err)
|
||||
}
|
||||
|
||||
// Verify the result is a map
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Result should be a map, got %T", result)
|
||||
}
|
||||
|
||||
// Verify string values
|
||||
if resultMap["name"] != "Acme Corporation" {
|
||||
t.Errorf("Expected name 'Acme Corporation', got %v", resultMap["name"])
|
||||
}
|
||||
if resultMap["website"] != "https://www.acmecorp.com" {
|
||||
t.Errorf("Expected website 'https://www.acmecorp.com', got %v", resultMap["website"])
|
||||
}
|
||||
|
||||
// Verify timestamp
|
||||
establishmentDate, ok := resultMap["establishmentDate"].(time.Time)
|
||||
if !ok {
|
||||
t.Fatalf("establishmentDate should be time.Time, got %T", resultMap["establishmentDate"])
|
||||
}
|
||||
expectedDate, _ := time.Parse(time.RFC3339, "2000-01-15T10:30:00Z")
|
||||
if !establishmentDate.Equal(expectedDate) {
|
||||
t.Errorf("Expected date %v, got %v", expectedDate, establishmentDate)
|
||||
}
|
||||
|
||||
// Verify geopoint
|
||||
location, ok := resultMap["location"].(*latlng.LatLng)
|
||||
if !ok {
|
||||
t.Fatalf("location should be *latlng.LatLng, got %T", resultMap["location"])
|
||||
}
|
||||
if location.Latitude != 34.052235 {
|
||||
t.Errorf("Expected latitude 34.052235, got %v", location.Latitude)
|
||||
}
|
||||
if location.Longitude != -118.243683 {
|
||||
t.Errorf("Expected longitude -118.243683, got %v", location.Longitude)
|
||||
}
|
||||
|
||||
// Verify boolean
|
||||
if resultMap["active"] != true {
|
||||
t.Errorf("Expected active true, got %v", resultMap["active"])
|
||||
}
|
||||
|
||||
// Verify integer (should be int64)
|
||||
employeeCount, ok := resultMap["employeeCount"].(int64)
|
||||
if !ok {
|
||||
t.Fatalf("employeeCount should be int64, got %T", resultMap["employeeCount"])
|
||||
}
|
||||
if employeeCount != int64(1500) {
|
||||
t.Errorf("Expected employeeCount 1500, got %v", employeeCount)
|
||||
}
|
||||
|
||||
// Verify double
|
||||
annualRevenue, ok := resultMap["annualRevenue"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("annualRevenue should be float64, got %T", resultMap["annualRevenue"])
|
||||
}
|
||||
if annualRevenue != 1234567.89 {
|
||||
t.Errorf("Expected annualRevenue 1234567.89, got %v", annualRevenue)
|
||||
}
|
||||
|
||||
// Verify nested map
|
||||
contactInfo, ok := resultMap["contactInfo"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("contactInfo should be a map, got %T", resultMap["contactInfo"])
|
||||
}
|
||||
if contactInfo["email"] != "info@acmecorp.com" {
|
||||
t.Errorf("Expected email 'info@acmecorp.com', got %v", contactInfo["email"])
|
||||
}
|
||||
if contactInfo["phone"] != "+1-555-123-4567" {
|
||||
t.Errorf("Expected phone '+1-555-123-4567', got %v", contactInfo["phone"])
|
||||
}
|
||||
|
||||
// Verify nested nested map
|
||||
address, ok := contactInfo["address"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("address should be a map, got %T", contactInfo["address"])
|
||||
}
|
||||
if address["street"] != "123 Business Blvd" {
|
||||
t.Errorf("Expected street '123 Business Blvd', got %v", address["street"])
|
||||
}
|
||||
if address["city"] != "Los Angeles" {
|
||||
t.Errorf("Expected city 'Los Angeles', got %v", address["city"])
|
||||
}
|
||||
if address["state"] != "CA" {
|
||||
t.Errorf("Expected state 'CA', got %v", address["state"])
|
||||
}
|
||||
if address["zipCode"] != "90012" {
|
||||
t.Errorf("Expected zipCode '90012', got %v", address["zipCode"])
|
||||
}
|
||||
|
||||
// Verify array
|
||||
products, ok := resultMap["products"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("products should be an array, got %T", resultMap["products"])
|
||||
}
|
||||
if len(products) != 3 {
|
||||
t.Errorf("Expected 3 products, got %d", len(products))
|
||||
}
|
||||
if products[0] != "Product A" {
|
||||
t.Errorf("Expected products[0] 'Product A', got %v", products[0])
|
||||
}
|
||||
if products[1] != "Product B" {
|
||||
t.Errorf("Expected products[1] 'Product B', got %v", products[1])
|
||||
}
|
||||
|
||||
// Verify complex item in array
|
||||
product3, ok := products[2].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("products[2] should be a map, got %T", products[2])
|
||||
}
|
||||
if product3["productName"] != "Product C Deluxe" {
|
||||
t.Errorf("Expected productName 'Product C Deluxe', got %v", product3["productName"])
|
||||
}
|
||||
version, ok := product3["version"].(int64)
|
||||
if !ok {
|
||||
t.Fatalf("version should be int64, got %T", product3["version"])
|
||||
}
|
||||
if version != int64(2) {
|
||||
t.Errorf("Expected version 2, got %v", version)
|
||||
}
|
||||
|
||||
features, ok := product3["features"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("features should be an array, got %T", product3["features"])
|
||||
}
|
||||
if len(features) != 2 {
|
||||
t.Errorf("Expected 2 features, got %d", len(features))
|
||||
}
|
||||
if features[0] != "Feature X" {
|
||||
t.Errorf("Expected features[0] 'Feature X', got %v", features[0])
|
||||
}
|
||||
if features[1] != "Feature Y" {
|
||||
t.Errorf("Expected features[1] 'Feature Y', got %v", features[1])
|
||||
}
|
||||
|
||||
// Verify null value
|
||||
if resultMap["notes"] != nil {
|
||||
t.Errorf("Expected notes to be nil, got %v", resultMap["notes"])
|
||||
}
|
||||
|
||||
// Verify bytes
|
||||
binaryData, ok := resultMap["binaryData"].([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("binaryData should be []byte, got %T", resultMap["binaryData"])
|
||||
}
|
||||
expectedBytes, _ := base64.StdEncoding.DecodeString("SGVsbG8gV29ybGQh")
|
||||
if !bytes.Equal(binaryData, expectedBytes) {
|
||||
t.Errorf("Expected bytes %v, got %v", expectedBytes, binaryData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONToFirestoreValue_IntegerFromString(t *testing.T) {
|
||||
// Test that integerValue as string gets converted to int64
|
||||
data := map[string]interface{}{
|
||||
"integerValue": "1500",
|
||||
}
|
||||
|
||||
result, err := JSONToFirestoreValue(data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to convert: %v", err)
|
||||
}
|
||||
|
||||
intVal, ok := result.(int64)
|
||||
if !ok {
|
||||
t.Fatalf("Result should be int64, got %T", result)
|
||||
}
|
||||
if intVal != int64(1500) {
|
||||
t.Errorf("Expected 1500, got %v", intVal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirestoreValueToJSON_RoundTrip(t *testing.T) {
|
||||
// Test round-trip conversion
|
||||
original := map[string]interface{}{
|
||||
"name": "Test",
|
||||
"count": int64(42),
|
||||
"price": 19.99,
|
||||
"active": true,
|
||||
"tags": []interface{}{"tag1", "tag2"},
|
||||
"metadata": map[string]interface{}{
|
||||
"created": time.Now(),
|
||||
},
|
||||
"nullField": nil,
|
||||
}
|
||||
|
||||
// Convert to JSON representation
|
||||
jsonRepresentation := FirestoreValueToJSON(original)
|
||||
|
||||
// Verify types are simplified
|
||||
jsonMap, ok := jsonRepresentation.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map, got %T", jsonRepresentation)
|
||||
}
|
||||
|
||||
// Time should be converted to string
|
||||
metadata, ok := jsonMap["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("metadata should be a map, got %T", jsonMap["metadata"])
|
||||
}
|
||||
_, ok = metadata["created"].(string)
|
||||
if !ok {
|
||||
t.Errorf("created should be a string, got %T", metadata["created"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONToFirestoreValue_InvalidFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "invalid integer value",
|
||||
input: map[string]interface{}{
|
||||
"integerValue": "not-a-number",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid integer value",
|
||||
},
|
||||
{
|
||||
name: "invalid timestamp",
|
||||
input: map[string]interface{}{
|
||||
"timestampValue": "not-a-timestamp",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid timestamp format",
|
||||
},
|
||||
{
|
||||
name: "invalid geopoint - missing latitude",
|
||||
input: map[string]interface{}{
|
||||
"geoPointValue": map[string]interface{}{
|
||||
"longitude": -118.243683,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid geopoint value format",
|
||||
},
|
||||
{
|
||||
name: "invalid array format",
|
||||
input: map[string]interface{}{
|
||||
"arrayValue": "not-an-array",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid array value format",
|
||||
},
|
||||
{
|
||||
name: "invalid map format",
|
||||
input: map[string]interface{}{
|
||||
"mapValue": "not-a-map",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid map value format",
|
||||
},
|
||||
{
|
||||
name: "invalid bytes - not base64",
|
||||
input: map[string]interface{}{
|
||||
"bytesValue": "!!!not-base64!!!",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := JSONToFirestoreValue(tt.input, nil)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("Expected error containing '%s', got '%v'", tt.errMsg, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -129,9 +130,10 @@ func TestFirestoreToolEndpoints(t *testing.T) {
|
||||
|
||||
// Run specific Firestore tool tests
|
||||
runFirestoreGetDocumentsTest(t, docPath1, docPath2)
|
||||
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
|
||||
runFirestoreDeleteDocumentsTest(t, docPath3)
|
||||
runFirestoreQueryCollectionTest(t, testCollectionName)
|
||||
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
|
||||
runFirestoreAddDocumentsTest(t, testCollectionName)
|
||||
runFirestoreDeleteDocumentsTest(t, docPath3)
|
||||
runFirestoreGetRulesTest(t)
|
||||
runFirestoreValidateRulesTest(t)
|
||||
}
|
||||
@@ -569,6 +571,11 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
|
||||
"source": "my-instance",
|
||||
"description": "Validate Firestore security rules",
|
||||
},
|
||||
"firestore-add-docs": map[string]any{
|
||||
"kind": "firestore-add-documents",
|
||||
"source": "my-instance",
|
||||
"description": "Add documents to Firestore",
|
||||
},
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
@@ -577,6 +584,210 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func runFirestoreAddDocumentsTest(t *testing.T, collectionName string) {
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
api string
|
||||
requestBody io.Reader
|
||||
wantKeys []string
|
||||
validateDocData bool
|
||||
expectedDocData map[string]interface{}
|
||||
isErr bool
|
||||
}{
|
||||
{
|
||||
name: "add document with simple types",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collectionPath": "%s",
|
||||
"documentData": {
|
||||
"name": {"stringValue": "Test User"},
|
||||
"age": {"integerValue": "42"},
|
||||
"score": {"doubleValue": 99.5},
|
||||
"active": {"booleanValue": true},
|
||||
"notes": {"nullValue": null}
|
||||
}
|
||||
}`, collectionName))),
|
||||
wantKeys: []string{"documentPath", "createTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "add document with complex types",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collectionPath": "%s",
|
||||
"documentData": {
|
||||
"location": {
|
||||
"geoPointValue": {
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194
|
||||
}
|
||||
},
|
||||
"timestamp": {
|
||||
"timestampValue": "2025-01-07T10:00:00Z"
|
||||
},
|
||||
"tags": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{"stringValue": "tag1"},
|
||||
{"stringValue": "tag2"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"version": {"integerValue": "1"},
|
||||
"type": {"stringValue": "test"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, collectionName))),
|
||||
wantKeys: []string{"documentPath", "createTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "add document with returnData",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collectionPath": "%s",
|
||||
"documentData": {
|
||||
"name": {"stringValue": "Return Test"},
|
||||
"value": {"integerValue": "123"}
|
||||
},
|
||||
"returnData": true
|
||||
}`, collectionName))),
|
||||
wantKeys: []string{"documentPath", "createTime", "documentData"},
|
||||
validateDocData: true,
|
||||
expectedDocData: map[string]interface{}{
|
||||
"name": "Return Test",
|
||||
"value": float64(123), // JSON numbers are decoded as float64
|
||||
},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "add document with nested maps and arrays",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collectionPath": "%s",
|
||||
"documentData": {
|
||||
"company": {
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"name": {"stringValue": "Tech Corp"},
|
||||
"employees": {
|
||||
"arrayValue": {
|
||||
"values": [
|
||||
{
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"name": {"stringValue": "John"},
|
||||
"role": {"stringValue": "Developer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"mapValue": {
|
||||
"fields": {
|
||||
"name": {"stringValue": "Jane"},
|
||||
"role": {"stringValue": "Manager"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, collectionName))),
|
||||
wantKeys: []string{"documentPath", "createTime"},
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing collectionPath parameter",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(`{"documentData": {"test": {"stringValue": "value"}}}`)),
|
||||
isErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing documentData parameter",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"collectionPath": "%s"}`, collectionName))),
|
||||
isErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid documentData format",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-add-docs/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"collectionPath": "%s", "documentData": "not an object"}`, collectionName))),
|
||||
isErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range invokeTcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create request: %s", err)
|
||||
}
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to send request: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if tc.isErr {
|
||||
return
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing response body: %v", err)
|
||||
}
|
||||
|
||||
got, ok := body["result"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("unable to find result in response body")
|
||||
}
|
||||
|
||||
// Parse the result string as JSON
|
||||
var resultJSON map[string]interface{}
|
||||
err = json.Unmarshal([]byte(got), &resultJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing result as JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check if all wanted keys exist
|
||||
for _, key := range tc.wantKeys {
|
||||
if _, exists := resultJSON[key]; !exists {
|
||||
t.Fatalf("expected key %q not found in result: %s", key, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate document data if required
|
||||
if tc.validateDocData {
|
||||
docData, ok := resultJSON["documentData"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("documentData is not a map: %v", resultJSON["documentData"])
|
||||
}
|
||||
|
||||
// Use reflect.DeepEqual to compare the document data
|
||||
if !reflect.DeepEqual(docData, tc.expectedDocData) {
|
||||
t.Fatalf("documentData mismatch:\nexpected: %v\nactual: %v", tc.expectedDocData, docData)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupFirestoreTestData(t *testing.T, ctx context.Context, client *firestoreapi.Client,
|
||||
collectionName, subCollectionName, docID1, docID2, docID3 string) func(*testing.T) {
|
||||
// Create test documents
|
||||
@@ -619,30 +830,56 @@ func setupFirestoreTestData(t *testing.T, ctx context.Context, client *firestore
|
||||
t.Fatalf("Failed to create subcollection document: %v", err)
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
// Return cleanup function that deletes ALL collections and documents in the database
|
||||
return func(t *testing.T) {
|
||||
// Delete subcollection documents first
|
||||
subDocs := client.Collection(collectionName).Doc(docID1).Collection(subCollectionName).Documents(ctx)
|
||||
for {
|
||||
doc, err := subDocs.Next()
|
||||
// Helper function to recursively delete all documents in a collection
|
||||
var deleteCollection func(*firestoreapi.CollectionRef) error
|
||||
deleteCollection = func(collection *firestoreapi.CollectionRef) error {
|
||||
// Get all documents in the collection
|
||||
docs, err := collection.Documents(ctx).GetAll()
|
||||
if err != nil {
|
||||
break
|
||||
return fmt.Errorf("failed to list documents in collection %s: %w", collection.Path, err)
|
||||
}
|
||||
if _, err := doc.Ref.Delete(ctx); err != nil {
|
||||
t.Errorf("Failed to delete subcollection document: %v", err)
|
||||
|
||||
// Delete each document and its subcollections
|
||||
for _, doc := range docs {
|
||||
// First, get all subcollections of this document
|
||||
subcollections, err := doc.Ref.Collections(ctx).GetAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list subcollections of document %s: %w", doc.Ref.Path, err)
|
||||
}
|
||||
|
||||
// Recursively delete each subcollection
|
||||
for _, subcoll := range subcollections {
|
||||
if err := deleteCollection(subcoll); err != nil {
|
||||
return fmt.Errorf("failed to delete subcollection %s: %w", subcoll.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the document itself
|
||||
if _, err := doc.Ref.Delete(ctx); err != nil {
|
||||
return fmt.Errorf("failed to delete document %s: %w", doc.Ref.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all root collections in the database
|
||||
rootCollections, err := client.Collections(ctx).GetAll()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to list root collections: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete each root collection and all its contents
|
||||
for _, collection := range rootCollections {
|
||||
if err := deleteCollection(collection); err != nil {
|
||||
t.Errorf("Failed to delete collection %s and its contents: %v", collection.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete main collection documents
|
||||
if _, err := client.Collection(collectionName).Doc(docID1).Delete(ctx); err != nil {
|
||||
t.Errorf("Failed to delete test document 1: %v", err)
|
||||
}
|
||||
if _, err := client.Collection(collectionName).Doc(docID2).Delete(ctx); err != nil {
|
||||
t.Errorf("Failed to delete test document 2: %v", err)
|
||||
}
|
||||
if _, err := client.Collection(collectionName).Doc(docID3).Delete(ctx); err != nil {
|
||||
t.Errorf("Failed to delete test document 3: %v", err)
|
||||
}
|
||||
t.Logf("Successfully deleted all collections and documents in the database")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,7 +1148,7 @@ func runFirestoreQueryCollectionTest(t *testing.T, collectionName string) {
|
||||
"orderBy": "{\"field\": \"age\", \"direction\": \"DESCENDING\"}",
|
||||
"limit": 2
|
||||
}`, collectionName))),
|
||||
wantRegex: `"age":30.*"age":25`, // Should be ordered by age descending (Charlie=35, Alice=30, Bob=25)
|
||||
wantRegex: `"age":35.*"age":30`, // Should be ordered by age descending (Charlie=35, Alice=30, Bob=25)
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user