diff --git a/.circleci/config.yml b/.circleci/config.yml index 4d433f05..d7f746e1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -157,4 +157,4 @@ workflows: cron: 0 6 * * 1 filters: branches: - only: master + only: master \ No newline at end of file diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index af55f645..cfd07539 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -5,6 +5,7 @@ - Add missing endpoints from collections to v2 - Add missing endpoints from query to v2 - Add SSO auth token implementation +- Add missing endpoints from foxx to v2 ## [2.1.3](https://github.com/arangodb/go-driver/tree/v2.1.3) (2025-02-21) - Switch to Go 1.22.11 diff --git a/v2/arangodb/client_foxx.go b/v2/arangodb/client_foxx.go index 87561f86..bfbf6d1a 100644 --- a/v2/arangodb/client_foxx.go +++ b/v2/arangodb/client_foxx.go @@ -33,12 +33,88 @@ type ClientFoxx interface { type ClientFoxxService interface { // InstallFoxxService installs a new service at a given mount path. - InstallFoxxService(ctx context.Context, dbName string, zipFile string, options *FoxxCreateOptions) error + InstallFoxxService(ctx context.Context, dbName string, zipFile string, options *FoxxDeploymentOptions) error // UninstallFoxxService uninstalls service at a given mount path. UninstallFoxxService(ctx context.Context, dbName string, options *FoxxDeleteOptions) error + // ListInstalledFoxxServices retrieves the list of Foxx services installed in the specified database. + // If excludeSystem is true, system services (like _admin/aardvark) will be excluded from the result, + // returning only custom-installed Foxx services. + ListInstalledFoxxServices(ctx context.Context, dbName string, excludeSystem *bool) ([]FoxxServiceListItem, error) + // GetInstalledFoxxService retrieves detailed information about a specific Foxx service + // installed in the specified database. + // The service is identified by its mount path, which must be provided and non-empty. + // If the mount path is missing or empty, a RequiredFieldError is returned. + // The returned FoxxServiceObject contains the full metadata and configuration details + // for the specified service. + GetInstalledFoxxService(ctx context.Context, dbName string, mount *string) (FoxxServiceObject, error) + // ReplaceFoxxService removes the service at the given mount path from the database and file system + // and installs the given new service at the same mount path. + ReplaceFoxxService(ctx context.Context, dbName string, zipFile string, opts *FoxxDeploymentOptions) error + // UpgradeFoxxService installs the given new service on top of the service currently installed + // at the specified mount path, retaining the existing service’s configuration and dependencies. + // This should be used only when upgrading to a newer or equivalent version of the same service. + UpgradeFoxxService(ctx context.Context, dbName string, zipFile string, opts *FoxxDeploymentOptions) error + // GetFoxxServiceConfiguration retrieves the configuration values for the Foxx service + // mounted at the specified path in the given database. + // The mount parameter must not be nil or empty. + // Returns a map containing the current configuration key-value pairs. + GetFoxxServiceConfiguration(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) + // UpdateFoxxServiceConfiguration updates the configuration of a specific Foxx service. + // If the Foxx service does not allow a particular configuration key, it will appear + // in the response warnings. + // The caller is responsible for validating allowed keys before calling this method. + UpdateFoxxServiceConfiguration(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) + // ReplaceFoxxServiceConfiguration replaces the given Foxx service's dependencies entirely. + // If the Foxx service does not allow a particular configuration key, it will appear + // in the response warnings. + // The caller is responsible for validating allowed keys before calling this method. + ReplaceFoxxServiceConfiguration(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) + + // GetFoxxServiceDependencies retrieves the configured dependencies for a specific Foxx service. + // Returns: + // A map where each key is a dependency name and the value is an object containing: + // * title: Human-readable title of the dependency + // * mount: Current mount path of the dependency service (if set) + // An error if the request fails or the mount is missing. + GetFoxxServiceDependencies(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) + // UpdateFoxxServiceDependencies updates the configured dependencies of a specific Foxx service. + // If the Foxx service does not allow a particular dependency key, it will appear + // in the "warnings" field of the response. + // The caller is responsible for ensuring that only allowed dependency keys are provided. + UpdateFoxxServiceDependencies(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) + // ReplaceFoxxServiceDependencies replaces the given Foxx service's dependencies entirely. + // If the Foxx service does not allow a particular dependency key, it will appear + // in the "warnings" field of the response. + // The caller is responsible for validating allowed keys before calling this method. + ReplaceFoxxServiceDependencies(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) + // GetFoxxServiceScripts retrieves the scripts associated with a specific Foxx service. + GetFoxxServiceScripts(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) + // RunFoxxServiceScript executes a specific script associated with a Foxx service. + RunFoxxServiceScript(ctx context.Context, dbName string, name string, mount *string, body map[string]interface{}) (map[string]interface{}, error) + // RunFoxxServiceTests executes the test suite of a specific Foxx service + // deployed in an ArangoDB database. + RunFoxxServiceTests(ctx context.Context, dbName string, opt FoxxTestOptions) (map[string]interface{}, error) + // EnableDevelopmentMode enables the development mode for a specific Foxx service. + // Development mode causes the Foxx service to be reloaded from the filesystem and its setup + // script (if present) to be re-executed every time the service handles a request. + EnableDevelopmentMode(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) + // DisableDevelopmentMode disables the development mode for a specific Foxx service. + DisableDevelopmentMode(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) + // GetFoxxServiceReadme retrieves the README file for a specific Foxx service. + GetFoxxServiceReadme(ctx context.Context, dbName string, mount *string) ([]byte, error) + // GetFoxxServiceSwagger retrieves the Swagger specification + // for a specific Foxx service mounted in the given database. + GetFoxxServiceSwagger(ctx context.Context, dbName string, mount *string) (SwaggerResponse, error) + // CommitFoxxService commits the local Foxx service state of the Coordinator + // to the database. This can resolve service conflicts between Coordinators. + CommitFoxxService(ctx context.Context, dbName string, replace *bool) error + // DownloadFoxxServiceBundle downloads a zip bundle of the Foxx service directory + // from the specified database and mount point. + // Note: The response is the raw zip data (binary). + DownloadFoxxServiceBundle(ctx context.Context, dbName string, mount *string) ([]byte, error) } -type FoxxCreateOptions struct { +type FoxxDeploymentOptions struct { Mount *string } @@ -48,22 +124,24 @@ type FoxxDeleteOptions struct { } // ImportDocumentRequest holds Query parameters for /import. -type InstallFoxxServiceRequest struct { - FoxxCreateOptions `json:",inline"` +type DeployFoxxServiceRequest struct { + FoxxDeploymentOptions `json:",inline"` } type UninstallFoxxServiceRequest struct { FoxxDeleteOptions `json:",inline"` } -func (c *InstallFoxxServiceRequest) modifyRequest(r connection.Request) error { +func (c *DeployFoxxServiceRequest) modifyRequest(r connection.Request) error { if c == nil { return nil } r.AddHeader(connection.ContentType, "application/zip") - r.AddQuery("mount", *c.Mount) - + if c.Mount != nil && *c.Mount != "" { + mount := *c.Mount + r.AddQuery("mount", mount) + } return nil } @@ -72,8 +150,163 @@ func (c *UninstallFoxxServiceRequest) modifyRequest(r connection.Request) error return nil } - r.AddQuery("mount", *c.Mount) - r.AddQuery("teardown", strconv.FormatBool(*c.Teardown)) + if c.Mount != nil && *c.Mount != "" { + mount := *c.Mount + r.AddQuery("mount", mount) + } + + if c.Teardown != nil { + r.AddQuery("teardown", strconv.FormatBool(*c.Teardown)) + } + return nil +} + +type CommonFoxxServiceFields struct { + // Mount is the mount path of the Foxx service in the database (e.g., "/my-service"). + // This determines the URL path at which the service can be accessed. + Mount *string `json:"mount"` + + // Development indicates whether the service is in development mode. + // When true, the service is not cached and changes are applied immediately. + Development *bool `json:"development"` + + // Legacy indicates whether the service uses a legacy format or API. + // This may be used for backward compatibility checks. + Legacy *bool `json:"legacy"` + // Name is the name of the Foxx service (optional). + // This may be defined in the service manifest (manifest.json). + Name *string `json:"name,omitempty"` + + // Version is the version of the Foxx service (optional). + // This is useful for managing service upgrades or deployments. + Version *string `json:"version,omitempty"` +} + +// FoxxServiceListItem represents a single Foxx service installed in an ArangoDB database. +type FoxxServiceListItem struct { + CommonFoxxServiceFields + // Provides lists the capabilities or interfaces the service provides. + // This is a flexible map that may contain metadata like API contracts or service roles. + Provides map[string]interface{} `json:"provides"` +} + +// Repository describes the version control repository for the Foxx service. +type Repository struct { + // Type is the type of repository (e.g., "git"). + Type *string `json:"type,omitempty"` + + // URL is the link to the repository. + URL *string `json:"url,omitempty"` +} + +// Contributor represents a person who contributed to the Foxx service. +type Contributor struct { + // Name is the contributor's name. + Name *string `json:"name,omitempty"` + + // Email is the contributor's contact email. + Email *string `json:"email,omitempty"` +} + +// Engines specifies the ArangoDB engine requirements for the Foxx service. +type Engines struct { + // Arangodb specifies the required ArangoDB version range (semver format). + Arangodb *string `json:"arangodb,omitempty"` +} + +// Manifest represents the normalized manifest.json of the Foxx service. +type Manifest struct { + // Schema is the JSON schema URL for the manifest structure. + Schema *string `json:"$schema,omitempty"` + + // Name is the name of the Foxx service. + Name *string `json:"name,omitempty"` + + // Version is the service's semantic version. + Version *string `json:"version,omitempty"` + + // License is the license identifier (e.g., "Apache-2.0"). + License *string `json:"license,omitempty"` + + // Repository contains details about the service's source repository. + Repository *Repository `json:"repository,omitempty"` + + // Author is the main author of the service. + Author *string `json:"author,omitempty"` + + // Contributors is a list of people who contributed to the service. + Contributors []*Contributor `json:"contributors,omitempty"` + + // Description provides a human-readable explanation of the service. + Description *string `json:"description,omitempty"` + + // Engines specifies the engine requirements for running the service. + Engines *Engines `json:"engines,omitempty"` + + // DefaultDocument specifies the default document to serve (e.g., "index.html"). + DefaultDocument *string `json:"defaultDocument,omitempty"` + + // Main specifies the main entry point JavaScript file of the service. + Main *string `json:"main,omitempty"` + + // Configuration contains service-specific configuration options. + Configuration map[string]interface{} `json:"configuration,omitempty"` + + // Dependencies defines other services or packages this service depends on. + Dependencies map[string]interface{} `json:"dependencies,omitempty"` + + // Files maps URL paths to static files or directories included in the service. + Files map[string]interface{} `json:"files,omitempty"` + + // Scripts contains script definitions for service lifecycle hooks or tasks. + Scripts map[string]interface{} `json:"scripts,omitempty"` +} + +// FoxxServiceObject is the top-level response object for a Foxx service details request. +type FoxxServiceObject struct { + // Common fields for all Foxx services. + CommonFoxxServiceFields + + // Path is the local filesystem path where the service is installed. + Path *string `json:"path,omitempty"` + + // Manifest contains the normalized manifest.json of the service. + Manifest *Manifest `json:"manifest,omitempty"` + + // Options contains optional runtime options defined for the service. + Options map[string]interface{} `json:"options,omitempty"` +} + +type FoxxTestOptions struct { + FoxxDeploymentOptions + Reporter *string `json:"reporter,omitempty"` + Idiomatic *bool `json:"idiomatic,omitempty"` + Filter *string `json:"filter,omitempty"` +} + +// SwaggerInfo contains general metadata about the API, typically displayed +// in tools like Swagger UI. +type SwaggerInfo struct { + // Title is the title of the API. + Title *string `json:"title,omitempty"` + // Description provides a short description of the API. + Description *string `json:"description,omitempty"` + // Version specifies the version of the API. + Version *string `json:"version,omitempty"` + // License provides licensing information for the API. + License map[string]interface{} `json:"license,omitempty"` +} +// SwaggerResponse represents the root object of a Swagger (OpenAPI 2.0) specification. +// It contains metadata, versioning information, available API paths, and additional details. +type SwaggerResponse struct { + // Swagger specifies the Swagger specification version (e.g., "2.0"). + Swagger *string `json:"swagger,omitempty"` + // BasePath defines the base path on which the API is served, relative to the host. + BasePath *string `json:"basePath,omitempty"` + // Paths holds the available endpoints and their supported operations. + Paths map[string]interface{} `json:"paths,omitempty"` + // Info provides metadata about the API, such as title, version, and license. + Info *SwaggerInfo `json:"info,omitempty"` } diff --git a/v2/arangodb/client_foxx_impl.go b/v2/arangodb/client_foxx_impl.go index 668d1aa4..d6604d5d 100644 --- a/v2/arangodb/client_foxx_impl.go +++ b/v2/arangodb/client_foxx_impl.go @@ -21,7 +21,10 @@ package arangodb import ( "context" + "encoding/json" + "fmt" "net/http" + "net/url" "os" "github.com/arangodb/go-driver/v2/arangodb/shared" @@ -41,17 +44,47 @@ func newClientFoxx(client *client) *clientFoxx { } } +func (c *clientFoxx) url(dbName string, pathSegments []string, queryParams map[string]interface{}) string { + + base := connection.NewUrl("_db", url.PathEscape(dbName), "_api", "foxx") + for _, seg := range pathSegments { + base = fmt.Sprintf("%s/%s", base, url.PathEscape(seg)) + } + + if len(queryParams) > 0 { + q := url.Values{} + for k, v := range queryParams { + switch val := v.(type) { + case string: + q.Set(k, val) + case bool: + q.Set(k, fmt.Sprintf("%t", val)) + case int, int64, float64: + q.Set(k, fmt.Sprintf("%v", val)) + default: + // skip unsupported types or handle as needed + } + } + base = fmt.Sprintf("%s?%s", base, q.Encode()) + } + return base +} + // InstallFoxxService installs a new service at a given mount path. -func (c *clientFoxx) InstallFoxxService(ctx context.Context, dbName string, zipFile string, opts *FoxxCreateOptions) error { +func (c *clientFoxx) InstallFoxxService(ctx context.Context, dbName string, zipFile string, opts *FoxxDeploymentOptions) error { url := connection.NewUrl("_db", dbName, "_api/foxx") var response struct { shared.ResponseStruct `json:",inline"` } - request := &InstallFoxxServiceRequest{} + request := &DeployFoxxServiceRequest{} if opts != nil { - request.FoxxCreateOptions = *opts + request.FoxxDeploymentOptions = *opts + } + + if _, err := os.Stat(zipFile); os.IsNotExist(err) { + return errors.WithStack(err) } bytes, err := os.ReadFile(zipFile) @@ -65,7 +98,7 @@ func (c *clientFoxx) InstallFoxxService(ctx context.Context, dbName string, zipF } switch code := resp.Code(); code { - case http.StatusOK: + case http.StatusCreated: // Foxx install returns 201 Created as per ArangoDB API return nil default: return response.AsArangoErrorWithCode(code) @@ -75,6 +108,7 @@ func (c *clientFoxx) InstallFoxxService(ctx context.Context, dbName string, zipF // UninstallFoxxService uninstalls service at a given mount path. func (c *clientFoxx) UninstallFoxxService(ctx context.Context, dbName string, opts *FoxxDeleteOptions) error { + url := connection.NewUrl("_db", dbName, "_api/foxx/service") var response struct { @@ -85,8 +119,152 @@ func (c *clientFoxx) UninstallFoxxService(ctx context.Context, dbName string, op if opts != nil { request.FoxxDeleteOptions = *opts } + // As per ArangoDB docs (Foxx uninstall → DELETE /_db/{db}/_api/foxx/service). + resp, err := connection.CallDelete(ctx, c.client.connection, url, &response, request.modifyRequest) + if err != nil { + return errors.WithStack(err) + } + + switch code := resp.Code(); code { + case http.StatusNoContent: + return nil + default: + return response.AsArangoErrorWithCode(code) + } +} + +// ListInstalledFoxxServices retrieves the list of Foxx services. +func (c *clientFoxx) ListInstalledFoxxServices(ctx context.Context, dbName string, excludeSystem *bool) ([]FoxxServiceListItem, error) { + query := map[string]interface{}{} + // query params + if excludeSystem != nil { + query["excludeSystem"] = *excludeSystem + } + + urlEndpoint := c.url(dbName, nil, query) + // Use json.RawMessage to capture raw response for debugging + var rawResult json.RawMessage + resp, err := connection.CallGet(ctx, c.client.connection, urlEndpoint, &rawResult) + if err != nil { + return nil, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + // Try to unmarshal as array first + var result []FoxxServiceListItem + if err := json.Unmarshal(rawResult, &result); err == nil { + return result, nil + } + + // If array unmarshaling fails, try as object with result field + var objResult struct { + Result []FoxxServiceListItem `json:"result"` + Error bool `json:"error"` + Code int `json:"code"` + } + + if err := json.Unmarshal(rawResult, &objResult); err == nil { + if objResult.Error { + return nil, fmt.Errorf("ArangoDB API error: code %d", objResult.Code) + } + return objResult.Result, nil + } + + // If both fail, return the unmarshal error + return nil, fmt.Errorf("cannot unmarshal response into []FoxxServiceListItem or object with result field: %s", string(rawResult)) + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) + } +} + +// GetInstalledFoxxService retrieves detailed information about a specific Foxx service +func (c *clientFoxx) GetInstalledFoxxService(ctx context.Context, dbName string, mount *string) (FoxxServiceObject, error) { + + if mount == nil || *mount == "" { + return FoxxServiceObject{}, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"service"}, map[string]interface{}{ + "mount": *mount, + }) + + // Use json.RawMessage to capture raw response for debugging + var result struct { + shared.ResponseStruct `json:",inline"` + FoxxServiceObject `json:",inline"` + } + resp, err := connection.CallGet(ctx, c.client.connection, urlEndpoint, &result) + if err != nil { + return FoxxServiceObject{}, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + return result.FoxxServiceObject, nil + default: + return FoxxServiceObject{}, result.AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) ReplaceFoxxService(ctx context.Context, dbName string, zipFile string, opts *FoxxDeploymentOptions) error { + + // url := connection.NewUrl("_db", dbName, "_api/foxx/service") + url := c.url(dbName, []string{"service"}, nil) + var response struct { + shared.ResponseStruct `json:",inline"` + } + + request := &DeployFoxxServiceRequest{} + if opts != nil { + request.FoxxDeploymentOptions = *opts + } + + if _, err := os.Stat(zipFile); os.IsNotExist(err) { + return errors.WithStack(err) + } + + bytes, err := os.ReadFile(zipFile) + if err != nil { + return errors.WithStack(err) + } + + resp, err := connection.CallPut(ctx, c.client.connection, url, &response, bytes, request.modifyRequest) + if err != nil { + return errors.WithStack(err) + } + + switch code := resp.Code(); code { + case http.StatusOK: + return nil + default: + return response.AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) UpgradeFoxxService(ctx context.Context, dbName string, zipFile string, opts *FoxxDeploymentOptions) error { + + // url := connection.NewUrl("_db", dbName, "_api/foxx/service") + url := c.url(dbName, []string{"service"}, nil) + var response struct { + shared.ResponseStruct `json:",inline"` + } + + request := &DeployFoxxServiceRequest{} + if opts != nil { + request.FoxxDeploymentOptions = *opts + } + + if _, err := os.Stat(zipFile); os.IsNotExist(err) { + return errors.WithStack(err) + } + + bytes, err := os.ReadFile(zipFile) + if err != nil { + return errors.WithStack(err) + } - resp, err := connection.CallPost(ctx, c.client.connection, url, &response, nil, request.modifyRequest) + resp, err := connection.CallPatch(ctx, c.client.connection, url, &response, bytes, request.modifyRequest) if err != nil { return errors.WithStack(err) } @@ -98,3 +276,396 @@ func (c *clientFoxx) UninstallFoxxService(ctx context.Context, dbName string, op return response.AsArangoErrorWithCode(code) } } + +func (c *clientFoxx) callFoxxServiceAPI( + ctx context.Context, + dbName string, + mount *string, + path string, // "configuration" or "dependencies" + method string, // "GET", "PATCH", "PUT" + body map[string]interface{}, // nil for GET +) (map[string]interface{}, error) { + if mount == nil || *mount == "" { + return nil, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{path}, map[string]interface{}{ + "mount": *mount, + }) + + var rawResult json.RawMessage + var resp connection.Response + var err error + + switch method { + case http.MethodGet: + resp, err = connection.CallGet(ctx, c.client.connection, urlEndpoint, &rawResult) + case http.MethodPatch: + resp, err = connection.CallPatch(ctx, c.client.connection, urlEndpoint, &rawResult, body) + case http.MethodPut: + resp, err = connection.CallPut(ctx, c.client.connection, urlEndpoint, &rawResult, body) + default: + return nil, fmt.Errorf("unsupported HTTP method: %s", method) + } + + if err != nil { + return nil, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + var result map[string]interface{} + if err := json.Unmarshal(rawResult, &result); err == nil { + return result, nil + } + + var objResult struct { + Result map[string]interface{} `json:"result"` + Error bool `json:"error"` + Code int `json:"code"` + } + if err := json.Unmarshal(rawResult, &objResult); err == nil { + if objResult.Error { + return nil, fmt.Errorf("ArangoDB API error: code %d", objResult.Code) + } + return objResult.Result, nil + } + + return nil, fmt.Errorf( + "cannot unmarshal response into map or object with result field: %s", + string(rawResult), + ) + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) GetFoxxServiceConfiguration(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "configuration", http.MethodGet, nil) +} + +func (c *clientFoxx) UpdateFoxxServiceConfiguration(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "configuration", http.MethodPatch, opt) +} + +func (c *clientFoxx) ReplaceFoxxServiceConfiguration(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "configuration", http.MethodPut, opt) +} + +func (c *clientFoxx) GetFoxxServiceDependencies(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "dependencies", http.MethodGet, nil) +} + +func (c *clientFoxx) UpdateFoxxServiceDependencies(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "dependencies", http.MethodPatch, opt) +} + +func (c *clientFoxx) ReplaceFoxxServiceDependencies(ctx context.Context, dbName string, mount *string, opt map[string]interface{}) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "dependencies", http.MethodPut, opt) +} + +func (c *clientFoxx) GetFoxxServiceScripts(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) { + return c.callFoxxServiceAPI(ctx, dbName, mount, "scripts", http.MethodGet, nil) +} + +func (c *clientFoxx) RunFoxxServiceScript(ctx context.Context, dbName string, name string, mount *string, body map[string]interface{}) (map[string]interface{}, error) { + + if mount == nil || *mount == "" { + return nil, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"scripts", name}, map[string]interface{}{ + "mount": *mount, + }) + + var rawResult json.RawMessage + resp, err := connection.CallPost(ctx, c.client.connection, urlEndpoint, &rawResult, body) + + if err != nil { + return nil, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + var result map[string]interface{} + if err := json.Unmarshal(rawResult, &result); err == nil { + return result, nil + } + + var objResult struct { + Result map[string]interface{} `json:"result"` + Error bool `json:"error"` + Code int `json:"code"` + } + if err := json.Unmarshal(rawResult, &objResult); err == nil { + if objResult.Error { + return nil, fmt.Errorf("ArangoDB API error: code %d", objResult.Code) + } + return objResult.Result, nil + } + + return nil, fmt.Errorf( + "cannot unmarshal response into map or object with result field: %s", + string(rawResult), + ) + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) RunFoxxServiceTests(ctx context.Context, dbName string, opt FoxxTestOptions) (map[string]interface{}, error) { + + if opt.Mount == nil || *opt.Mount == "" { + return nil, RequiredFieldError("mount") + } + + queryParams := map[string]interface{}{ + "mount": *opt.Mount, + } + + if opt.Reporter != nil { + queryParams["reporter"] = *opt.Reporter + } + if opt.Idiomatic != nil { + queryParams["idiomatic"] = *opt.Idiomatic + } + if opt.Filter != nil { + queryParams["filter"] = *opt.Filter + } + + urlEndpoint := c.url(dbName, []string{"tests"}, queryParams) + + var rawResult json.RawMessage + resp, err := connection.CallPost(ctx, c.client.connection, urlEndpoint, &rawResult, nil) + + if err != nil { + return nil, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + var result map[string]interface{} + if err := json.Unmarshal(rawResult, &result); err == nil { + return result, nil + } + + var objResult struct { + Result map[string]interface{} `json:"result"` + Error bool `json:"error"` + Code int `json:"code"` + } + if err := json.Unmarshal(rawResult, &objResult); err == nil { + if objResult.Error { + return nil, fmt.Errorf("ArangoDB API error: code %d", objResult.Code) + } + return objResult.Result, nil + } + + return nil, fmt.Errorf( + "cannot unmarshal response into map or object with result field: %s", + string(rawResult), + ) + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) EnableDevelopmentMode(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) { + + if mount == nil || *mount == "" { + return nil, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"development"}, map[string]interface{}{ + "mount": *mount, + }) + + var rawResult json.RawMessage + resp, err := connection.CallPost(ctx, c.client.connection, urlEndpoint, &rawResult, nil) + + if err != nil { + return nil, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + var result map[string]interface{} + if err := json.Unmarshal(rawResult, &result); err == nil { + return result, nil + } + + var objResult struct { + Result map[string]interface{} `json:"result"` + Error bool `json:"error"` + Code int `json:"code"` + } + if err := json.Unmarshal(rawResult, &objResult); err == nil { + if objResult.Error { + return nil, fmt.Errorf("ArangoDB API error: code %d", objResult.Code) + } + return objResult.Result, nil + } + + return nil, fmt.Errorf( + "cannot unmarshal response into map or object with result field: %s", + string(rawResult), + ) + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) DisableDevelopmentMode(ctx context.Context, dbName string, mount *string) (map[string]interface{}, error) { + + if mount == nil || *mount == "" { + return nil, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"development"}, map[string]interface{}{ + "mount": *mount, + }) + + var rawResult json.RawMessage + resp, err := connection.CallDelete(ctx, c.client.connection, urlEndpoint, &rawResult) + + if err != nil { + return nil, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + var result map[string]interface{} + if err := json.Unmarshal(rawResult, &result); err == nil { + return result, nil + } + + var objResult struct { + Result map[string]interface{} `json:"result"` + Error bool `json:"error"` + Code int `json:"code"` + } + if err := json.Unmarshal(rawResult, &objResult); err == nil { + if objResult.Error { + return nil, fmt.Errorf("ArangoDB API error: code %d", objResult.Code) + } + return objResult.Result, nil + } + + return nil, fmt.Errorf( + "cannot unmarshal response into map or object with result field: %s", + string(rawResult), + ) + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) GetFoxxServiceReadme(ctx context.Context, dbName string, mount *string) ([]byte, error) { + if mount == nil || *mount == "" { + return nil, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"readme"}, map[string]interface{}{ + "mount": *mount, + }) + + // Create request + req, err := c.client.Connection().NewRequest(http.MethodGet, urlEndpoint) + if err != nil { + return nil, err + } + var data []byte + // Call Do with nil result (we'll handle body manually) + resp, err := c.client.Connection().Do(ctx, req, &data, http.StatusOK, http.StatusNoContent) + if err != nil { + return nil, err + } + + switch resp.Code() { + case http.StatusOK: + return data, nil + case http.StatusNoContent: + return nil, nil + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(resp.Code()) + } +} + +func (c *clientFoxx) GetFoxxServiceSwagger(ctx context.Context, dbName string, mount *string) (SwaggerResponse, error) { + if mount == nil || *mount == "" { + return SwaggerResponse{}, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"swagger"}, map[string]interface{}{ + "mount": *mount, + }) + + var result struct { + shared.ResponseStruct `json:",inline"` + SwaggerResponse `json:",inline"` + } + + resp, err := connection.CallGet(ctx, c.client.connection, urlEndpoint, &result) + if err != nil { + return SwaggerResponse{}, err + } + + switch code := resp.Code(); code { + case http.StatusOK: + return result.SwaggerResponse, nil + default: + return SwaggerResponse{}, result.AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) CommitFoxxService(ctx context.Context, dbName string, replace *bool) error { + queryParams := make(map[string]interface{}) + if replace != nil { + queryParams["replace"] = *replace + } + + urlEndpoint := c.url(dbName, []string{"commit"}, queryParams) + + var result struct { + shared.ResponseStruct `json:",inline"` + } + + resp, err := connection.CallPost(ctx, c.client.connection, urlEndpoint, &result, nil) + if err != nil { + return err + } + + switch code := resp.Code(); code { + case http.StatusNoContent: // 204 expected + return nil + default: + return result.AsArangoErrorWithCode(code) + } +} + +func (c *clientFoxx) DownloadFoxxServiceBundle(ctx context.Context, dbName string, mount *string) ([]byte, error) { + if mount == nil || *mount == "" { + return nil, RequiredFieldError("mount") + } + + urlEndpoint := c.url(dbName, []string{"download"}, map[string]interface{}{ + "mount": *mount, + }) + // Create request + req, err := c.client.Connection().NewRequest(http.MethodPost, urlEndpoint) + if err != nil { + return nil, err + } + var data []byte + // Call Do with nil result (we'll handle body manually) + resp, err := c.client.Connection().Do(ctx, req, &data, http.StatusOK, http.StatusNoContent) + if err != nil { + return nil, err + } + switch code := resp.Code(); code { + case http.StatusOK: + return data, nil + default: + return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(resp.Code()) + } +} diff --git a/v2/tests/foxx_test.go b/v2/tests/foxx_test.go index fba09999..e8a71c95 100644 --- a/v2/tests/foxx_test.go +++ b/v2/tests/foxx_test.go @@ -29,55 +29,322 @@ import ( "github.com/stretchr/testify/require" "github.com/arangodb/go-driver/v2/arangodb" - "github.com/arangodb/go-driver/v2/connection" "github.com/arangodb/go-driver/v2/utils" ) func Test_FoxxItzpapalotlService(t *testing.T) { Wrap(t, func(t *testing.T, client arangodb.Client) { - WithDatabase(t, client, nil, func(db arangodb.Database) { - t.Run("Install and uninstall Foxx", func(t *testing.T) { - withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { - if os.Getenv("TEST_CONNECTION") == "vst" { - skipBelowVersion(client, ctx, "3.6", t) - } + ctx := context.Background() + db, err := client.GetDatabase(ctx, "_system", nil) + require.NoError(t, err) - // /tmp/resources/ directory is provided by .travis.yml - zipFilePath := "/tmp/resources/itzpapalotl-v1.2.0.zip" - if _, err := os.Stat(zipFilePath); os.IsNotExist(err) { - // Test works only via travis pipeline unless the above file exists locally - t.Skipf("file %s does not exist", zipFilePath) - } + if os.Getenv("TEST_CONNECTION") == "vst" { + skipBelowVersion(client, ctx, "3.6", t) + } - timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) - mountName := "test" - options := &arangodb.FoxxCreateOptions{ - Mount: utils.NewType[string]("/" + mountName), - } - err := client.InstallFoxxService(timeoutCtx, db.Name(), zipFilePath, options) - cancel() - require.NoError(t, err) + // /tmp/resources/ directory is provided by .travis.yml + zipFilePath := "/tmp/resources/itzpapalotl-v1.2.0.zip" + if _, err := os.Stat(zipFilePath); os.IsNotExist(err) { + // Test works only via travis pipeline unless the above file exists locally + t.Skipf("file %s does not exist", zipFilePath) + } + mountName := "test" + options := &arangodb.FoxxDeploymentOptions{ + Mount: utils.NewType[string]("/" + mountName), + } - timeoutCtx, cancel = context.WithTimeout(context.Background(), time.Second*30) - resp, err := connection.CallGet(ctx, client.Connection(), "_db/_system/"+mountName+"/random", nil, nil, nil) - require.NotNil(t, resp) + // InstallFoxxService + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*5) - value, ok := resp, true - require.Equal(t, true, ok) - require.NotEmpty(t, value) - cancel() + err = client.InstallFoxxService(timeoutCtx, db.Name(), zipFilePath, options) + cancel() + require.NoError(t, err) + + // UninstallFoxxService + defer client.UninstallFoxxService(context.Background(), db.Name(), &arangodb.FoxxDeleteOptions{ + Mount: utils.NewType[string]("/" + mountName), + Teardown: utils.NewType[bool](true), + }) + + // Try to fetch random name from installed foxx service + timeoutCtx, cancel = context.WithTimeout(context.Background(), time.Second*30) + connection := client.Connection() + req, err := connection.NewRequest("GET", "_db/"+db.Name()+"/"+mountName+"/random") + require.NoError(t, err) + resp, err := connection.Do(timeoutCtx, req, nil) + require.NoError(t, err) + require.NotNil(t, resp) + + value, ok := resp, true + require.Equal(t, true, ok) + require.NotEmpty(t, value) + cancel() + + // ReplaceFoxxService + t.Run("Replace Foxx service", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + err = client.ReplaceFoxxService(timeoutCtx, db.Name(), zipFilePath, options) + cancel() + require.NoError(t, err) + }) + }) + + // UpgradeFoxxService + t.Run("Upgrade Foxx service", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + err = client.UpgradeFoxxService(timeoutCtx, db.Name(), zipFilePath, options) + cancel() + require.NoError(t, err) + }) + }) + + // Foxx Service Configurations + t.Run("Fetch Foxx service Configuration", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.GetFoxxServiceConfiguration(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Update Foxx service Configuration + t.Run("Update Foxx service Configuration", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.UpdateFoxxServiceConfiguration(timeoutCtx, db.Name(), options.Mount, map[string]interface{}{ + "apiKey": "abcdef", + "maxItems": 100, + }) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Replace Foxx service Configuration + t.Run("Replace Foxx service Configuration", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.ReplaceFoxxServiceConfiguration(timeoutCtx, db.Name(), options.Mount, map[string]interface{}{ + "apiKey": "xyz987", + "maxItems": 100, + }) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Fetch Foxx Service Dependencies + t.Run("Fetch Foxx Service Dependencies", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.GetFoxxServiceDependencies(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Update Foxx Service Dependencies + t.Run("Update Foxx Service Dependencies", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.UpdateFoxxServiceDependencies(timeoutCtx, db.Name(), options.Mount, map[string]interface{}{ + "title": "Auth Service", + }) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Replace Foxx Service Dependencies + t.Run("Replace Foxx Service Dependencies", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.ReplaceFoxxServiceDependencies(timeoutCtx, db.Name(), options.Mount, map[string]interface{}{ + "title": "Auth Service", + "description": "Service that handles authentication", + "mount": "/auth-v2", + }) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Fetch Foxx Service Scripts + t.Run("Fetch Foxx Service Scripts", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.GetFoxxServiceScripts(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Run Foxx Service Script + t.Run("Run Foxx Service Script", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + scriptName := "cleanupData" + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + _, err := client.RunFoxxServiceScript(timeoutCtx, db.Name(), scriptName, options.Mount, + map[string]interface{}{ + "cleanupData": "Cleanup Old Data", + }) + cancel() + require.Error(t, err) + }) + }) + // Test Foxx Service + t.Run("Test Foxx Service", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.RunFoxxServiceTests(timeoutCtx, db.Name(), arangodb.FoxxTestOptions{ + FoxxDeploymentOptions: arangodb.FoxxDeploymentOptions{ + Mount: utils.NewType("/" + mountName), + }, + }) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Enable Development Mode For Foxx Service + t.Run("Enable Development Mode For Foxx Service", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + // if getTestMode() == string(testModeCluster) { + // t.Skipf("It's a cluster mode") + // } + resp, err := client.EnableDevelopmentMode(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Disable Development Mode For Foxx Service + t.Run("Disable Development Mode For Foxx Service", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + // if getTestMode() == string(testModeCluster) { + // t.Skipf("It's a cluster mode") + // } + resp, err := client.DisableDevelopmentMode(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Get Foxx Service Readme + t.Run("Get Foxx Service Readme", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + resp, err := client.GetFoxxServiceReadme(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) + + // Get Foxx Service Swagger + t.Run("Get Foxx Service Swagger ", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.GetFoxxServiceSwagger(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) + }) + }) - timeoutCtx, cancel = context.WithTimeout(context.Background(), time.Second*30) - deleteOptions := &arangodb.FoxxDeleteOptions{ - Mount: utils.NewType[string]("/" + mountName), - Teardown: utils.NewType[bool](true), - } - err = client.UninstallFoxxService(timeoutCtx, db.Name(), deleteOptions) + // Commit Foxx Service + t.Run("Commit Foxx Service ", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + role, err := client.ServerRole(ctx) + require.NoError(t, err) + if role != "Single" { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + err := client.CommitFoxxService(timeoutCtx, db.Name(), utils.NewType(false)) cancel() require.NoError(t, err) - }) + } + }) + }) + + // Download Foxx Service Bundle + t.Run("Download Foxx Service Bundle ", func(t *testing.T) { + withContextT(t, defaultTestTimeout, func(ctx context.Context, t testing.TB) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Minute*30) + resp, err := client.DownloadFoxxServiceBundle(timeoutCtx, db.Name(), options.Mount) + cancel() + require.NoError(t, err) + require.NotNil(t, resp) }) }) }) } + +func Test_ListInstalledFoxxServices(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + db, err := client.GetDatabase(ctx, "_system", nil) + require.NoError(t, err) + + // excludeSystem := false + services, err := client.ListInstalledFoxxServices(ctx, db.Name(), nil) + require.NoError(t, err) + require.NotEmpty(t, services) + require.GreaterOrEqual(t, len(services), 0) + + if len(services) == 0 { + t.Log("No Foxx services found.") + return + } + + for _, service := range services { + require.NotEmpty(t, service.Mount) + require.NotEmpty(t, service.Name) + require.NotEmpty(t, service.Version) + require.NotNil(t, service.Development) + require.NotNil(t, service.Provides) + require.NotNil(t, service.Legacy) + } + }) +} + +func Test_GetInstalledFoxxService(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + db, err := client.GetDatabase(ctx, "_system", nil) + require.NoError(t, err) + + mount := "/_api/foxx" + serviceDetails, err := client.GetInstalledFoxxService(ctx, db.Name(), &mount) + require.NoError(t, err) + require.NotEmpty(t, serviceDetails) + require.NotNil(t, serviceDetails.Mount) + require.NotNil(t, serviceDetails.Name) + require.NotNil(t, serviceDetails.Version) + require.NotNil(t, serviceDetails.Development) + require.NotNil(t, serviceDetails.Path) + require.NotNil(t, serviceDetails.Legacy) + require.NotNil(t, serviceDetails.Manifest) + }) +}