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..2961882a --- /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, id string) (Task, error) + + // Tasks returns a list of all tasks on the server. + Tasks(ctx context.Context) ([]Task, error) + + // CreateTask creates a new task with the specified options. + CreateTask(ctx context.Context, options *TaskOptions) (Task, error) + + // If a task with the given ID already exists, a Conflict error is returned. + CreateTaskWithID(ctx context.Context, id string, options *TaskOptions) (Task, error) + + // RemoveTask deletes an existing task by its ID. + RemoveTask(ctx context.Context, 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..b7480774 --- /dev/null +++ b/v2/arangodb/tasks_impl.go @@ -0,0 +1,227 @@ +// 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" +) + +// 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 clientTask struct { + client *client +} + +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 +} + +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 +} + +func (c clientTask) Tasks(ctx context.Context) ([]Task, error) { + urlEndpoint := connection.NewUrl("_api", "tasks") // Note: This should include database context, see below + 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: + result := make([]Task, len(response)) + for i, task := range response { + fmt.Printf("Task %d: %+v\n", i, task) + 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) + } +} + +func (c clientTask) Task(ctx context.Context, id string) (Task, error) { + urlEndpoint := connection.NewUrl("_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() + } +} + +func (c clientTask) CreateTask(ctx context.Context, options *TaskOptions) (Task, error) { + var urlEndpoint string + if options.ID != "" { + urlEndpoint = connection.NewUrl("_api", "tasks", url.PathEscape(options.ID)) + } else { + urlEndpoint = connection.NewUrl("_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"` + }{ + ID: options.ID, + Name: options.Name, + Command: options.Command, + Period: options.Period, + Offset: options.Offset, + } + + if options.Params != nil { + 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() + } +} + +func (c clientTask) RemoveTask(ctx context.Context, id string) error { + urlEndpoint := connection.NewUrl("_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) + } +} + +func (c clientTask) CreateTaskWithID(ctx context.Context, id string, options *TaskOptions) (Task, error) { + // Check if task already exists + existingTask, err := c.Task(ctx, id) + fmt.Printf("Checking existing task with ID: %s, existingTask: %v, Error:%v", id, existingTask, err) + 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, options) +} diff --git a/v2/tests/tasks_test.go b/v2/tests/tasks_test.go new file mode 100644 index 00000000..ffb05f75 --- /dev/null +++ b/v2/tests/tasks_test.go @@ -0,0 +1,117 @@ +// +// 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" + "fmt" + "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) { + testCases := map[string]*arangodb.TaskOptions{ + "TestDataForTask": { + Name: "TestDataForTask", + Command: "(function(params) { require('@arangodb').print(params); })(params)", + Period: 2, + Params: map[string]interface{}{ + "test": "hello", + }, + }, + "TestDataForCreateTask": { + Name: "TestDataForCreateTask", + Command: "(function() { require('@arangodb').print(Hello); })()", + Period: 2, + }, + } + + for name, options := range testCases { + withContextT(t, defaultTestTimeout, func(ctx context.Context, tb testing.TB) { + createdTask, err := client.CreateTask(ctx, options) + require.NoError(t, err) + require.NotNil(t, createdTask) + require.Equal(t, name, createdTask.Name()) + + taskInfo, err := client.Task(ctx, createdTask.ID()) + require.NoError(t, err) + require.NotNil(t, taskInfo) + require.Equal(t, name, taskInfo.Name()) + + tasks, err := client.Tasks(ctx) + 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) + fmt.Printf("Number of tasks: %s\n", tasks[0].ID()) + + require.NoError(t, client.RemoveTask(ctx, createdTask.ID())) + t.Logf("Task %s removed successfully", createdTask.ID()) + }) + } + }, 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) { + taskID := "test-task-id" + options := &arangodb.TaskOptions{ + ID: taskID, // Optional if CreateTaskWithID sets it, but safe to keep + Name: "TestTaskWithID", + Command: "console.log('This is a test task with ID');", + Period: 5, + } + + // Create the task with explicit ID + task, err := client.CreateTaskWithID(ctx, 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, 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") + // Try to create task again with same ID — expect 429 + _, err = client.CreateTaskWithID(ctx, taskID, options) + require.Error(t, err, "Creating a duplicate task should fail") + + // Clean up + err = client.RemoveTask(ctx, taskID) + require.NoError(t, err, "Expected task removal to succeed") + }) + }) +}