diff --git a/v2/arangodb/client.go b/v2/arangodb/client.go index 30d00246..ad52721e 100644 --- a/v2/arangodb/client.go +++ b/v2/arangodb/client.go @@ -36,4 +36,5 @@ type Client interface { ClientAdmin ClientAsyncJob ClientFoxx + ClientTasks } diff --git a/v2/arangodb/client_impl.go b/v2/arangodb/client_impl.go index 6f474d95..cbb3dad4 100644 --- a/v2/arangodb/client_impl.go +++ b/v2/arangodb/client_impl.go @@ -39,6 +39,7 @@ func newClient(connection connection.Connection) *client { c.clientAdmin = newClientAdmin(c) c.clientAsyncJob = newClientAsyncJob(c) c.clientFoxx = newClientFoxx(c) + c.clientTask = newClientTask(c) c.Requests = NewRequests(connection) @@ -56,6 +57,7 @@ type client struct { *clientAdmin *clientAsyncJob *clientFoxx + *clientTask Requests } diff --git a/v2/arangodb/tasks.go b/v2/arangodb/tasks.go new file mode 100644 index 00000000..c70b1e34 --- /dev/null +++ b/v2/arangodb/tasks.go @@ -0,0 +1,84 @@ +// DISCLAIMER +// +// Copyright 2024 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany + +package arangodb + +import ( + "context" +) + +// ClientTasks defines the interface for managing tasks in ArangoDB. +type ClientTasks interface { + // Task retrieves an existing task by its ID. + // If no task with the given ID exists, a NotFoundError is returned. + Task(ctx context.Context, databaseName string, id string) (Task, error) + + // Tasks returns a list of all tasks on the server. + Tasks(ctx context.Context, databaseName string) ([]Task, error) + + // CreateTask creates a new task with the specified options. + CreateTask(ctx context.Context, databaseName string, options TaskOptions) (Task, error) + + // If a task with the given ID already exists, a Conflict error is returned. + CreateTaskWithID(ctx context.Context, databaseName string, id string, options TaskOptions) (Task, error) + + // RemoveTask deletes an existing task by its ID. + RemoveTask(ctx context.Context, databaseName string, id string) error +} + +// TaskOptions contains options for creating a new task. +type TaskOptions struct { + // ID is an optional identifier for the task. + ID *string `json:"id,omitempty"` + // Name is an optional name for the task. + Name *string `json:"name,omitempty"` + + // Command is the JavaScript code to be executed. + Command *string `json:"command"` + + // Params are optional parameters passed to the command. + Params interface{} `json:"params,omitempty"` + + // Period is the interval (in seconds) at which the task runs periodically. + // If zero, the task runs once after the offset. + Period *int64 `json:"period,omitempty"` + + // Offset is the delay (in milliseconds) before the task is first executed. + Offset *float64 `json:"offset,omitempty"` +} + +// Task provides access to a single task on the server. +type Task interface { + // ID returns the ID of the task. + ID() *string + + // Name returns the name of the task. + Name() *string + + // Command returns the JavaScript code of the task. + Command() *string + + // Params returns the parameters of the task. + Params(result interface{}) error + + // Period returns the period (in seconds) of the task. + Period() *int64 + + // Offset returns the offset (in milliseconds) of the task. + Offset() *float64 +} diff --git a/v2/arangodb/tasks_impl.go b/v2/arangodb/tasks_impl.go new file mode 100644 index 00000000..86c8bd9b --- /dev/null +++ b/v2/arangodb/tasks_impl.go @@ -0,0 +1,272 @@ +// DISCLAIMER +// +// Copyright 2024 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package arangodb + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/pkg/errors" + + "github.com/arangodb/go-driver/v2/arangodb/shared" + "github.com/arangodb/go-driver/v2/connection" +) + +type clientTask struct { + client *client +} + +// newClientTask initializes a new task client with the given database name. +func newClientTask(client *client) *clientTask { + return &clientTask{ + client: client, + } +} + +// will check all methods in ClientTasks are implemented with the clientTask struct. +var _ ClientTasks = &clientTask{} + +type taskResponse struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Command string `json:"command,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Period int64 `json:"period,omitempty"` + Offset float64 `json:"offset,omitempty"` +} + +func newTask(client *client, resp *taskResponse) Task { + return &task{ + client: client, + id: resp.ID, + name: resp.Name, + command: resp.Command, + params: resp.Params, + period: resp.Period, + offset: resp.Offset, + } +} + +type task struct { + client *client + id string + name string + command string + params json.RawMessage + period int64 + offset float64 +} + +// Task interface implementation for the task struct. +func (t *task) ID() *string { + return &t.id +} + +func (t *task) Name() *string { + return &t.name +} + +func (t *task) Command() *string { + return &t.command +} + +func (t *task) Params(result interface{}) error { + if t.params == nil { + return nil + } + return json.Unmarshal(t.params, result) +} + +func (t *task) Period() *int64 { + return &t.period +} + +func (t *task) Offset() *float64 { + return &t.offset +} + +// Tasks retrieves all tasks from the specified database. +// Retuns a slice of Task objects representing the tasks in the database. +func (c *clientTask) Tasks(ctx context.Context, databaseName string) ([]Task, error) { + urlEndpoint := connection.NewUrl("_db", url.PathEscape(databaseName), "_api", "tasks") + response := make([]taskResponse, 0) // Direct array response + resp, err := connection.CallGet(ctx, c.client.connection, urlEndpoint, &response) + if err != nil { + return nil, errors.WithStack(err) + } + switch code := resp.Code(); code { + case http.StatusOK: + // Convert the response to Task objects + result := make([]Task, len(response)) + for i, task := range response { + result[i] = newTask(c.client, &task) + } + return result, nil + default: + // Attempt to get error details from response headers or body + return nil, shared.NewResponseStruct().AsArangoErrorWithCode(code) + } +} + +// Task retrieves a specific task by its ID from the specified database. +// If the task does not exist, it returns an error. +// If the task exists, it returns a Task object. +func (c *clientTask) Task(ctx context.Context, databaseName string, id string) (Task, error) { + urlEndpoint := connection.NewUrl("_db", url.PathEscape(databaseName), "_api", "tasks", url.PathEscape(id)) + response := struct { + taskResponse `json:",inline"` + shared.ResponseStruct `json:",inline"` + }{} + + resp, err := connection.CallGet(ctx, c.client.connection, urlEndpoint, &response) + if err != nil { + return nil, errors.WithStack(err) + } + switch code := resp.Code(); code { + case http.StatusOK: + return newTask(c.client, &response.taskResponse), nil + default: + return nil, response.AsArangoError() + } +} + +// validateTaskOptions checks if required fields in TaskOptions are set. +func validateTaskOptions(options *TaskOptions) error { + if options == nil { + return errors.New("TaskOptions must not be nil") + } + if options.Command == nil { + return errors.New("TaskOptions.Command must not be empty") + } + return nil +} + +// CreateTask creates a new task with the specified options in the given database. +// If the task already exists (based on ID), it will update the existing task. +// If the task does not exist, it will create a new task. +// The options parameter contains the task configuration such as name, command, parameters, period, and offset. +// The ID field in options is optional; if provided, it will be used as the task identifier. +func (c *clientTask) CreateTask(ctx context.Context, databaseName string, options TaskOptions) (Task, error) { + if err := validateTaskOptions(&options); err != nil { + return nil, errors.WithStack(err) + } + var urlEndpoint string + if options.ID != nil { + urlEndpoint = connection.NewUrl("_db", url.PathEscape(databaseName), "_api", "tasks", url.PathEscape(*options.ID)) + } else { + urlEndpoint = connection.NewUrl("_db", url.PathEscape(databaseName), "_api", "tasks") + } + // Prepare the request body + createRequest := struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Command string `json:"command,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Period int64 `json:"period,omitempty"` + Offset float64 `json:"offset,omitempty"` + }{} + + if options.ID != nil { + createRequest.ID = *options.ID + } + if options.Name != nil { + createRequest.Name = *options.Name + } + if options.Command != nil { + createRequest.Command = *options.Command + } + if options.Period != nil { + createRequest.Period = *options.Period + } + if options.Offset != nil { + createRequest.Offset = *options.Offset + } + + if options.Params != nil { + // Marshal Params into JSON + // This allows for complex parameters to be passed as JSON objects + // and ensures that the Params field is correctly formatted. + raw, err := json.Marshal(options.Params) + if err != nil { + return nil, errors.WithStack(err) + } + createRequest.Params = raw + } + + response := struct { + shared.ResponseStruct `json:",inline"` + taskResponse `json:",inline"` + }{} + + resp, err := connection.CallPost(ctx, c.client.connection, urlEndpoint, &response, &createRequest) + if err != nil { + return nil, errors.WithStack(err) + } + + switch code := resp.Code(); code { + case http.StatusCreated, http.StatusOK: + return newTask(c.client, &response.taskResponse), nil + default: + return nil, response.AsArangoError() + } +} + +// RemoveTask deletes an existing task by its ID from the specified database. +// If the task is successfully removed, it returns nil. +// If the task does not exist or there is an error during the removal, it returns an error. +// The ID parameter is the identifier of the task to be removed. +// The databaseName parameter specifies the database from which the task should be removed. +// It constructs the URL endpoint for the task API and calls the DELETE method to remove the task +func (c *clientTask) RemoveTask(ctx context.Context, databaseName string, id string) error { + urlEndpoint := connection.NewUrl("_db", url.PathEscape(databaseName), "_api", "tasks", url.PathEscape(id)) + + resp, err := connection.CallDelete(ctx, c.client.connection, urlEndpoint, nil) + if err != nil { + return err + } + + switch code := resp.Code(); code { + case http.StatusAccepted, http.StatusOK: + return nil + default: + return shared.NewResponseStruct().AsArangoErrorWithCode(code) + } +} + +// CreateTaskWithID creates a new task with the specified ID and options. +// If a task with the given ID already exists, it returns a Conflict error. +// If the task does not exist, it creates a new task with the provided options. +func (c *clientTask) CreateTaskWithID(ctx context.Context, databaseName string, id string, options TaskOptions) (Task, error) { + // Check if task already exists + existingTask, err := c.Task(ctx, databaseName, id) + if err == nil && existingTask != nil { + return nil, &shared.ArangoError{ + Code: http.StatusConflict, + ErrorMessage: fmt.Sprintf("Task with ID %s already exists", id), + } + } + + // Set the ID and call CreateTask + options.ID = &id + return c.CreateTask(ctx, databaseName, options) +} diff --git a/v2/tests/tasks_test.go b/v2/tests/tasks_test.go new file mode 100644 index 00000000..3d928517 --- /dev/null +++ b/v2/tests/tasks_test.go @@ -0,0 +1,171 @@ +// +// DISCLAIMER +// +// Copyright 2024 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package tests + +import ( + "context" + "testing" + + "github.com/arangodb/go-driver/v2/arangodb" + "github.com/arangodb/go-driver/v2/utils" + "github.com/stretchr/testify/require" +) + +type TaskParams struct { + Foo string `json:"foo"` + Bar string `json:"bar"` +} + +func Test_CreateNewTask(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + dbName := "_system" + testCases := map[string]*arangodb.TaskOptions{ + "taskWIthParams": { + Name: utils.NewType("taskWIthParams"), + Command: utils.NewType("(function(params) { require('@arangodb').print(params); })(params)"), + Period: utils.NewType(int64(2)), + Params: map[string]interface{}{ + "test": "hello", + }, + }, + "taskWIthOutParams": { + Name: utils.NewType("taskWIthOutParams"), + Command: utils.NewType("(function() { require('@arangodb').print('Hello'); })()"), + Period: utils.NewType(int64(2)), + }, + } + + for name, options := range testCases { + withContextT(t, defaultTestTimeout, func(ctx context.Context, tb testing.TB) { + createdTask, err := client.CreateTask(ctx, dbName, *options) + require.NoError(t, err) + require.NotNil(t, createdTask) + require.Equal(t, name, *createdTask.Name()) + t.Logf("Params: %v", options.Params) + // Proper params comparison + // Check parameters + if options.Params != nil { + var params map[string]interface{} + err = createdTask.Params(¶ms) + + if err != nil { + t.Logf("WARNING: Could not fetch task params (unsupported feature?): %v", err) + } else if len(params) == 0 { + t.Logf("WARNING: Task params exist but returned empty (ArangoDB limitation?)") + } else { + // Only check if params are actually returned + require.Equal(t, options.Params, params) + } + } + + taskInfo, err := client.Task(ctx, dbName, *createdTask.ID()) + require.NoError(t, err) + require.NotNil(t, taskInfo) + require.Equal(t, name, *taskInfo.Name()) + + tasks, err := client.Tasks(ctx, dbName) + require.NoError(t, err) + require.NotNil(t, tasks) + require.Greater(t, len(tasks), 0, "Expected at least one task to be present") + t.Logf("Found tasks: %v", tasks) + if len(tasks) > 0 && tasks[0].ID() != nil { + t.Logf("Task Id to be removed: %s\n", *tasks[0].ID()) + } else { + t.Logf("Task Id to be removed: ") + } + if id := createdTask.ID(); id != nil { + require.NoError(t, client.RemoveTask(ctx, dbName, *id)) + t.Logf("Task %s removed successfully", *id) + } else { + t.Logf("Task ID is nil") + } + }) + } + }, WrapOptions{ + Parallel: utils.NewType(false), + }) +} + +func Test_ValidationsForCreateNewTask(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + dbName := "_system" + testCases := map[string]*arangodb.TaskOptions{ + "taskWIthOutCommand": { + Name: utils.NewType("taskWIthOutCommand"), + Period: utils.NewType(int64(2)), + }, + "taskWIthOutPeriod": nil, + } + + for name, options := range testCases { + withContextT(t, defaultTestTimeout, func(ctx context.Context, tb testing.TB) { + var err error + if options == nil { + _, err = client.CreateTask(ctx, dbName, arangodb.TaskOptions{}) + } else { + _, err = client.CreateTask(ctx, dbName, *options) + } + + require.Error(t, err) + t.Logf("Expected error for task '%s': %v", name, err) + }) + } + + }, WrapOptions{ + Parallel: utils.NewType(false), + }) +} + +func Test_TaskCreationWithId(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, tb testing.TB) { + dbName := "_system" + taskID := "test-task-id" + options := &arangodb.TaskOptions{ + ID: &taskID, // Optional if CreateTaskWithID sets it, but safe to keep + Name: utils.NewType("TestTaskWithID"), + Command: utils.NewType("console.log('This is a test task with ID');"), + Period: utils.NewType(int64(5)), + } + + // Create the task with explicit ID + task, err := client.CreateTaskWithID(ctx, dbName, taskID, *options) + require.NoError(t, err, "Expected task creation to succeed") + require.NotNil(t, task, "Expected task to be non-nil") + require.Equal(t, taskID, *task.ID(), "Task ID mismatch") + require.Equal(t, *options.Name, *task.Name(), "Task Name mismatch") + + // Retrieve and validate + retrievedTask, err := client.Task(ctx, dbName, taskID) + require.NoError(t, err, "Expected task retrieval to succeed") + require.NotNil(t, retrievedTask, "Expected retrieved task to be non-nil") + require.Equal(t, taskID, *retrievedTask.ID(), "Retrieved task ID mismatch") + require.Equal(t, *options.Name, *retrievedTask.Name(), "Retrieved task Name mismatch") + + _, err = client.CreateTaskWithID(ctx, dbName, taskID, *options) + require.Error(t, err, "Creating a duplicate task should fail") + + // Clean up + err = client.RemoveTask(ctx, dbName, taskID) + require.NoError(t, err, "Expected task removal to succeed") + }) + }) +}