diff --git a/README.md b/README.md index 9ee6495..22bf3f4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, - [`add_business_term`](pkg/tools/add_business_term/) - Create a business term asset with definition and optional attributes - [`add_data_classification_match`](pkg/tools/add_data_classification_match/) - Associate a data class with an asset - [`create_asset`](pkg/tools/create_asset/) - Create a new data asset with optional attributes +- [`edit_asset`](pkg/tools/edit_asset/) - Edit an existing asset via a list of typed operations: + - `update_attribute`, `add_attribute`, `remove_attribute` - change, append, or clear an attribute value (e.g. `Definition`, `Note`) + - `update_property` - rename the asset (`name`), change its `displayName`, or change its `statusId` (status name or UUID accepted) + - `add_relation`, `remove_relation` - link or unlink the asset to another asset by relation role (e.g. `is synonym of`) + - `add_tag` - append a free-text tag without replacing existing tags + - `set_responsibility` - assign a user or group to a resource role (e.g. `Steward`, `Owner`) by username, email, or UUID - [`push_data_contract_manifest`](pkg/tools/push_data_contract_manifest/) - Upload manifest for a data contract - [`remove_data_classification_match`](pkg/tools/remove_data_classification_match/) - Remove a classification match diff --git a/pkg/chip/version.go b/pkg/chip/version.go index f63fc16..cd02a39 100644 --- a/pkg/chip/version.go +++ b/pkg/chip/version.go @@ -1,3 +1,3 @@ package chip -var Version = "0.0.31-SNAPSHOT" +var Version = "0.0.32-SNAPSHOT" diff --git a/pkg/clients/edit_asset_client.go b/pkg/clients/edit_asset_client.go new file mode 100644 index 0000000..017cee6 --- /dev/null +++ b/pkg/clients/edit_asset_client.go @@ -0,0 +1,1008 @@ +package clients + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// EditAssetCore is the slim view of an asset returned by GET /rest/2.0/assets/{id} +// that the edit_asset tool needs for validation and dispatch. +type EditAssetCore struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Type EditAssetTypeRef `json:"type"` + Domain EditAssetDomainRef `json:"domain"` + Status *EditAssetStatusRef `json:"status,omitempty"` +} + +// EditAssetTypeRef is a reference to an asset type. +type EditAssetTypeRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// EditAssetDomainRef is a reference to a domain on an asset. +type EditAssetDomainRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// EditAssetStatusRef is a reference to the asset's status. +type EditAssetStatusRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// EditAssetDomainDetails is the view of a domain that exposes its domain type, +// returned by GET /rest/2.0/domains/{id}. Needed to scope the assignment. +type EditAssetDomainDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Type *EditAssetDomainTypeRef `json:"type,omitempty"` +} + +// EditAssetDomainTypeRef is a reference to a domain type. +type EditAssetDomainTypeRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// EditAssetAttributeInstance is a single attribute value on an asset, +// returned by GET /rest/2.0/attributes?assetId=.... +type EditAssetAttributeInstance struct { + ID string `json:"id"` + Type EditAssetAttributeTypeRef `json:"type"` + Asset EditAssetAttributeAssetRef `json:"asset"` + Value string `json:"value"` +} + +// UnmarshalJSON tolerates `value` fields returned as JSON numbers, booleans, +// or null. Collibra emits the field with whatever underlying type the +// attribute kind dictates (NumericAttributeType -> number, +// BooleanAttributeType -> bool, etc.), so a strict string decode fails when +// an asset has any non-string attribute. We stringify any scalar and treat +// null as the empty string; consumers only need a printable representation +// for diffs and error messages. +func (a *EditAssetAttributeInstance) UnmarshalJSON(data []byte) error { + type alias EditAssetAttributeInstance + aux := struct { + *alias + Value json.RawMessage `json:"value"` + }{alias: (*alias)(a)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + switch { + case len(aux.Value) == 0, string(aux.Value) == "null": + a.Value = "" + case aux.Value[0] == '"': + var s string + if err := json.Unmarshal(aux.Value, &s); err != nil { + return err + } + a.Value = s + default: + // Number, bool, or other primitive — keep the raw JSON literal. + a.Value = string(aux.Value) + } + return nil +} + +// EditAssetAttributeTypeRef is a reference to an attribute type on an instance. +type EditAssetAttributeTypeRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// EditAssetAttributeAssetRef is a reference to the owning asset. +type EditAssetAttributeAssetRef struct { + ID string `json:"id"` +} + +// editAssetAttributesList is the paginated wrapper returned by +// GET /rest/2.0/attributes. +type editAssetAttributesList struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Results []EditAssetAttributeInstance `json:"results"` +} + +// EditAssetAssignment is the scoped assignment for a (asset type, domain type) +// pair — lists which attribute and relation types are valid for assets of +// this shape. This is the public shape the edit_asset tool consumes; it is +// built up from Collibra's raw assignment response by GetAssignmentForAssetType. +type EditAssetAssignment struct { + AssetType EditAssetTypeRef `json:"assetType"` + DomainType *EditAssetDomainTypeRef `json:"domainType,omitempty"` + AttributeTypes []EditAssetAssignmentAttributeType `json:"attributeTypes"` + RelationTypes []EditAssetAssignmentRelationType `json:"relationTypes,omitempty"` +} + +// EditAssetAssignmentRelationType is a relation type allowed by a scoped +// assignment, in the direction where the edited asset is the source (head). +// Role is the forward name (e.g. "synonym"); CoRole is the reverse name. +type EditAssetAssignmentRelationType struct { + ID string `json:"id"` + Role string `json:"role"` + CoRole string `json:"coRole,omitempty"` + SourceType *EditAssetTypeRef `json:"sourceType,omitempty"` + TargetType *EditAssetTypeRef `json:"targetType,omitempty"` +} + +// --- Raw assignment response shape (Collibra's wire format) ---------------- + +type rawAssignmentResponse struct { + ID string `json:"id"` + AssetType EditAssetTypeRef `json:"assetType"` + DomainTypes []EditAssetDomainTypeRef `json:"domainTypes"` + CharacteristicTypes []rawAssignmentCharacteristic `json:"characteristicTypes"` +} + +type rawAssignmentCharacteristic struct { + ID string `json:"id"` + MinimumOccurrences int `json:"minimumOccurrences"` + RoleDirection string `json:"roleDirection,omitempty"` + AttributeType *rawAssignmentAttributeType `json:"attributeType,omitempty"` + RelationType *rawAssignmentRelationType `json:"relationType,omitempty"` + AssignedCharacteristicTypeDiscriminator string `json:"assignedCharacteristicTypeDiscriminator"` +} + +type rawAssignmentAttributeType struct { + ID string `json:"id"` + Name string `json:"name"` + PublicID string `json:"publicId,omitempty"` + Kind string `json:"resourceType,omitempty"` +} + +type rawAssignmentRelationType struct { + ID string `json:"id"` + Role string `json:"role"` + CoRole string `json:"coRole,omitempty"` + SourceType *EditAssetTypeRef `json:"sourceType,omitempty"` + TargetType *EditAssetTypeRef `json:"targetType,omitempty"` +} + +// EditAssetAssignmentAttributeType is an attribute type allowed by a scoped +// assignment, with its full name and (optional) constraints. +type EditAssetAssignmentAttributeType struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind,omitempty"` + Required bool `json:"required,omitempty"` + Constraints *EditAssetAssignmentConstraints `json:"constraints,omitempty"` +} + +// EditAssetAssignmentConstraints captures attribute type constraints used to +// validate operation values before any writes. +type EditAssetAssignmentConstraints struct { + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` +} + +// EditAssetPatchRequest is the body for PATCH /rest/2.0/assets/{id} — only the +// fields allowed by update_property (name, displayName, statusId). +type EditAssetPatchRequest struct { + Name *string `json:"name,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + StatusID *string `json:"statusId,omitempty"` +} + +// EditAssetPatchAttributeRequest is the body for PATCH /rest/2.0/attributes/{id}. +type EditAssetPatchAttributeRequest struct { + Value string `json:"value"` +} + +// GetAssetCore fetches the core asset shape needed by the edit_asset tool. +func GetAssetCore(ctx context.Context, client *http.Client, assetID string) (*EditAssetCore, error) { + reqURL := fmt.Sprintf("/rest/2.0/assets/%s", url.PathEscape(assetID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("get asset: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("get asset: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("asset %q not found", assetID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get asset: status %d: %s", resp.StatusCode, string(body)) + } + + var result EditAssetCore + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("get asset: decoding response: %w", err) + } + return &result, nil +} + +// GetDomainDetails fetches a domain including its domain type reference, used +// to scope the assignment lookup. +func GetDomainDetails(ctx context.Context, client *http.Client, domainID string) (*EditAssetDomainDetails, error) { + reqURL := fmt.Sprintf("/rest/2.0/domains/%s", url.PathEscape(domainID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("get domain: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("get domain: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("domain %q not found", domainID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get domain: status %d: %s", resp.StatusCode, string(body)) + } + + var result EditAssetDomainDetails + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("get domain: decoding response: %w", err) + } + return &result, nil +} + +// ListAttributesForAsset fetches all attribute instances on an asset. +// Pages are followed transparently so the caller gets the full list. +func ListAttributesForAsset(ctx context.Context, client *http.Client, assetID string) ([]EditAssetAttributeInstance, error) { + const pageSize = 100 + var all []EditAssetAttributeInstance + offset := 0 + for { + params := url.Values{} + params.Set("assetId", assetID) + params.Set("limit", fmt.Sprintf("%d", pageSize)) + params.Set("offset", fmt.Sprintf("%d", offset)) + + reqURL := fmt.Sprintf("/rest/2.0/attributes?%s", params.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("list attributes: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("list attributes: sending request: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("list attributes: reading response: %w", readErr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list attributes: status %d: %s", resp.StatusCode, string(body)) + } + var page editAssetAttributesList + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("list attributes: decoding response: %w", err) + } + all = append(all, page.Results...) + if len(page.Results) < pageSize || len(all) >= page.Total { + break + } + offset += pageSize + } + return all, nil +} + +// GetAssignmentForAssetType returns the scoped assignment for an (asset type, +// domain type) pair, listing valid attribute and relation types. Collibra's +// endpoint returns an array — typically one entry per matching scope. We +// merge attribute and relation types across the returned entries; if a +// domainTypeID was supplied, we filter to entries that match it (when present +// on the entry), otherwise we use everything returned. +func GetAssignmentForAssetType(ctx context.Context, client *http.Client, assetTypeID, domainTypeID string) (*EditAssetAssignment, error) { + params := url.Values{} + if domainTypeID != "" { + params.Set("domainTypeId", domainTypeID) + } + reqURL := fmt.Sprintf("/rest/2.0/assignments/assetType/%s", url.PathEscape(assetTypeID)) + if q := params.Encode(); q != "" { + reqURL += "?" + q + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("get assignment: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("get assignment: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("no assignment for asset type %q (domain type %q)", assetTypeID, domainTypeID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get assignment: status %d: %s", resp.StatusCode, string(body)) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("get assignment: reading response: %w", err) + } + + // Collibra returns assignments as a top-level array; tolerate a single + // object too just in case. + var list []rawAssignmentResponse + if err := json.Unmarshal(respBody, &list); err != nil { + var single rawAssignmentResponse + if err2 := json.Unmarshal(respBody, &single); err2 != nil { + return nil, fmt.Errorf("get assignment: decoding response: %w", err) + } + list = []rawAssignmentResponse{single} + } + + merged := EditAssetAssignment{ + AssetType: EditAssetTypeRef{ID: assetTypeID}, + } + for _, a := range list { + // If the caller passed a domainTypeID, skip assignments whose + // domainTypes don't include it. + if domainTypeID != "" && len(a.DomainTypes) > 0 { + matched := false + for _, dt := range a.DomainTypes { + if dt.ID == domainTypeID { + matched = true + break + } + } + if !matched { + continue + } + } + if merged.DomainType == nil && len(a.DomainTypes) > 0 { + dt := a.DomainTypes[0] + merged.DomainType = &dt + } + for _, ct := range a.CharacteristicTypes { + switch ct.AssignedCharacteristicTypeDiscriminator { + case "AttributeType": + if ct.AttributeType == nil { + continue + } + merged.AttributeTypes = append(merged.AttributeTypes, EditAssetAssignmentAttributeType{ + ID: ct.AttributeType.ID, + Name: ct.AttributeType.Name, + Kind: ct.AttributeType.Kind, + Required: ct.MinimumOccurrences >= 1, + }) + case "RelationType": + if ct.RelationType == nil { + continue + } + // Only register the direction where the edited asset is the + // source (head). Collibra emits both directions as separate + // characteristic entries; TO_TARGET = forward (asset->target). + if ct.RoleDirection != "" && ct.RoleDirection != "TO_TARGET" { + continue + } + merged.RelationTypes = append(merged.RelationTypes, EditAssetAssignmentRelationType{ + ID: ct.RelationType.ID, + Role: ct.RelationType.Role, + CoRole: ct.RelationType.CoRole, + SourceType: ct.RelationType.SourceType, + TargetType: ct.RelationType.TargetType, + }) + } + } + } + return &merged, nil +} + +// PatchAsset updates the whitelisted core fields (name, displayName, statusId) +// on an asset via PATCH /rest/2.0/assets/{id}. +func PatchAsset(ctx context.Context, client *http.Client, assetID string, payload EditAssetPatchRequest) (*EditAssetCore, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("patch asset: marshaling request: %w", err) + } + reqURL := fmt.Sprintf("/rest/2.0/assets/%s", url.PathEscape(assetID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("patch asset: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("patch asset: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("patch asset: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("patch asset: status %d: %s", resp.StatusCode, string(respBody)) + } + + var result EditAssetCore + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("patch asset: decoding response: %w", err) + } + return &result, nil +} + +// PatchAttributeValue updates a single attribute instance's value via +// PATCH /rest/2.0/attributes/{id}. +func PatchAttributeValue(ctx context.Context, client *http.Client, attributeID, value string) (*EditAssetAttributeInstance, error) { + body, err := json.Marshal(EditAssetPatchAttributeRequest{Value: value}) + if err != nil { + return nil, fmt.Errorf("patch attribute: marshaling request: %w", err) + } + reqURL := fmt.Sprintf("/rest/2.0/attributes/%s", url.PathEscape(attributeID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("patch attribute: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("patch attribute: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("patch attribute: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("patch attribute: status %d: %s", resp.StatusCode, string(respBody)) + } + + var result EditAssetAttributeInstance + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("patch attribute: decoding response: %w", err) + } + return &result, nil +} + +// CreateAttributeOnAsset adds a single attribute instance via POST /rest/2.0/attributes. +// It mirrors CreateAttribute in create_asset_client.go but returns the richer +// EditAssetAttributeInstance shape for diff tracking. +func CreateAttributeOnAsset(ctx context.Context, client *http.Client, assetID, attrTypeID, value string) (*EditAssetAttributeInstance, error) { + body, err := json.Marshal(CreateAttributeRequest{ + AssetID: assetID, + TypeID: attrTypeID, + Value: value, + }) + if err != nil { + return nil, fmt.Errorf("create attribute: marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/attributes", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create attribute: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("create attribute: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("create attribute: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("create attribute: status %d: %s", resp.StatusCode, string(respBody)) + } + + var result EditAssetAttributeInstance + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("create attribute: decoding response: %w", err) + } + return &result, nil +} + +// DeleteAttribute removes a single attribute instance via DELETE /rest/2.0/attributes/{id}. +func DeleteAttribute(ctx context.Context, client *http.Client, attributeID string) error { + reqURL := fmt.Sprintf("/rest/2.0/attributes/%s", url.PathEscape(attributeID)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) + if err != nil { + return fmt.Errorf("delete attribute: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("delete attribute: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete attribute: status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +// EditAssetCreateRelationRequest is the body for POST /rest/2.0/relations. +type EditAssetCreateRelationRequest struct { + SourceID string `json:"sourceId"` + TargetID string `json:"targetId"` + TypeID string `json:"typeId"` +} + +// EditAssetRelation is a relation instance between two assets. +type EditAssetRelation struct { + ID string `json:"id"` + Type EditAssetTypeRef `json:"type"` + Source EditAssetAttributeAssetRef `json:"source"` + Target EditAssetAttributeAssetRef `json:"target"` +} + +// CreateRelation posts a new relation via POST /rest/2.0/relations. The source +// asset is the head; target is the tail. +func CreateRelation(ctx context.Context, client *http.Client, payload EditAssetCreateRelationRequest) (*EditAssetRelation, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("create relation: marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/relations", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create relation: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("create relation: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("create relation: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("create relation: status %d: %s", resp.StatusCode, string(respBody)) + } + + var result EditAssetRelation + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("create relation: decoding response: %w", err) + } + return &result, nil +} + +// EditAssetAddTagsRequest is the body for POST /rest/2.0/assets/{id}/tags. +// Collibra expects the field to be named "tagNames" — sending "tags" is +// silently ignored by the API and yields a "tagNames may not be null" 400. +type EditAssetAddTagsRequest struct { + TagNames []string `json:"tagNames"` +} + +// AddTagsToAsset appends one or more tags to an asset without replacing +// existing tags (incremental, matching the "prefer incremental" AC). +func AddTagsToAsset(ctx context.Context, client *http.Client, assetID string, tags []string) error { + body, err := json.Marshal(EditAssetAddTagsRequest{TagNames: tags}) + if err != nil { + return fmt.Errorf("add tags: marshaling request: %w", err) + } + reqURL := fmt.Sprintf("/rest/2.0/assets/%s/tags", url.PathEscape(assetID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("add tags: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("add tags: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("add tags: status %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// EditAssetRole is a resource role (e.g. Steward, Owner) that can be assigned +// to an asset via responsibilities. +type EditAssetRole struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// EditAssetStatus is a status (e.g. Candidate, Accepted, Obsolete) that can be +// assigned to an asset via update_property statusId. +type EditAssetStatus struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// editAssetStatusesList is the paginated wrapper returned by GET /rest/2.0/statuses. +type editAssetStatusesList struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Results []EditAssetStatus `json:"results"` +} + +// ListStatuses returns all asset statuses defined in Collibra. Used to resolve +// a status name (e.g. "Candidate") to its UUID before patching an asset. +func ListStatuses(ctx context.Context, client *http.Client) ([]EditAssetStatus, error) { + const pageSize = 1000 + var all []EditAssetStatus + offset := 0 + for { + params := url.Values{} + params.Set("limit", fmt.Sprintf("%d", pageSize)) + params.Set("offset", fmt.Sprintf("%d", offset)) + + reqURL := "/rest/2.0/statuses?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("list statuses: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("list statuses: sending request: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("list statuses: reading response: %w", readErr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list statuses: status %d: %s", resp.StatusCode, string(body)) + } + var page editAssetStatusesList + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("list statuses: decoding response: %w", err) + } + all = append(all, page.Results...) + if len(page.Results) < pageSize || len(all) >= page.Total { + break + } + offset += pageSize + } + return all, nil +} + +// editAssetRolesList is the paginated wrapper returned by GET /rest/2.0/roles. +type editAssetRolesList struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Results []EditAssetRole `json:"results"` +} + +// ListRoles returns all resource roles defined in Collibra. Callers use this +// to resolve a role name (e.g. "Steward") to its UUID before creating a +// responsibility. The full list is typically small and fits in a single page. +func ListRoles(ctx context.Context, client *http.Client) ([]EditAssetRole, error) { + const pageSize = 1000 + var all []EditAssetRole + offset := 0 + for { + params := url.Values{} + params.Set("limit", fmt.Sprintf("%d", pageSize)) + params.Set("offset", fmt.Sprintf("%d", offset)) + + reqURL := "/rest/2.0/roles?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("list roles: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("list roles: sending request: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("list roles: reading response: %w", readErr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list roles: status %d: %s", resp.StatusCode, string(body)) + } + var page editAssetRolesList + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("list roles: decoding response: %w", err) + } + all = append(all, page.Results...) + if len(page.Results) < pageSize || len(all) >= page.Total { + break + } + offset += pageSize + } + return all, nil +} + +// EditAssetUser is a Collibra user, used to resolve a username or email +// to the user's UUID before assigning responsibilities. +type EditAssetUser struct { + ID string `json:"id"` + UserName string `json:"userName,omitempty"` + EmailAddress string `json:"emailAddress,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` +} + +// editAssetUsersList is the paginated wrapper returned by GET /rest/2.0/users. +type editAssetUsersList struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Results []EditAssetUser `json:"results"` +} + +// FindUserByUsername returns the first user matching a username, or nil if +// none exists. Used by set_responsibility to resolve "jane.smith" to a UUID. +func FindUserByUsername(ctx context.Context, client *http.Client, username string) (*EditAssetUser, error) { + params := url.Values{} + params.Set("name", username) + params.Set("limit", "1") + return findUserBy(ctx, client, params) +} + +// FindUserByEmail returns the first user matching an email address, or nil +// if none exists. +func FindUserByEmail(ctx context.Context, client *http.Client, email string) (*EditAssetUser, error) { + params := url.Values{} + params.Set("emailAddress", email) + params.Set("limit", "1") + return findUserBy(ctx, client, params) +} + +func findUserBy(ctx context.Context, client *http.Client, params url.Values) (*EditAssetUser, error) { + reqURL := "/rest/2.0/users?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("find user: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("find user: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("find user: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("find user: status %d: %s", resp.StatusCode, string(body)) + } + var page editAssetUsersList + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("find user: decoding response: %w", err) + } + if len(page.Results) == 0 { + return nil, nil + } + user := page.Results[0] + return &user, nil +} + +// EditAssetCreateResponsibilityRequest is the body for POST /rest/2.0/responsibilities. +type EditAssetCreateResponsibilityRequest struct { + RoleID string `json:"roleId"` + OwnerID string `json:"ownerId"` + ResourceID string `json:"resourceId"` +} + +// EditAssetResponsibility is a responsibility instance linking a role, an +// owner (user or group), and an asset. +type EditAssetResponsibility struct { + ID string `json:"id"` + RoleID string `json:"roleId,omitempty"` + OwnerID string `json:"ownerId,omitempty"` + ResourceID string `json:"resourceId,omitempty"` +} + +// CreateResponsibility assigns a role to an owner for an asset via +// POST /rest/2.0/responsibilities. This is incremental — it doesn't replace +// other responsibilities on the asset. +func CreateResponsibility(ctx context.Context, client *http.Client, payload EditAssetCreateResponsibilityRequest) (*EditAssetResponsibility, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("create responsibility: marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/responsibilities", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create responsibility: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("create responsibility: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("create responsibility: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("create responsibility: status %d: %s", resp.StatusCode, string(respBody)) + } + + var result EditAssetResponsibility + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("create responsibility: decoding response: %w", err) + } + return &result, nil +} + +// EditAssetBulkPatchAttributeItem is one row of PATCH /rest/2.0/attributes/bulk. +type EditAssetBulkPatchAttributeItem struct { + ID string `json:"id"` + Value string `json:"value"` +} + +// BulkCreateAttributes creates multiple attribute instances in one round trip +// via POST /rest/2.0/attributes/bulk. Treated as all-or-nothing: if the whole +// batch fails, the caller marks every affected op as failed with the batch +// error. Partial-row failures from Collibra aren't parsed individually. +func BulkCreateAttributes(ctx context.Context, client *http.Client, items []CreateAttributeRequest) ([]EditAssetAttributeInstance, error) { + body, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("bulk create attributes: marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/attributes/bulk", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("bulk create attributes: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("bulk create attributes: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("bulk create attributes: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bulk create attributes: status %d: %s", resp.StatusCode, string(respBody)) + } + var result []EditAssetAttributeInstance + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("bulk create attributes: decoding response: %w", err) + } + return result, nil +} + +// BulkPatchAttributes updates multiple attribute instances in one round trip +// via PATCH /rest/2.0/attributes/bulk. All-or-nothing, same rationale as +// BulkCreateAttributes. +func BulkPatchAttributes(ctx context.Context, client *http.Client, items []EditAssetBulkPatchAttributeItem) ([]EditAssetAttributeInstance, error) { + body, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("bulk patch attributes: marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, "/rest/2.0/attributes/bulk", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("bulk patch attributes: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("bulk patch attributes: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("bulk patch attributes: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bulk patch attributes: status %d: %s", resp.StatusCode, string(respBody)) + } + var result []EditAssetAttributeInstance + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("bulk patch attributes: decoding response: %w", err) + } + return result, nil +} + +// BulkCreateRelations creates multiple relations in one round trip via +// POST /rest/2.0/relations/bulk. +func BulkCreateRelations(ctx context.Context, client *http.Client, items []EditAssetCreateRelationRequest) ([]EditAssetRelation, error) { + body, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("bulk create relations: marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/relations/bulk", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("bulk create relations: building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("bulk create relations: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("bulk create relations: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bulk create relations: status %d: %s", resp.StatusCode, string(respBody)) + } + var result []EditAssetRelation + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("bulk create relations: decoding response: %w", err) + } + return result, nil +} + +// DeleteRelation removes a relation via DELETE /rest/2.0/relations/{id}. +func DeleteRelation(ctx context.Context, client *http.Client, relationID string) error { + reqURL := fmt.Sprintf("/rest/2.0/relations/%s", url.PathEscape(relationID)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) + if err != nil { + return fmt.Errorf("delete relation: building request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("delete relation: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("relation %q not found", relationID) + } + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete relation: status %d: %s", resp.StatusCode, string(body)) + } + return nil +} diff --git a/pkg/clients/edit_asset_client_test.go b/pkg/clients/edit_asset_client_test.go new file mode 100644 index 0000000..6c0589b --- /dev/null +++ b/pkg/clients/edit_asset_client_test.go @@ -0,0 +1,70 @@ +package clients + +import ( + "encoding/json" + "testing" +) + +// TestEditAssetAttributeInstance_ValueAcceptsAnyScalar covers Collibra's +// behavior of returning attribute values typed by their attribute kind: +// strings come back quoted, numbers as numeric literals, booleans as bare +// true/false, and unset values as null. The unmarshaler must accept all of +// these and present them as a single printable string to consumers. +func TestEditAssetAttributeInstance_ValueAcceptsAnyScalar(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + {"string value", `{"id":"a","value":"hello"}`, "hello"}, + {"empty string value", `{"id":"a","value":""}`, ""}, + {"numeric value (int)", `{"id":"a","value":42}`, "42"}, + {"numeric value (float)", `{"id":"a","value":3.14}`, "3.14"}, + {"boolean value true", `{"id":"a","value":true}`, "true"}, + {"boolean value false", `{"id":"a","value":false}`, "false"}, + {"null value", `{"id":"a","value":null}`, ""}, + {"missing value field", `{"id":"a"}`, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got EditAssetAttributeInstance + if err := json.Unmarshal([]byte(tc.raw), &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Value != tc.want { + t.Fatalf("Value = %q, want %q", got.Value, tc.want) + } + }) + } +} + +// TestEditAssetAttributeInstance_ListPageDecodesMixedKinds covers the path +// the edit_asset tool actually hits: a paginated /rest/2.0/attributes?assetId= +// response with attribute values of mixed kinds in the same payload. +// Reproduces the bug reported in DEV-177761 where a numeric attribute on a +// freshly-created asset broke the entire attribute fetch. +func TestEditAssetAttributeInstance_ListPageDecodesMixedKinds(t *testing.T) { + raw := `{ + "total": 3, + "offset": 0, + "limit": 100, + "results": [ + {"id":"a1","type":{"id":"t1","name":"Definition"},"asset":{"id":"x"},"value":"Some text"}, + {"id":"a2","type":{"id":"t2","name":"Row Count"},"asset":{"id":"x"},"value":12345}, + {"id":"a3","type":{"id":"t3","name":"Is Public"},"asset":{"id":"x"},"value":true} + ] + }` + var page editAssetAttributesList + if err := json.Unmarshal([]byte(raw), &page); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(page.Results) != 3 { + t.Fatalf("expected 3 results, got %d", len(page.Results)) + } + wantValues := []string{"Some text", "12345", "true"} + for i, w := range wantValues { + if page.Results[i].Value != w { + t.Fatalf("results[%d].Value = %q, want %q", i, page.Results[i].Value, w) + } + } +} diff --git a/pkg/tools/edit_asset/bulk.go b/pkg/tools/edit_asset/bulk.go new file mode 100644 index 0000000..acc97b4 --- /dev/null +++ b/pkg/tools/edit_asset/bulk.go @@ -0,0 +1,159 @@ +package edit_asset + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/clients" +) + +// bulkThreshold is the minimum number of same-type ops that triggers a bulk +// endpoint call instead of per-op individual requests. Keeping this at 2 means +// a single add/update/relation still uses the cheap individual endpoint and +// 2+ amortize a single round trip. +const bulkThreshold = 2 + +// executeValidPlans runs every plan whose validation step succeeded. It groups +// bulk-eligible ops by type and fires a single bulk request per group; any +// remaining ops (or groups below bulkThreshold) fall through to individual +// execution via executePlan. Order of results is preserved via in-place +// updates to plans. +func executeValidPlans(ctx context.Context, client *http.Client, ec *editContext, plans []opPlan) { + // Collect indices of valid plans grouped by bulk-eligible op type. + var addAttrIdx, updAttrIdx, addRelIdx []int + for i, p := range plans { + if p.result.Status == "error" { + continue + } + switch p.op.Type { + case OpAddAttribute: + addAttrIdx = append(addAttrIdx, i) + case OpUpdateAttribute: + updAttrIdx = append(updAttrIdx, i) + case OpAddRelation: + addRelIdx = append(addRelIdx, i) + } + } + + // Bulk-eligible groups: dispatch as bulk if at-or-above threshold. + bulked := map[int]bool{} + if len(addAttrIdx) >= bulkThreshold { + executeBulkAddAttributes(ctx, client, ec, plans, addAttrIdx) + for _, i := range addAttrIdx { + bulked[i] = true + } + } + if len(updAttrIdx) >= bulkThreshold { + executeBulkUpdateAttributes(ctx, client, plans, updAttrIdx) + for _, i := range updAttrIdx { + bulked[i] = true + } + } + if len(addRelIdx) >= bulkThreshold { + executeBulkAddRelations(ctx, client, ec, plans, addRelIdx) + for _, i := range addRelIdx { + bulked[i] = true + } + } + + // Everything not bulked runs through the per-op executor. + for i := range plans { + if plans[i].result.Status == "error" || bulked[i] { + continue + } + plans[i] = executePlan(ctx, client, ec, plans[i]) + } +} + +// executeBulkAddAttributes issues POST /rest/2.0/attributes/bulk for every +// add_attribute plan in indices. On batch failure every op in the batch gets +// the same error message. +func executeBulkAddAttributes(ctx context.Context, client *http.Client, ec *editContext, plans []opPlan, indices []int) { + reqs := make([]clients.CreateAttributeRequest, len(indices)) + for j, idx := range indices { + reqs[j] = clients.CreateAttributeRequest{ + AssetID: ec.asset.ID, + TypeID: plans[idx].attributeTypeID, + Value: plans[idx].op.Value, + } + } + + created, err := clients.BulkCreateAttributes(ctx, client, reqs) + if err != nil { + for _, idx := range indices { + plans[idx].result = newErrorResult(plans[idx].op, err.Error()) + } + return + } + // Collibra's bulk endpoint returns results in input order. + for j, idx := range indices { + res := newSuccessResult(plans[idx].op) + if j < len(created) { + res.NewValue = created[j].Value + } else { + res.NewValue = plans[idx].op.Value + } + plans[idx].result = res + } +} + +// executeBulkUpdateAttributes issues PATCH /rest/2.0/attributes/bulk for every +// update_attribute plan in indices. +func executeBulkUpdateAttributes(ctx context.Context, client *http.Client, plans []opPlan, indices []int) { + reqs := make([]clients.EditAssetBulkPatchAttributeItem, len(indices)) + for j, idx := range indices { + reqs[j] = clients.EditAssetBulkPatchAttributeItem{ + ID: plans[idx].targetAttributeID, + Value: plans[idx].op.Value, + } + } + + updated, err := clients.BulkPatchAttributes(ctx, client, reqs) + if err != nil { + for _, idx := range indices { + plans[idx].result = newErrorResult(plans[idx].op, err.Error()) + } + return + } + for j, idx := range indices { + res := OperationResult{ + Operation: plans[idx].op.Type, + Status: "success", + AttributeName: plans[idx].op.AttributeName, + PreviousValue: plans[idx].previousValue, + } + if j < len(updated) { + res.NewValue = updated[j].Value + } else { + res.NewValue = plans[idx].op.Value + } + plans[idx].result = res + } +} + +// executeBulkAddRelations issues POST /rest/2.0/relations/bulk. +func executeBulkAddRelations(ctx context.Context, client *http.Client, ec *editContext, plans []opPlan, indices []int) { + reqs := make([]clients.EditAssetCreateRelationRequest, len(indices)) + for j, idx := range indices { + reqs[j] = clients.EditAssetCreateRelationRequest{ + SourceID: ec.asset.ID, + TargetID: plans[idx].op.TargetAssetID, + TypeID: plans[idx].relationTypeID, + } + } + + created, err := clients.BulkCreateRelations(ctx, client, reqs) + if err != nil { + for _, idx := range indices { + plans[idx].result = newErrorResult(plans[idx].op, err.Error()) + } + return + } + for j, idx := range indices { + res := newSuccessResult(plans[idx].op) + if j < len(created) { + res.RelationID = created[j].ID + } + plans[idx].result = res + } +} diff --git a/pkg/tools/edit_asset/operations.go b/pkg/tools/edit_asset/operations.go new file mode 100644 index 0000000..7de581f --- /dev/null +++ b/pkg/tools/edit_asset/operations.go @@ -0,0 +1,411 @@ +package edit_asset + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/validation" + "github.com/google/uuid" +) + +// --- update_attribute --------------------------------------------------------- + +func validateUpdateAttribute(ec *editContext, plan opPlan) opPlan { + op := plan.op + if strings.TrimSpace(op.AttributeName) == "" { + plan.result = newErrorResult(op, "attributeName is required for update_attribute") + return plan + } + key := normalize(op.AttributeName) + attrType, ok := ec.attributeTypeByName[key] + if !ok { + plan.result = newErrorResult(op, fmt.Sprintf( + "attribute %q is not valid for asset type %q in this domain.%s", + op.AttributeName, ec.asset.Type.Name, + suggestionSuffix("Attributes", ec.availableAttributeNames(), 10))) + return plan + } + instances := ec.attributesByTypeName[key] + switch len(instances) { + case 0: + plan.result = newErrorResult(op, fmt.Sprintf("no existing %q attribute to update on this asset (use add_attribute)", op.AttributeName)) + return plan + case 1: + plan.targetAttributeID = instances[0].ID + plan.previousValue = instances[0].Value + default: + plan.result = newErrorResult(op, fmt.Sprintf("%d %q attributes on this asset — cannot disambiguate by name alone", len(instances), op.AttributeName)) + return plan + } + if err := validateAttributeValue(attrType, op.Value); err != nil { + plan.result = newErrorResult(op, err.Error()) + return plan + } + plan.result = newSuccessResult(op) + return plan +} + +func executeUpdateAttribute(ctx context.Context, client *http.Client, plan opPlan) opPlan { + updated, err := clients.PatchAttributeValue(ctx, client, plan.targetAttributeID, plan.op.Value) + if err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + plan.result = OperationResult{ + Operation: plan.op.Type, + Status: "success", + AttributeName: plan.op.AttributeName, + PreviousValue: plan.previousValue, + NewValue: updated.Value, + } + return plan +} + +// --- add_attribute ------------------------------------------------------------ + +func validateAddAttribute(ec *editContext, plan opPlan) opPlan { + op := plan.op + if strings.TrimSpace(op.AttributeName) == "" { + plan.result = newErrorResult(op, "attributeName is required for add_attribute") + return plan + } + attrType, ok := ec.attributeTypeByName[normalize(op.AttributeName)] + if !ok { + plan.result = newErrorResult(op, fmt.Sprintf( + "attribute %q is not valid for asset type %q in this domain.%s", + op.AttributeName, ec.asset.Type.Name, + suggestionSuffix("Attributes", ec.availableAttributeNames(), 10))) + return plan + } + if err := validateAttributeValue(attrType, op.Value); err != nil { + plan.result = newErrorResult(op, err.Error()) + return plan + } + plan.attributeTypeID = attrType.ID + plan.result = newSuccessResult(op) + return plan +} + +func executeAddAttribute(ctx context.Context, client *http.Client, ec *editContext, plan opPlan) opPlan { + created, err := clients.CreateAttributeOnAsset(ctx, client, ec.asset.ID, plan.attributeTypeID, plan.op.Value) + if err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + plan.result = OperationResult{ + Operation: plan.op.Type, + Status: "success", + AttributeName: plan.op.AttributeName, + NewValue: created.Value, + } + return plan +} + +// --- remove_attribute --------------------------------------------------------- + +func validateRemoveAttribute(ec *editContext, plan opPlan) opPlan { + op := plan.op + if strings.TrimSpace(op.AttributeName) == "" { + plan.result = newErrorResult(op, "attributeName is required for remove_attribute") + return plan + } + instances := ec.attributesByTypeName[normalize(op.AttributeName)] + switch len(instances) { + case 0: + plan.result = newErrorResult(op, fmt.Sprintf("no %q attribute to remove on this asset", op.AttributeName)) + return plan + case 1: + plan.targetAttributeID = instances[0].ID + plan.previousValue = instances[0].Value + default: + plan.result = newErrorResult(op, fmt.Sprintf("%d %q attributes on this asset — cannot disambiguate by name alone", len(instances), op.AttributeName)) + return plan + } + plan.result = newSuccessResult(op) + return plan +} + +func executeRemoveAttribute(ctx context.Context, client *http.Client, _ *editContext, plan opPlan) opPlan { + if err := clients.DeleteAttribute(ctx, client, plan.targetAttributeID); err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + plan.result = OperationResult{ + Operation: plan.op.Type, + Status: "success", + AttributeName: plan.op.AttributeName, + PreviousValue: plan.previousValue, + } + return plan +} + +// --- update_property ---------------------------------------------------------- + +func validateUpdateProperty(ec *editContext, plan opPlan) opPlan { + op := plan.op + switch op.Field { + case PropertyName: + if strings.TrimSpace(op.Value) == "" { + plan.result = newErrorResult(op, "value is required for update_property name") + return plan + } + // Patch is built at execute time, not here, because the cascade + // decision (whether to also PATCH displayName) reads ec.asset's + // current state — which may have changed if an earlier op in the + // same batch updated displayName. + case PropertyDisplayName: + v := op.Value + plan.propertyPatch = clients.EditAssetPatchRequest{DisplayName: &v} + case PropertyStatusID: + if strings.TrimSpace(op.Value) == "" { + plan.result = newErrorResult(op, "value is required for update_property statusId") + return plan + } + // Accept either a UUID or a human-friendly status name. If the value + // isn't a UUID, look it up by name in the pre-fetched statuses map. + statusID := op.Value + if _, parseErr := uuid.Parse(op.Value); parseErr != nil { + st, ok := ec.statusByName[normalize(op.Value)] + if !ok { + plan.result = newErrorResult(op, fmt.Sprintf( + "status %q is not defined in Collibra.%s", + op.Value, suggestionSuffix("Statuses", ec.availableStatusNames(), 10))) + return plan + } + statusID = st.ID + } + plan.propertyPatch = clients.EditAssetPatchRequest{StatusID: &statusID} + case "": + plan.result = newErrorResult(op, "field is required for update_property (one of: name, displayName, statusId)") + return plan + default: + plan.result = newErrorResult(op, fmt.Sprintf("field %q is not supported for update_property; allowed: name, displayName, statusId", op.Field)) + return plan + } + plan.result = newSuccessResult(op) + return plan +} + +func executeUpdateProperty(ctx context.Context, client *http.Client, ec *editContext, plan opPlan) opPlan { + patch := plan.propertyPatch + cascadedDisplayName := false + if plan.op.Field == PropertyName { + // Auto-cascade displayName when it tracks name (Collibra's + // create-time default). Skip when displayName has diverged from + // name — that means the user explicitly customised the visible + // label and we should not silently overwrite it. + v := plan.op.Value + patch = clients.EditAssetPatchRequest{Name: &v} + if ec.asset.DisplayName != "" && ec.asset.DisplayName == ec.asset.Name { + patch.DisplayName = &v + cascadedDisplayName = true + } + } + + updated, err := clients.PatchAsset(ctx, client, ec.asset.ID, patch) + if err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + + prev, next := previousAndNewProperty(ec.asset, updated, plan.op.Field) + plan.result = OperationResult{ + Operation: plan.op.Type, + Status: "success", + Field: plan.op.Field, + PreviousValue: prev, + NewValue: next, + CascadedDisplayName: cascadedDisplayName, + } + // Keep our in-memory snapshot current for subsequent ops in the same request. + ec.asset = updated + return plan +} + +func previousAndNewProperty(before, after *clients.EditAssetCore, field string) (string, string) { + switch field { + case PropertyName: + return before.Name, after.Name + case PropertyDisplayName: + return before.DisplayName, after.DisplayName + case PropertyStatusID: + var prev, next string + if before.Status != nil { + prev = before.Status.ID + } + if after.Status != nil { + next = after.Status.ID + } + return prev, next + } + return "", "" +} + +// --- add_relation ------------------------------------------------------------- + +func validateAddRelation(ec *editContext, plan opPlan) opPlan { + op := plan.op + if strings.TrimSpace(op.RelationType) == "" { + plan.result = newErrorResult(op, "relationType is required for add_relation") + return plan + } + if err := validation.UUID("targetAssetId", op.TargetAssetID); err != nil { + plan.result = newErrorResult(op, err.Error()) + return plan + } + rt, ok := ec.relationTypeByRole[normalize(op.RelationType)] + if !ok { + plan.result = newErrorResult(op, fmt.Sprintf( + "relation type %q is not valid for asset type %q in this domain (edited asset must be the source/head; try the forward role name).%s", + op.RelationType, ec.asset.Type.Name, + suggestionSuffix("Relation roles", ec.availableRelationRoles(), 10))) + return plan + } + plan.relationTypeID = rt.ID + plan.result = newSuccessResult(op) + return plan +} + +func executeAddRelation(ctx context.Context, client *http.Client, ec *editContext, plan opPlan) opPlan { + created, err := clients.CreateRelation(ctx, client, clients.EditAssetCreateRelationRequest{ + SourceID: ec.asset.ID, + TargetID: plan.op.TargetAssetID, + TypeID: plan.relationTypeID, + }) + if err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + res := newSuccessResult(plan.op) + res.RelationID = created.ID + plan.result = res + return plan +} + +// --- remove_relation ---------------------------------------------------------- + +func validateRemoveRelation(plan opPlan) opPlan { + op := plan.op + if err := validation.UUID("relationId", op.RelationID); err != nil { + plan.result = newErrorResult(op, err.Error()) + return plan + } + plan.result = newSuccessResult(op) + return plan +} + +func executeRemoveRelation(ctx context.Context, client *http.Client, plan opPlan) opPlan { + if err := clients.DeleteRelation(ctx, client, plan.op.RelationID); err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + plan.result = newSuccessResult(plan.op) + return plan +} + +// --- add_tag ------------------------------------------------------------------ + +func validateAddTag(plan opPlan) opPlan { + op := plan.op + if strings.TrimSpace(op.Tag) == "" { + plan.result = newErrorResult(op, "tag is required for add_tag") + return plan + } + plan.result = newSuccessResult(op) + return plan +} + +func executeAddTag(ctx context.Context, client *http.Client, ec *editContext, plan opPlan) opPlan { + if err := clients.AddTagsToAsset(ctx, client, ec.asset.ID, []string{plan.op.Tag}); err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + res := newSuccessResult(plan.op) + res.NewValue = plan.op.Tag + plan.result = res + return plan +} + +// --- set_responsibility ------------------------------------------------------- + +func validateSetResponsibility(ec *editContext, plan opPlan) opPlan { + op := plan.op + if strings.TrimSpace(op.Role) == "" { + plan.result = newErrorResult(op, "role is required for set_responsibility") + return plan + } + if strings.TrimSpace(op.UserID) == "" { + plan.result = newErrorResult(op, "userId is required for set_responsibility (UUID, username, or email)") + return plan + } + role, ok := ec.roleByName[normalize(op.Role)] + if !ok { + plan.result = newErrorResult(op, fmt.Sprintf( + "role %q is not defined in Collibra.%s", + op.Role, suggestionSuffix("Roles", ec.availableRoleNames(), 10))) + return plan + } + plan.roleID = role.ID + plan.result = newSuccessResult(op) + return plan +} + +func executeSetResponsibility(ctx context.Context, client *http.Client, ec *editContext, plan opPlan) opPlan { + // Resolve userId at execution time. UUIDs pass through; emails go to + // the email lookup; anything else is treated as a username. Failures + // surface as per-op errors. + ownerID := plan.op.UserID + if _, parseErr := uuid.Parse(plan.op.UserID); parseErr != nil { + var ( + user *clients.EditAssetUser + err error + ) + if strings.Contains(plan.op.UserID, "@") { + user, err = clients.FindUserByEmail(ctx, client, plan.op.UserID) + } else { + user, err = clients.FindUserByUsername(ctx, client, plan.op.UserID) + } + if err != nil { + plan.result = newErrorResult(plan.op, fmt.Sprintf("resolving user %q: %s", plan.op.UserID, err.Error())) + return plan + } + if user == nil { + plan.result = newErrorResult(plan.op, fmt.Sprintf("no user found matching %q (try the user's username, email, or UUID)", plan.op.UserID)) + return plan + } + ownerID = user.ID + } + + created, err := clients.CreateResponsibility(ctx, client, clients.EditAssetCreateResponsibilityRequest{ + RoleID: plan.roleID, + OwnerID: ownerID, + ResourceID: ec.asset.ID, + }) + if err != nil { + plan.result = newErrorResult(plan.op, err.Error()) + return plan + } + res := newSuccessResult(plan.op) + res.NewValue = created.ID + plan.result = res + return plan +} + +// --- shared helpers ----------------------------------------------------------- + +func validateAttributeValue(attrType clients.EditAssetAssignmentAttributeType, value string) error { + c := attrType.Constraints + if c == nil { + return nil + } + if c.MinLength != nil && len(value) < *c.MinLength { + return fmt.Errorf("value for %q is shorter than minimum length %d", attrType.Name, *c.MinLength) + } + if c.MaxLength != nil && len(value) > *c.MaxLength { + return fmt.Errorf("value for %q exceeds maximum length %d", attrType.Name, *c.MaxLength) + } + return nil +} diff --git a/pkg/tools/edit_asset/tool.go b/pkg/tools/edit_asset/tool.go new file mode 100644 index 0000000..1e7da20 --- /dev/null +++ b/pkg/tools/edit_asset/tool.go @@ -0,0 +1,504 @@ +// Package edit_asset implements the edit_asset MCP tool: a single entry point +// for updating properties, attributes, relations, responsibilities, and tags +// on any existing Collibra asset via a typed list of operations. The MCP +// server resolves names to IDs internally and validates each operation against +// the asset's scoped assignment before executing any writes, so the calling +// agent never needs to know which REST endpoint to hit. +package edit_asset + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/validation" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// normalize lowercases and trims whitespace, used as a key for all +// human-name lookups (attributes, roles, statuses, relation roles). +// Collibra rarely distinguishes names by case in practice, so a forgiving +// match prevents a class of LLM-typos. +func normalize(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// suggestionSuffix renders a short list of valid names to append to a +// "not valid" error so the calling model can self-correct in one step +// instead of round-tripping through another tool. +func suggestionSuffix(label string, names []string, max int) string { + if len(names) == 0 { + return "" + } + sort.Strings(names) + if len(names) <= max { + return fmt.Sprintf(" %s available: %s.", label, strings.Join(names, ", ")) + } + return fmt.Sprintf(" %s available: %s (and %d more).", label, strings.Join(names[:max], ", "), len(names)-max) +} + +// OperationType enumerates the kinds of edits edit_asset can perform. Phases 2+ +// add add_relation, remove_relation, add_tag, set_responsibility. +type OperationType string + +const ( + OpUpdateAttribute OperationType = "update_attribute" + OpAddAttribute OperationType = "add_attribute" + OpRemoveAttribute OperationType = "remove_attribute" + OpUpdateProperty OperationType = "update_property" + OpAddRelation OperationType = "add_relation" + OpRemoveRelation OperationType = "remove_relation" + OpAddTag OperationType = "add_tag" + OpSetResponsibility OperationType = "set_responsibility" +) + +// Whitelisted fields for update_property. Keeping this narrow avoids letting +// the agent PATCH fields that require a different flow (typeId, domainId). +const ( + PropertyName = "name" + PropertyDisplayName = "displayName" + PropertyStatusID = "statusId" +) + +// Input is the tool's typed input. +type Input struct { + AssetID string `json:"assetId" jsonschema:"Required. UUID of the asset to edit."` + Operations []Operation `json:"operations" jsonschema:"Required. Non-empty list of operations to apply. Each operation's type selects which additional fields are used (see Operation)."` +} + +// Operation is a discriminated union: the 'type' field selects which other +// fields are interpreted. Unused fields are ignored. Server-side validation +// catches missing or incompatible fields and returns a per-operation error. +type Operation struct { + Type OperationType `json:"type" jsonschema:"Required. One of: update_attribute, add_attribute, remove_attribute, update_property, add_relation, remove_relation. (Phase 3: add_tag, set_responsibility.)"` + + // Attribute ops — used by update_attribute, add_attribute, remove_attribute. + AttributeName string `json:"attributeName,omitempty" jsonschema:"Attribute type name (e.g. 'Definition', 'Note'). Used by update_attribute, add_attribute, remove_attribute. The server resolves this to the attribute type UUID via the asset's scoped assignment."` + Value string `json:"value,omitempty" jsonschema:"New value. Used by update_attribute, add_attribute, and update_property."` + + // update_property — whitelisted fields only. + Field string `json:"field,omitempty" jsonschema:"For update_property: one of 'name', 'displayName', 'statusId'. When field is 'statusId', value may be either the status UUID or the status name (e.g. 'Candidate', 'Accepted'); the server resolves names automatically. When field is 'name' and the asset's current displayName equals its current name (Collibra's create-time default), displayName is also updated to the new value so the user-facing label stays in sync — set field=displayName separately if the user has already customized it differently."` + + // Relation ops. + RelationType string `json:"relationType,omitempty" jsonschema:"For add_relation: the forward role name of the relation type (e.g. 'is synonym of'). The edited asset is assumed to be the source (head) of the relation; if the named relation type expects the opposite direction, Collibra will return an error."` + TargetAssetID string `json:"targetAssetId,omitempty" jsonschema:"For add_relation: UUID of the asset on the target (tail) side of the relation."` + RelationID string `json:"relationId,omitempty" jsonschema:"For remove_relation: UUID of the relation instance to delete."` + + // Tag op — appends a tag to the asset (does not replace existing tags). + Tag string `json:"tag,omitempty" jsonschema:"For add_tag: a free-text tag to append to the asset (e.g. 'finance'). Existing tags are preserved."` + + // Responsibility op. + Role string `json:"role,omitempty" jsonschema:"For set_responsibility: resource role name (e.g. 'Steward', 'Owner'). The server resolves this to the role UUID."` + UserID string `json:"userId,omitempty" jsonschema:"For set_responsibility: identifies the user (or user group) to assign to the role. Accepts a UUID, a username (e.g. 'jane.smith'), or an email address (e.g. 'jane@example.com'). Names are resolved server-side via /rest/2.0/users."` +} + +// OutputStatus summarises the result of the call. +type OutputStatus string + +const ( + StatusSuccess OutputStatus = "success" + StatusPartialSuccess OutputStatus = "partial_success" + StatusError OutputStatus = "error" +) + +// Output is the tool's typed output. +type Output struct { + Status OutputStatus `json:"status" jsonschema:"Overall status: success if every operation applied, partial_success if some succeeded and some failed, error if every operation failed or the request could not be executed."` + Results []OperationResult `json:"results" jsonschema:"Per-operation outcomes, in the same order as the input operations."` + Asset *AssetSummary `json:"asset,omitempty" jsonschema:"The asset's state after applying successful operations. Present on success or partial_success."` + Error string `json:"error,omitempty" jsonschema:"Populated only when the overall request could not start (e.g. the asset was not found). Per-operation errors live in Results."` +} + +// AssetSummary is the post-edit snapshot of the asset. +type AssetSummary struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Type string `json:"type"` + Domain string `json:"domain"` + Status string `json:"status,omitempty"` +} + +// OperationResult is the outcome of a single operation in the input array. +type OperationResult struct { + Operation OperationType `json:"operation"` + Status string `json:"status" jsonschema:"'success' or 'error'."` + AttributeName string `json:"attributeName,omitempty"` + Field string `json:"field,omitempty"` + RelationType string `json:"relationType,omitempty"` + RelationID string `json:"relationId,omitempty"` + TargetAssetID string `json:"targetAssetId,omitempty"` + Tag string `json:"tag,omitempty"` + Role string `json:"role,omitempty"` + UserID string `json:"userId,omitempty"` + PreviousValue string `json:"previousValue,omitempty"` + NewValue string `json:"newValue,omitempty"` + CascadedDisplayName bool `json:"cascadedDisplayName,omitempty" jsonschema:"True when update_property field=name also updated displayName because the asset's previous displayName matched its previous name (Collibra's create-time default). Only set on update_property results."` + Error string `json:"error,omitempty"` +} + +// NewTool returns the registered tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "edit_asset", + Description: "Edit an existing Collibra asset by submitting a list of typed operations against a single assetId. " + + "Supported operations: " + + "update_attribute / add_attribute / remove_attribute (change, append, or clear an attribute value such as 'Definition' or 'Note', identified by attribute type name); " + + "update_property (whitelisted fields only: 'name' to rename — also updates displayName when it tracks the current name, so the user-facing label stays in sync; 'displayName' to change the display name; or 'statusId' which accepts either a status UUID or a status name like 'Candidate'/'Accepted'); " + + "add_relation / remove_relation (link or unlink the asset to another asset; add_relation takes a forward role name like 'is synonym of' plus the target assetId, remove_relation takes the relation instance UUID); " + + "add_tag (append a free-text tag without replacing existing tags); " + + "set_responsibility (assign a user or group to a resource role such as 'Steward' or 'Owner'; the user can be given as a UUID, username, or email). " + + "Names (attribute names, relation roles, status names, resource role names, and user identifiers) are resolved server-side and matching is case- and whitespace-insensitive. " + + "Each operation is validated against the asset's scoped assignment before any writes; invalid ops return per-operation errors while valid siblings still apply, yielding status=success, partial_success, or error. " + + "On success the response includes a post-edit snapshot of the asset and per-operation before/after values.", + Handler: handler(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{DestructiveHint: chip.Ptr(true)}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if err := validation.UUID("assetId", input.AssetID); err != nil { + return Output{}, err + } + if len(input.Operations) == 0 { + return Output{}, fmt.Errorf("operations must not be empty") + } + + ec, err := newEditContext(ctx, collibraClient, input.AssetID, input.Operations) + if err != nil { + return Output{Status: StatusError, Error: err.Error()}, nil + } + + // Two-phase execution: validate every op first, then run the ones that + // passed. Per-op validation errors become per-op results (partial_success) + // rather than failing the whole request. executeValidPlans groups + // bulk-eligible ops (2+ of the same type) into single bulk requests + // where Collibra supports them. + plans := make([]opPlan, len(input.Operations)) + for i, op := range input.Operations { + plans[i] = validateOperation(ec, op) + } + executeValidPlans(ctx, collibraClient, ec, plans) + + results := make([]OperationResult, len(plans)) + successes := 0 + for i, plan := range plans { + results[i] = plan.result + if plan.result.Status == "success" { + successes++ + } + } + + out := Output{Results: results} + switch { + case successes == len(plans): + out.Status = StatusSuccess + case successes == 0: + out.Status = StatusError + default: + out.Status = StatusPartialSuccess + } + + // Re-fetch the asset to return an authoritative post-edit snapshot. If + // the re-fetch fails we still return the per-op results — don't mask a + // partial success with a read error. + if successes > 0 { + if updated, err := clients.GetAssetCore(ctx, collibraClient, input.AssetID); err == nil { + out.Asset = summariseAsset(updated) + } + } else { + out.Asset = summariseAsset(ec.asset) + } + + return out, nil + } +} + +// editContext holds the pre-fetched state that every operation consults. +type editContext struct { + asset *clients.EditAssetCore + attributes []clients.EditAssetAttributeInstance + assignment *clients.EditAssetAssignment + attributeTypeByName map[string]clients.EditAssetAssignmentAttributeType + attributesByTypeName map[string][]clients.EditAssetAttributeInstance + relationTypeByRole map[string]clients.EditAssetAssignmentRelationType + // roleByName is populated only when the request contains at least one + // set_responsibility op, saving a GET on calls that don't need roles. + roleByName map[string]clients.EditAssetRole + // statusByName is populated only when the request contains an + // update_property op with field=statusId, so plain attribute/relation + // edits don't pay for a /statuses fetch. + statusByName map[string]clients.EditAssetStatus +} + +// newEditContext fetches the asset, its current attributes, and the scoped +// assignment in one go so per-operation validation can be cheap and consistent. +// Roles are fetched lazily — only when the request contains a set_responsibility op. +func newEditContext(ctx context.Context, client *http.Client, assetID string, ops []Operation) (*editContext, error) { + asset, err := clients.GetAssetCore(ctx, client, assetID) + if err != nil { + return nil, err + } + + attrs, err := clients.ListAttributesForAsset(ctx, client, assetID) + if err != nil { + return nil, fmt.Errorf("fetching current attributes: %w", err) + } + + domain, err := clients.GetDomainDetails(ctx, client, asset.Domain.ID) + if err != nil { + return nil, fmt.Errorf("fetching domain for scoped assignment: %w", err) + } + var domainTypeID string + if domain.Type != nil { + domainTypeID = domain.Type.ID + } + + assignment, err := clients.GetAssignmentForAssetType(ctx, client, asset.Type.ID, domainTypeID) + if err != nil { + return nil, fmt.Errorf("fetching scoped assignment: %w", err) + } + + byName := make(map[string]clients.EditAssetAssignmentAttributeType, len(assignment.AttributeTypes)) + for _, at := range assignment.AttributeTypes { + byName[normalize(at.Name)] = at + } + + attrsByTypeName := make(map[string][]clients.EditAssetAttributeInstance) + for _, a := range attrs { + key := normalize(a.Type.Name) + attrsByTypeName[key] = append(attrsByTypeName[key], a) + } + + relationByRole := make(map[string]clients.EditAssetAssignmentRelationType, len(assignment.RelationTypes)) + for _, rt := range assignment.RelationTypes { + if rt.Role != "" { + relationByRole[normalize(rt.Role)] = rt + } + } + + var rolesByName map[string]clients.EditAssetRole + if opsNeedRoles(ops) { + roles, err := clients.ListRoles(ctx, client) + if err != nil { + return nil, fmt.Errorf("fetching roles: %w", err) + } + rolesByName = make(map[string]clients.EditAssetRole, len(roles)) + for _, r := range roles { + rolesByName[normalize(r.Name)] = r + } + } + + var statusesByName map[string]clients.EditAssetStatus + if opsNeedStatuses(ops) { + statuses, err := clients.ListStatuses(ctx, client) + if err != nil { + return nil, fmt.Errorf("fetching statuses: %w", err) + } + statusesByName = make(map[string]clients.EditAssetStatus, len(statuses)) + for _, s := range statuses { + statusesByName[normalize(s.Name)] = s + } + } + + return &editContext{ + asset: asset, + attributes: attrs, + assignment: assignment, + attributeTypeByName: byName, + attributesByTypeName: attrsByTypeName, + relationTypeByRole: relationByRole, + roleByName: rolesByName, + statusByName: statusesByName, + }, nil +} + +// availableAttributeNames returns the original (un-normalized) attribute +// names from the assignment, for inclusion in error suggestions. +func (ec *editContext) availableAttributeNames() []string { + names := make([]string, 0, len(ec.assignment.AttributeTypes)) + for _, at := range ec.assignment.AttributeTypes { + names = append(names, at.Name) + } + return names +} + +// availableRelationRoles returns the forward-direction role names from +// the assignment, for inclusion in error suggestions. +func (ec *editContext) availableRelationRoles() []string { + names := make([]string, 0, len(ec.assignment.RelationTypes)) + for _, rt := range ec.assignment.RelationTypes { + if rt.Role != "" { + names = append(names, rt.Role) + } + } + return names +} + +// availableRoleNames returns role names from the resolved roles map. +func (ec *editContext) availableRoleNames() []string { + names := make([]string, 0, len(ec.roleByName)) + for _, r := range ec.roleByName { + names = append(names, r.Name) + } + return names +} + +// availableStatusNames returns status names from the resolved statuses map. +func (ec *editContext) availableStatusNames() []string { + names := make([]string, 0, len(ec.statusByName)) + for _, s := range ec.statusByName { + names = append(names, s.Name) + } + return names +} + +// opsNeedRoles reports whether the request contains a set_responsibility op, +// so newEditContext can skip the roles fetch otherwise. +func opsNeedRoles(ops []Operation) bool { + for _, op := range ops { + if op.Type == OpSetResponsibility { + return true + } + } + return false +} + +// opsNeedStatuses reports whether the request contains an update_property op +// targeting statusId, so newEditContext can skip the statuses fetch otherwise. +func opsNeedStatuses(ops []Operation) bool { + for _, op := range ops { + if op.Type == OpUpdateProperty && op.Field == PropertyStatusID { + return true + } + } + return false +} + +// opPlan is the result of validating an operation — it carries enough state to +// execute the op or, if validation failed, a populated error result. +type opPlan struct { + op Operation + result OperationResult + + // Attribute ops (resolved during validation) + attributeTypeID string + targetAttributeID string + previousValue string + + // Property op (resolved during validation) + propertyPatch clients.EditAssetPatchRequest + + // Relation ops (resolved during validation) + relationTypeID string + + // Responsibility op (resolved during validation) + roleID string +} + +func newErrorResult(op Operation, msg string) OperationResult { + return OperationResult{ + Operation: op.Type, + Status: "error", + AttributeName: op.AttributeName, + Field: op.Field, + RelationType: op.RelationType, + RelationID: op.RelationID, + TargetAssetID: op.TargetAssetID, + Tag: op.Tag, + Role: op.Role, + UserID: op.UserID, + Error: msg, + } +} + +func newSuccessResult(op Operation) OperationResult { + return OperationResult{ + Operation: op.Type, + Status: "success", + AttributeName: op.AttributeName, + Field: op.Field, + RelationType: op.RelationType, + RelationID: op.RelationID, + TargetAssetID: op.TargetAssetID, + Tag: op.Tag, + Role: op.Role, + UserID: op.UserID, + } +} + +// validateOperation does all the checks that don't require a write and records +// resolved IDs on the plan so execution doesn't need to re-check them. +func validateOperation(ec *editContext, op Operation) opPlan { + plan := opPlan{op: op} + switch op.Type { + case OpUpdateAttribute: + return validateUpdateAttribute(ec, plan) + case OpAddAttribute: + return validateAddAttribute(ec, plan) + case OpRemoveAttribute: + return validateRemoveAttribute(ec, plan) + case OpUpdateProperty: + return validateUpdateProperty(ec, plan) + case OpAddRelation: + return validateAddRelation(ec, plan) + case OpRemoveRelation: + return validateRemoveRelation(plan) + case OpAddTag: + return validateAddTag(plan) + case OpSetResponsibility: + return validateSetResponsibility(ec, plan) + default: + plan.result = newErrorResult(op, fmt.Sprintf("unsupported operation type %q", op.Type)) + return plan + } +} + +// executePlan runs the side effect for a validated plan and records the final +// result (previous/new values on success, error message on failure). +func executePlan(ctx context.Context, client *http.Client, ec *editContext, plan opPlan) opPlan { + switch plan.op.Type { + case OpUpdateAttribute: + return executeUpdateAttribute(ctx, client, plan) + case OpAddAttribute: + return executeAddAttribute(ctx, client, ec, plan) + case OpRemoveAttribute: + return executeRemoveAttribute(ctx, client, ec, plan) + case OpUpdateProperty: + return executeUpdateProperty(ctx, client, ec, plan) + case OpAddRelation: + return executeAddRelation(ctx, client, ec, plan) + case OpRemoveRelation: + return executeRemoveRelation(ctx, client, plan) + case OpAddTag: + return executeAddTag(ctx, client, ec, plan) + case OpSetResponsibility: + return executeSetResponsibility(ctx, client, ec, plan) + default: + plan.result = newErrorResult(plan.op, fmt.Sprintf("unsupported operation type %q", plan.op.Type)) + return plan + } +} + +func summariseAsset(a *clients.EditAssetCore) *AssetSummary { + if a == nil { + return nil + } + s := &AssetSummary{ + ID: a.ID, + Name: a.Name, + DisplayName: a.DisplayName, + Type: a.Type.Name, + Domain: a.Domain.Name, + } + if a.Status != nil { + s.Status = a.Status.Name + } + return s +} diff --git a/pkg/tools/edit_asset/tool_test.go b/pkg/tools/edit_asset/tool_test.go new file mode 100644 index 0000000..ee6f29b --- /dev/null +++ b/pkg/tools/edit_asset/tool_test.go @@ -0,0 +1,1475 @@ +package edit_asset_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/edit_asset" + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + testAssetID = "018d3602-349b-7d85-8032-3942868ffdc2" + testAssetTypeID = "00000000-0000-0000-0000-000000031103" + testDomainID = "018d3602-70a4-7ebb-9648-8fd5dc099824" + testDomainTypeID = "00000000-0000-0000-0000-000000000001" + + defAttrTypeID = "00000000-0000-0000-0000-000000000202" + noteAttrTypeID = "00000000-0000-0000-0000-000000000203" + acrAttrTypeID = "00000000-0000-0000-0000-000000000204" + + defAttrInstanceID = "7a000000-0000-0000-0000-000000000001" + noteInstanceAID = "7a000000-0000-0000-0000-000000000002" + noteInstanceBID = "7a000000-0000-0000-0000-000000000003" + acrInstanceID = "7a000000-0000-0000-0000-000000000004" + + synonymRelTypeID = "00000000-0000-0000-0000-000000007050" + targetAssetID = "018d3602-6f34-73af-8621-2dd8cd39c76d" + testRelationID = "8b000000-0000-0000-0000-000000000001" + + stewardRoleID = "9c000000-0000-0000-0000-000000000001" + testUserID = "4d250cc5-e583-4640-9874-b93d82c7a6cb" + testResponsibilityID = "9d000000-0000-0000-0000-000000000001" + + candidateStatusID = "ae000000-0000-0000-0000-000000000001" + acceptedStatusID = "ae000000-0000-0000-0000-000000000002" +) + +// stub is a mutable config for the mock server: it lets individual tests +// override attribute instances (e.g. to simulate ambiguous or missing ones) +// and capture what the handler under test wrote to the API. +type stub struct { + attributes []clients.EditAssetAttributeInstance + asset *clients.EditAssetCore + attrTypesByID map[string]clients.EditAssetAssignmentAttributeType + relationTypes []clients.EditAssetAssignmentRelationType + roles []clients.EditAssetRole + statuses []clients.EditAssetStatus + users []clients.EditAssetUser + patchedAssets []map[string]any + patchedAttrs map[string]string + createdAttrs []clients.CreateAttributeRequest + deletedAttrIDs []string + createdRelations []clients.EditAssetCreateRelationRequest + deletedRelationIDs []string + addedTags [][]string + createdResponsibilities []clients.EditAssetCreateResponsibilityRequest + bulkCreatedAttrs [][]clients.CreateAttributeRequest + bulkPatchedAttrs [][]clients.EditAssetBulkPatchAttributeItem + bulkCreatedRelations [][]clients.EditAssetCreateRelationRequest + bulkAttrFailStatus int + bulkRelationFailStatus int + tagFailStatus int + responsibilityFailStatus int + relationFailStatus int + assetNotFound bool +} + +func newStub() *stub { + return &stub{ + asset: &clients.EditAssetCore{ + ID: testAssetID, + Name: "Churn Rate", + Type: clients.EditAssetTypeRef{ID: testAssetTypeID, Name: "Business Term"}, + Domain: clients.EditAssetDomainRef{ID: testDomainID, Name: "Marketing Glossary"}, + }, + attributes: []clients.EditAssetAttributeInstance{ + { + ID: defAttrInstanceID, + Type: clients.EditAssetAttributeTypeRef{ID: defAttrTypeID, Name: "Definition"}, + Asset: clients.EditAssetAttributeAssetRef{ID: testAssetID}, + Value: "Old definition text", + }, + { + ID: acrInstanceID, + Type: clients.EditAssetAttributeTypeRef{ID: acrAttrTypeID, Name: "Acronym"}, + Asset: clients.EditAssetAttributeAssetRef{ID: testAssetID}, + Value: "CR", + }, + }, + attrTypesByID: map[string]clients.EditAssetAssignmentAttributeType{ + defAttrTypeID: {ID: defAttrTypeID, Name: "Definition"}, + noteAttrTypeID: {ID: noteAttrTypeID, Name: "Note"}, + acrAttrTypeID: {ID: acrAttrTypeID, Name: "Acronym"}, + }, + relationTypes: []clients.EditAssetAssignmentRelationType{ + { + ID: synonymRelTypeID, + Role: "is synonym of", + CoRole: "has synonym", + SourceType: &clients.EditAssetTypeRef{ID: testAssetTypeID, Name: "Business Term"}, + TargetType: &clients.EditAssetTypeRef{ID: testAssetTypeID, Name: "Business Term"}, + }, + }, + roles: []clients.EditAssetRole{ + {ID: stewardRoleID, Name: "Steward"}, + }, + statuses: []clients.EditAssetStatus{ + {ID: candidateStatusID, Name: "Candidate"}, + {ID: acceptedStatusID, Name: "Accepted"}, + }, + users: []clients.EditAssetUser{ + {ID: testUserID, UserName: "jane.smith", EmailAddress: "jane.smith@example.com"}, + }, + patchedAttrs: map[string]string{}, + } +} + +func (s *stub) install(mux *http.ServeMux, t *testing.T) { + t.Helper() + + mux.HandleFunc("GET /rest/2.0/assets/"+testAssetID, func(w http.ResponseWriter, _ *http.Request) { + if s.assetNotFound { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(s.asset) + }) + + mux.HandleFunc("PATCH /rest/2.0/assets/"+testAssetID, func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + s.patchedAssets = append(s.patchedAssets, body) + updated := *s.asset + if v, ok := body["name"].(string); ok { + updated.Name = v + } + if v, ok := body["displayName"].(string); ok { + updated.DisplayName = v + } + if v, ok := body["statusId"].(string); ok { + updated.Status = &clients.EditAssetStatusRef{ID: v, Name: "Accepted"} + } + s.asset = &updated + _ = json.NewEncoder(w).Encode(updated) + }) + + mux.HandleFunc("GET /rest/2.0/attributes", func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]any{ + "total": len(s.attributes), + "offset": 0, + "limit": 100, + "results": s.attributes, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("POST /rest/2.0/attributes", func(w http.ResponseWriter, r *http.Request) { + var req clients.CreateAttributeRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.createdAttrs = append(s.createdAttrs, req) + resp := clients.EditAssetAttributeInstance{ + ID: "new-" + req.TypeID, + Type: clients.EditAssetAttributeTypeRef{ID: req.TypeID, Name: s.attrTypesByID[req.TypeID].Name}, + Asset: clients.EditAssetAttributeAssetRef{ID: req.AssetID}, + Value: req.Value, + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("PATCH /rest/2.0/attributes/", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + id := strings.TrimPrefix(r.URL.Path, "/rest/2.0/attributes/") + s.patchedAttrs[id] = body["value"] + resp := clients.EditAssetAttributeInstance{ID: id, Value: body["value"]} + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("DELETE /rest/2.0/attributes/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/rest/2.0/attributes/") + s.deletedAttrIDs = append(s.deletedAttrIDs, id) + // Also remove from the in-memory list so subsequent calls in the same + // test don't still see it. + remaining := s.attributes[:0] + for _, a := range s.attributes { + if a.ID != id { + remaining = append(remaining, a) + } + } + s.attributes = remaining + w.WriteHeader(http.StatusNoContent) + }) + + mux.HandleFunc("GET /rest/2.0/domains/"+testDomainID, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(clients.EditAssetDomainDetails{ + ID: testDomainID, + Name: "Marketing Glossary", + Type: &clients.EditAssetDomainTypeRef{ID: testDomainTypeID, Name: "Business Glossary"}, + }) + }) + + mux.HandleFunc("GET /rest/2.0/assignments/assetType/"+testAssetTypeID, func(w http.ResponseWriter, _ *http.Request) { + // Emit Collibra's actual response shape: top-level array with one + // assignment, characteristicTypes flattening attribute and relation + // types via assignedCharacteristicTypeDiscriminator. + chars := []map[string]any{} + for _, v := range s.attrTypesByID { + chars = append(chars, map[string]any{ + "id": "attr-char-" + v.ID, + "minimumOccurrences": 0, + "assignedCharacteristicTypeDiscriminator": "AttributeType", + "attributeType": map[string]any{ + "id": v.ID, + "name": v.Name, + "resourceType": "StringAttributeType", + }, + }) + } + for _, rt := range s.relationTypes { + chars = append(chars, map[string]any{ + "id": "rel-char-" + rt.ID, + "minimumOccurrences": 0, + "roleDirection": "TO_TARGET", + "assignedCharacteristicTypeDiscriminator": "RelationType", + "relationType": map[string]any{ + "id": rt.ID, + "role": rt.Role, + "coRole": rt.CoRole, + "sourceType": rt.SourceType, + "targetType": rt.TargetType, + }, + }) + } + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": "assignment-1", + "assetType": map[string]any{"id": testAssetTypeID, "name": "Business Term"}, + "domainTypes": []map[string]any{{ + "id": testDomainTypeID, "name": "Business Glossary", + }}, + "characteristicTypes": chars, + }}) + }) + + mux.HandleFunc("POST /rest/2.0/relations", func(w http.ResponseWriter, r *http.Request) { + var req clients.EditAssetCreateRelationRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.createdRelations = append(s.createdRelations, req) + if s.relationFailStatus != 0 { + w.WriteHeader(s.relationFailStatus) + _, _ = w.Write([]byte(`{"message":"simulated relation failure"}`)) + return + } + resp := clients.EditAssetRelation{ + ID: testRelationID, + Type: clients.EditAssetTypeRef{ID: req.TypeID, Name: "is synonym of"}, + Source: clients.EditAssetAttributeAssetRef{ID: req.SourceID}, + Target: clients.EditAssetAttributeAssetRef{ID: req.TargetID}, + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("DELETE /rest/2.0/relations/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/rest/2.0/relations/") + if id != testRelationID { + w.WriteHeader(http.StatusNotFound) + return + } + s.deletedRelationIDs = append(s.deletedRelationIDs, id) + w.WriteHeader(http.StatusNoContent) + }) + + mux.HandleFunc("POST /rest/2.0/assets/"+testAssetID+"/tags", func(w http.ResponseWriter, r *http.Request) { + // Mimic Collibra's actual contract: required field is "tagNames". + // If the client sends anything else, the field comes through empty + // and we 400 the same way the real API does. + var raw map[string]json.RawMessage + _ = json.NewDecoder(r.Body).Decode(&raw) + var names []string + if rawNames, ok := raw["tagNames"]; ok { + _ = json.Unmarshal(rawNames, &names) + } + if len(names) == 0 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"tagNames may not be null"}`)) + return + } + s.addedTags = append(s.addedTags, names) + if s.tagFailStatus != 0 { + w.WriteHeader(s.tagFailStatus) + _, _ = w.Write([]byte(`{"message":"simulated tag failure"}`)) + return + } + w.WriteHeader(http.StatusNoContent) + }) + + mux.HandleFunc("GET /rest/2.0/roles", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": len(s.roles), + "offset": 0, + "limit": 1000, + "results": s.roles, + }) + }) + + mux.HandleFunc("GET /rest/2.0/users", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + var matches []clients.EditAssetUser + username := q.Get("name") + email := q.Get("emailAddress") + for _, u := range s.users { + if username != "" && u.UserName == username { + matches = append(matches, u) + } + if email != "" && u.EmailAddress == email { + matches = append(matches, u) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": len(matches), + "offset": 0, + "limit": 1, + "results": matches, + }) + }) + + mux.HandleFunc("GET /rest/2.0/statuses", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": len(s.statuses), + "offset": 0, + "limit": 1000, + "results": s.statuses, + }) + }) + + mux.HandleFunc("POST /rest/2.0/attributes/bulk", func(w http.ResponseWriter, r *http.Request) { + var items []clients.CreateAttributeRequest + _ = json.NewDecoder(r.Body).Decode(&items) + s.bulkCreatedAttrs = append(s.bulkCreatedAttrs, items) + if s.bulkAttrFailStatus != 0 { + w.WriteHeader(s.bulkAttrFailStatus) + _, _ = w.Write([]byte(`{"message":"simulated bulk attr failure"}`)) + return + } + resp := make([]clients.EditAssetAttributeInstance, len(items)) + for i, it := range items { + resp[i] = clients.EditAssetAttributeInstance{ + ID: "bulk-new-" + it.TypeID, + Type: clients.EditAssetAttributeTypeRef{ID: it.TypeID, Name: s.attrTypesByID[it.TypeID].Name}, + Asset: clients.EditAssetAttributeAssetRef{ID: it.AssetID}, + Value: it.Value, + } + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("PATCH /rest/2.0/attributes/bulk", func(w http.ResponseWriter, r *http.Request) { + var items []clients.EditAssetBulkPatchAttributeItem + _ = json.NewDecoder(r.Body).Decode(&items) + s.bulkPatchedAttrs = append(s.bulkPatchedAttrs, items) + if s.bulkAttrFailStatus != 0 { + w.WriteHeader(s.bulkAttrFailStatus) + _, _ = w.Write([]byte(`{"message":"simulated bulk patch failure"}`)) + return + } + resp := make([]clients.EditAssetAttributeInstance, len(items)) + for i, it := range items { + resp[i] = clients.EditAssetAttributeInstance{ID: it.ID, Value: it.Value} + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("POST /rest/2.0/relations/bulk", func(w http.ResponseWriter, r *http.Request) { + var items []clients.EditAssetCreateRelationRequest + _ = json.NewDecoder(r.Body).Decode(&items) + s.bulkCreatedRelations = append(s.bulkCreatedRelations, items) + if s.bulkRelationFailStatus != 0 { + w.WriteHeader(s.bulkRelationFailStatus) + _, _ = w.Write([]byte(`{"message":"simulated bulk relation failure"}`)) + return + } + resp := make([]clients.EditAssetRelation, len(items)) + for i, it := range items { + resp[i] = clients.EditAssetRelation{ + ID: "bulk-rel-" + it.TargetID, + Type: clients.EditAssetTypeRef{ID: it.TypeID, Name: "is synonym of"}, + Source: clients.EditAssetAttributeAssetRef{ID: it.SourceID}, + Target: clients.EditAssetAttributeAssetRef{ID: it.TargetID}, + } + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("POST /rest/2.0/responsibilities", func(w http.ResponseWriter, r *http.Request) { + var req clients.EditAssetCreateResponsibilityRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.createdResponsibilities = append(s.createdResponsibilities, req) + if s.responsibilityFailStatus != 0 { + w.WriteHeader(s.responsibilityFailStatus) + _, _ = w.Write([]byte(`{"message":"simulated responsibility failure"}`)) + return + } + resp := clients.EditAssetResponsibility{ + ID: testResponsibilityID, + RoleID: req.RoleID, + OwnerID: req.OwnerID, + ResourceID: req.ResourceID, + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) +} + +func runTool(t *testing.T, s *stub, in edit_asset.Input) (edit_asset.Output, error) { + t.Helper() + mux := http.NewServeMux() + s.install(mux, t) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client := testutil.NewClient(srv) + return edit_asset.NewTool(client).Handler(t.Context(), in) +} + +// --- tests -------------------------------------------------------------------- + +func TestEditAsset_UpdateAttribute_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "New definition", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if got := s.patchedAttrs[defAttrInstanceID]; got != "New definition" { + t.Fatalf("expected PATCH with new value, got %q", got) + } + r := out.Results[0] + if r.PreviousValue != "Old definition text" || r.NewValue != "New definition" { + t.Fatalf("unexpected diff: prev=%q new=%q", r.PreviousValue, r.NewValue) + } +} + +func TestEditAsset_AddAttribute_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "Reviewed 2026-04", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if len(s.createdAttrs) != 1 || s.createdAttrs[0].TypeID != noteAttrTypeID { + t.Fatalf("expected POST to create Note attribute, got %+v", s.createdAttrs) + } +} + +func TestEditAsset_RemoveAttribute_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpRemoveAttribute, AttributeName: "Acronym", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if len(s.deletedAttrIDs) != 1 || s.deletedAttrIDs[0] != acrInstanceID { + t.Fatalf("expected DELETE of acronym instance, got %v", s.deletedAttrIDs) + } + if out.Results[0].PreviousValue != "CR" { + t.Fatalf("expected previousValue=CR, got %q", out.Results[0].PreviousValue) + } +} + +func TestEditAsset_UpdateProperty_Name(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "name", Value: "Renamed", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if got := s.patchedAssets[0]["name"]; got != "Renamed" { + t.Fatalf("expected PATCH name=Renamed, got %v", got) + } + if out.Results[0].PreviousValue != "Churn Rate" || out.Results[0].NewValue != "Renamed" { + t.Fatalf("unexpected diff: %+v", out.Results[0]) + } +} + +// When displayName tracks name (Collibra's create-time default), an +// update_property field=name should auto-cascade to displayName so the +// user-facing label stays in sync. +func TestEditAsset_UpdateProperty_Name_CascadesDisplayNameWhenItTracksName(t *testing.T) { + s := newStub() + s.asset.DisplayName = "Churn Rate" // matches Name → Collibra's auto-default + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "name", Value: "Renamed", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if got := s.patchedAssets[0]["name"]; got != "Renamed" { + t.Fatalf("expected PATCH name=Renamed, got %v", got) + } + if got := s.patchedAssets[0]["displayName"]; got != "Renamed" { + t.Fatalf("expected cascade to PATCH displayName=Renamed, got %v", got) + } + if !out.Results[0].CascadedDisplayName { + t.Fatalf("expected CascadedDisplayName=true on the result") + } +} + +// When displayName has been customized to differ from name, name updates +// must NOT silently overwrite it. +func TestEditAsset_UpdateProperty_Name_DoesNotCascadeWhenDisplayNameDiverged(t *testing.T) { + s := newStub() + s.asset.DisplayName = "Customer Churn" // explicitly customized away from Name + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "name", Value: "Renamed", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if got := s.patchedAssets[0]["name"]; got != "Renamed" { + t.Fatalf("expected PATCH name=Renamed, got %v", got) + } + if _, present := s.patchedAssets[0]["displayName"]; present { + t.Fatalf("expected NO displayName in PATCH when displayName had diverged, got %+v", s.patchedAssets[0]) + } + if out.Results[0].CascadedDisplayName { + t.Fatalf("expected CascadedDisplayName=false when displayName had diverged") + } +} + +// In a batch where an earlier op explicitly sets displayName, the later +// name update should NOT clobber that intentional customization. The +// cascade decision must use the post-batch state (after the displayName +// patch), not the validate-time snapshot. +func TestEditAsset_UpdateProperty_Name_DoesNotCascadeAfterDisplayNamePatchedEarlierInBatch(t *testing.T) { + s := newStub() + s.asset.DisplayName = "Churn Rate" // initially tracks Name + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpUpdateProperty, Field: "displayName", Value: "Customer Churn"}, + {Type: edit_asset.OpUpdateProperty, Field: "name", Value: "Renamed"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q (results=%+v)", out.Status, out.Results) + } + if len(s.patchedAssets) != 2 { + t.Fatalf("expected 2 PATCH calls, got %d", len(s.patchedAssets)) + } + // Second patch is the name update — must not also re-patch displayName. + if got := s.patchedAssets[1]["name"]; got != "Renamed" { + t.Fatalf("expected second PATCH name=Renamed, got %v", got) + } + if _, present := s.patchedAssets[1]["displayName"]; present { + t.Fatalf("name op must not cascade after a prior displayName customization in same batch, got %+v", s.patchedAssets[1]) + } + if out.Results[1].CascadedDisplayName { + t.Fatalf("expected CascadedDisplayName=false on the post-customization name op") + } +} + +func TestEditAsset_UpdateProperty_StatusID_ByUUID(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "statusId", Value: acceptedStatusID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if got := s.patchedAssets[0]["statusId"]; got != acceptedStatusID { + t.Fatalf("expected PATCH statusId=%s, got %v", acceptedStatusID, got) + } +} + +func TestEditAsset_UpdateProperty_StatusID_ByName(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "statusId", Value: "Candidate", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if got := s.patchedAssets[0]["statusId"]; got != candidateStatusID { + t.Fatalf("expected name 'Candidate' to resolve to %s, got %v", candidateStatusID, got) + } +} + +func TestEditAsset_UpdateProperty_StatusID_UnknownName(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "statusId", Value: "Mayor", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "not defined") { + t.Fatalf("expected unknown-status error, got %+v", out) + } + if len(s.patchedAssets) != 0 { + t.Fatalf("expected no PATCH on unknown status, got %+v", s.patchedAssets) + } +} + +func TestEditAsset_StatusesFetchedOnlyWhenNeeded(t *testing.T) { + s := newStub() + mux := http.NewServeMux() + s.install(mux, t) + var statusesCalled bool + mux.HandleFunc("GET /rest/2.0/statuses/", func(w http.ResponseWriter, _ *http.Request) { + statusesCalled = true + _ = json.NewEncoder(w).Encode(map[string]any{"total": 0, "results": []any{}}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + client := testutil.NewClient(srv) + _, err := edit_asset.NewTool(client).Handler(t.Context(), edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "x", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if statusesCalled { + t.Fatal("statuses endpoint should not be hit when no statusId update is present") + } +} + +func TestEditAsset_UnknownAttributeName(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: "NotAnAttribute", Value: "x", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected error, got %q", out.Status) + } + if !strings.Contains(out.Results[0].Error, "not valid for asset type") { + t.Fatalf("expected scoped-assignment error, got %q", out.Results[0].Error) + } +} + +func TestEditAsset_AmbiguousAttributeName(t *testing.T) { + s := newStub() + // Two Note instances on the asset + s.attributes = append(s.attributes, + clients.EditAssetAttributeInstance{ID: noteInstanceAID, Type: clients.EditAssetAttributeTypeRef{ID: noteAttrTypeID, Name: "Note"}, Asset: clients.EditAssetAttributeAssetRef{ID: testAssetID}, Value: "one"}, + clients.EditAssetAttributeInstance{ID: noteInstanceBID, Type: clients.EditAssetAttributeTypeRef{ID: noteAttrTypeID, Name: "Note"}, Asset: clients.EditAssetAttributeAssetRef{ID: testAssetID}, Value: "two"}, + ) + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: "Note", Value: "three", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected error, got %q", out.Status) + } + if !strings.Contains(out.Results[0].Error, "cannot disambiguate") { + t.Fatalf("expected disambiguation error, got %q", out.Results[0].Error) + } + if len(s.patchedAttrs) != 0 { + t.Fatalf("expected no writes on ambiguous update, saw %+v", s.patchedAttrs) + } +} + +func TestEditAsset_UnsupportedPropertyField(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "typeId", Value: "x", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "not supported") { + t.Fatalf("expected unsupported-field error, got %+v", out) + } + if len(s.patchedAssets) != 0 { + t.Fatalf("expected no PATCH on unsupported field, saw %+v", s.patchedAssets) + } +} + +func TestEditAsset_AssetNotFound(t *testing.T) { + s := newStub() + s.assetNotFound = true + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "x", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || out.Error == "" { + t.Fatalf("expected top-level error on asset not found, got %+v", out) + } +} + +func TestEditAsset_InvalidAssetID(t *testing.T) { + s := newStub() + _, err := runTool(t, s, edit_asset.Input{ + AssetID: "not-a-uuid", + Operations: []edit_asset.Operation{{Type: edit_asset.OpUpdateAttribute, AttributeName: "x", Value: "x"}}, + }) + if err == nil { + t.Fatal("expected UUID validation error, got nil") + } +} + +func TestEditAsset_EmptyOperations(t *testing.T) { + s := newStub() + _, err := runTool(t, s, edit_asset.Input{AssetID: testAssetID}) + if err == nil { + t.Fatal("expected error on empty operations, got nil") + } +} + +func TestEditAsset_PartialSuccess(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "new def"}, + {Type: edit_asset.OpUpdateAttribute, AttributeName: "Bogus", Value: "x"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusPartialSuccess { + t.Fatalf("expected partial_success, got %q", out.Status) + } + if out.Results[0].Status != "success" { + t.Fatalf("expected first op to succeed, got %+v", out.Results[0]) + } + if out.Results[1].Status != "error" { + t.Fatalf("expected second op to fail, got %+v", out.Results[1]) + } + if _, patched := s.patchedAttrs[defAttrInstanceID]; !patched { + t.Fatal("expected valid op to have run despite sibling failure") + } +} + +func TestEditAsset_AddRelation_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddRelation, RelationType: "is synonym of", TargetAssetID: targetAssetID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if len(s.createdRelations) != 1 { + t.Fatalf("expected one POST to /rest/2.0/relations, got %+v", s.createdRelations) + } + got := s.createdRelations[0] + if got.SourceID != testAssetID || got.TargetID != targetAssetID || got.TypeID != synonymRelTypeID { + t.Fatalf("unexpected relation payload: %+v", got) + } + if out.Results[0].RelationID != testRelationID { + t.Fatalf("expected RelationID in result, got %q", out.Results[0].RelationID) + } +} + +func TestEditAsset_AddRelation_UnknownType(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddRelation, RelationType: "not a real relation", TargetAssetID: targetAssetID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected error, got %q", out.Status) + } + if !strings.Contains(out.Results[0].Error, "not valid for asset type") { + t.Fatalf("expected scoped-assignment error, got %q", out.Results[0].Error) + } + if len(s.createdRelations) != 0 { + t.Fatalf("expected no POST on unknown relation type, got %+v", s.createdRelations) + } +} + +func TestEditAsset_AddRelation_CoRoleDoesNotMatch(t *testing.T) { + s := newStub() + // "has synonym" is the coRole; we intentionally only match forward roles. + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddRelation, RelationType: "has synonym", TargetAssetID: targetAssetID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected error for coRole-only match, got %q", out.Status) + } +} + +func TestEditAsset_AddRelation_InvalidTargetUUID(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddRelation, RelationType: "is synonym of", TargetAssetID: "not-a-uuid", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "UUID") { + t.Fatalf("expected UUID validation error, got %+v", out) + } +} + +func TestEditAsset_AddRelation_CollibraRejection(t *testing.T) { + // Simulate Collibra returning a 422, e.g. because the direction is wrong — + // user assumed source but the relation expects this asset as target. + s := newStub() + s.relationFailStatus = http.StatusUnprocessableEntity + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddRelation, RelationType: "is synonym of", TargetAssetID: targetAssetID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected per-op error on Collibra rejection, got %q", out.Status) + } +} + +func TestEditAsset_RemoveRelation_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpRemoveRelation, RelationID: testRelationID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if len(s.deletedRelationIDs) != 1 || s.deletedRelationIDs[0] != testRelationID { + t.Fatalf("expected DELETE of relation, got %v", s.deletedRelationIDs) + } +} + +func TestEditAsset_RemoveRelation_NotFound(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpRemoveRelation, RelationID: "8b000000-0000-0000-0000-00000000dead", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "not found") { + t.Fatalf("expected not-found error, got %+v", out) + } +} + +func TestEditAsset_RemoveRelation_InvalidUUID(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpRemoveRelation, RelationID: "not-a-uuid", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "UUID") { + t.Fatalf("expected UUID validation error, got %+v", out) + } +} + +func TestEditAsset_AddTag_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddTag, Tag: "finance", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if len(s.addedTags) != 1 || len(s.addedTags[0]) != 1 || s.addedTags[0][0] != "finance" { + t.Fatalf("expected POST to tags with ['finance'], got %+v", s.addedTags) + } + if out.Results[0].NewValue != "finance" { + t.Fatalf("expected NewValue=finance, got %q", out.Results[0].NewValue) + } +} + +func TestEditAsset_AddTag_Empty(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddTag, Tag: "", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "tag is required") { + t.Fatalf("expected 'tag is required' error, got %+v", out) + } + if len(s.addedTags) != 0 { + t.Fatalf("expected no POST on empty tag, got %+v", s.addedTags) + } +} + +func TestEditAsset_SetResponsibility_HappyPath(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "Steward", UserID: testUserID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if len(s.createdResponsibilities) != 1 { + t.Fatalf("expected one POST to /rest/2.0/responsibilities, got %+v", s.createdResponsibilities) + } + got := s.createdResponsibilities[0] + if got.RoleID != stewardRoleID || got.OwnerID != testUserID || got.ResourceID != testAssetID { + t.Fatalf("unexpected responsibility payload: %+v", got) + } + if out.Results[0].NewValue != testResponsibilityID { + t.Fatalf("expected responsibility ID in NewValue, got %q", out.Results[0].NewValue) + } +} + +func TestEditAsset_SetResponsibility_UnknownRole(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "Mayor", UserID: testUserID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "not defined") { + t.Fatalf("expected unknown-role error, got %+v", out) + } + if len(s.createdResponsibilities) != 0 { + t.Fatalf("expected no POST on unknown role, got %+v", s.createdResponsibilities) + } +} + +func TestEditAsset_SetResponsibility_UnknownUserName(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "Steward", UserID: "no.such.user", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "no user found") { + t.Fatalf("expected no-user-found error, got %+v", out) + } +} + +func TestEditAsset_SetResponsibility_ResolvesByUsername(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "Steward", UserID: "jane.smith", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if s.createdResponsibilities[0].OwnerID != testUserID { + t.Fatalf("expected username 'jane.smith' to resolve to %s, got %s", testUserID, s.createdResponsibilities[0].OwnerID) + } +} + +func TestEditAsset_SetResponsibility_ResolvesByEmail(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "Steward", UserID: "jane.smith@example.com", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if s.createdResponsibilities[0].OwnerID != testUserID { + t.Fatalf("expected email to resolve to %s, got %s", testUserID, s.createdResponsibilities[0].OwnerID) + } +} + +// --- Case-insensitive + whitespace + suggestion tests ---------------------- + +func TestEditAsset_AttributeName_CaseAndWhitespaceInsensitive(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: " definition ", Value: "matched despite case/whitespace", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } +} + +func TestEditAsset_StatusName_CaseAndWhitespaceInsensitive(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "statusId", Value: " CANDIDATE ", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if got := s.patchedAssets[0]["statusId"]; got != candidateStatusID { + t.Fatalf("expected normalized lookup to resolve, got %v", got) + } +} + +func TestEditAsset_RoleName_CaseAndWhitespaceInsensitive(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "steward", UserID: testUserID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success on case-insensitive role match, got %q", out.Status) + } +} + +func TestEditAsset_ErrorIncludesAvailableAttributes(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateAttribute, AttributeName: "Bogus", Value: "x", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + msg := out.Results[0].Error + if !strings.Contains(msg, "Attributes available:") { + t.Fatalf("error should suggest available attributes, got %q", msg) + } + for _, expected := range []string{"Definition", "Note", "Acronym"} { + if !strings.Contains(msg, expected) { + t.Fatalf("error should include %q in suggestion, got %q", expected, msg) + } + } +} + +func TestEditAsset_ErrorIncludesAvailableStatuses(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpUpdateProperty, Field: "statusId", Value: "NotARealStatus", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + msg := out.Results[0].Error + if !strings.Contains(msg, "Statuses available:") || !strings.Contains(msg, "Candidate") { + t.Fatalf("error should suggest available statuses, got %q", msg) + } +} + +func TestEditAsset_SetResponsibility_MissingRole(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, UserID: testUserID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError || !strings.Contains(out.Results[0].Error, "role is required") { + t.Fatalf("expected 'role is required' error, got %+v", out) + } +} + +func TestEditAsset_SetResponsibility_CollibraRejection(t *testing.T) { + s := newStub() + s.responsibilityFailStatus = http.StatusConflict + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpSetResponsibility, Role: "Steward", UserID: testUserID, + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected per-op error, got %q", out.Status) + } +} + +func TestEditAsset_RolesFetchedOnlyWhenNeeded(t *testing.T) { + // Request with no set_responsibility op should not fetch /rest/2.0/roles. + // Verify by observing the request path log on the stub. + s := newStub() + + mux := http.NewServeMux() + s.install(mux, t) + var rolesCalled bool + // Override to flag when /roles is hit. + mux.HandleFunc("GET /rest/2.0/roles/", func(w http.ResponseWriter, _ *http.Request) { + rolesCalled = true + _ = json.NewEncoder(w).Encode(map[string]any{"total": 0, "results": []any{}}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + client := testutil.NewClient(srv) + _, err := edit_asset.NewTool(client).Handler(t.Context(), edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{{ + Type: edit_asset.OpAddTag, Tag: "finance", + }}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rolesCalled { + t.Fatal("roles endpoint should not be hit when no set_responsibility op is present") + } +} + +// --- Phase 4: bulk batching --------------------------------------------------- + +func TestEditAsset_BulkAddAttributes(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "one"}, + {Type: edit_asset.OpAddAttribute, AttributeName: "Acronym", Value: "CR"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if len(s.bulkCreatedAttrs) != 1 || len(s.bulkCreatedAttrs[0]) != 2 { + t.Fatalf("expected one bulk POST with 2 items, got %+v", s.bulkCreatedAttrs) + } + if len(s.createdAttrs) != 0 { + t.Fatalf("expected no individual POST /attributes calls, got %+v", s.createdAttrs) + } +} + +func TestEditAsset_SingleAddAttributeUsesIndividualEndpoint(t *testing.T) { + s := newStub() + _, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "solo"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(s.bulkCreatedAttrs) != 0 { + t.Fatalf("expected no bulk POST on single op, got %+v", s.bulkCreatedAttrs) + } + if len(s.createdAttrs) != 1 { + t.Fatalf("expected one individual POST, got %+v", s.createdAttrs) + } +} + +func TestEditAsset_BulkUpdateAttributes(t *testing.T) { + s := newStub() + // Need two existing attributes to update. + s.attributes = append(s.attributes, clients.EditAssetAttributeInstance{ + ID: noteInstanceAID, + Type: clients.EditAssetAttributeTypeRef{ID: noteAttrTypeID, Name: "Note"}, + Asset: clients.EditAssetAttributeAssetRef{ID: testAssetID}, + Value: "old note", + }) + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "new def"}, + {Type: edit_asset.OpUpdateAttribute, AttributeName: "Note", Value: "new note"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if len(s.bulkPatchedAttrs) != 1 || len(s.bulkPatchedAttrs[0]) != 2 { + t.Fatalf("expected one bulk PATCH with 2 items, got %+v", s.bulkPatchedAttrs) + } + if len(s.patchedAttrs) != 0 { + t.Fatalf("expected no individual PATCH /attributes calls, got %+v", s.patchedAttrs) + } + if out.Results[0].PreviousValue != "Old definition text" || out.Results[1].PreviousValue != "old note" { + t.Fatalf("expected previous values preserved per op, got %+v", out.Results) + } +} + +func TestEditAsset_BulkAddRelations(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpAddRelation, RelationType: "is synonym of", TargetAssetID: targetAssetID}, + {Type: edit_asset.OpAddRelation, RelationType: "is synonym of", TargetAssetID: "018d3602-aaaa-0000-0000-000000000001"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q", out.Status) + } + if len(s.bulkCreatedRelations) != 1 || len(s.bulkCreatedRelations[0]) != 2 { + t.Fatalf("expected one bulk POST with 2 items, got %+v", s.bulkCreatedRelations) + } + if len(s.createdRelations) != 0 { + t.Fatalf("expected no individual relation POSTs, got %+v", s.createdRelations) + } + if out.Results[0].RelationID == "" || out.Results[1].RelationID == "" { + t.Fatalf("expected RelationID populated per op, got %+v", out.Results) + } +} + +func TestEditAsset_BulkFailureMarksAllOpsFailed(t *testing.T) { + s := newStub() + s.bulkAttrFailStatus = http.StatusBadRequest + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "one"}, + {Type: edit_asset.OpAddAttribute, AttributeName: "Acronym", Value: "CR"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusError { + t.Fatalf("expected error, got %q", out.Status) + } + for i, r := range out.Results { + if r.Status != "error" { + t.Fatalf("op %d should be error on batch failure, got %+v", i, r) + } + } +} + +func TestEditAsset_BulkOnlyGroupsSameType(t *testing.T) { + // Two add_attrs (bulk) + one update_attr (individual) + one update_property + // (individual) + one add_tag (individual). + s := newStub() + _, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "one"}, + {Type: edit_asset.OpAddAttribute, AttributeName: "Acronym", Value: "CR"}, + {Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "new def"}, + {Type: edit_asset.OpUpdateProperty, Field: "name", Value: "Renamed"}, + {Type: edit_asset.OpAddTag, Tag: "finance"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(s.bulkCreatedAttrs) != 1 || len(s.bulkCreatedAttrs[0]) != 2 { + t.Fatalf("expected bulk add POST with 2 items, got %+v", s.bulkCreatedAttrs) + } + if len(s.bulkPatchedAttrs) != 0 { + t.Fatalf("expected no bulk PATCH (only 1 update op), got %+v", s.bulkPatchedAttrs) + } + if len(s.patchedAttrs) != 1 { + t.Fatalf("expected one individual PATCH /attributes for the lone update, got %+v", s.patchedAttrs) + } + if len(s.patchedAssets) != 1 { + t.Fatalf("expected one PATCH /assets for update_property, got %+v", s.patchedAssets) + } + if len(s.addedTags) != 1 { + t.Fatalf("expected one tag POST, got %+v", s.addedTags) + } +} + +func TestEditAsset_InvalidOpsExcludedFromBulk(t *testing.T) { + // Two add_attrs, but one is invalid (unknown attribute). The valid one + // should run via the individual endpoint (not bulk) since the threshold + // filters on validated ops. + s := newStub() + _, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "valid"}, + {Type: edit_asset.OpAddAttribute, AttributeName: "NotValid", Value: "x"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(s.bulkCreatedAttrs) != 0 { + t.Fatalf("expected no bulk POST when only 1 op is valid, got %+v", s.bulkCreatedAttrs) + } + if len(s.createdAttrs) != 1 { + t.Fatalf("expected one individual POST for the valid op, got %+v", s.createdAttrs) + } +} + +func TestEditAsset_MultipleValidOperations(t *testing.T) { + s := newStub() + out, err := runTool(t, s, edit_asset.Input{ + AssetID: testAssetID, + Operations: []edit_asset.Operation{ + {Type: edit_asset.OpUpdateAttribute, AttributeName: "Definition", Value: "new def"}, + {Type: edit_asset.OpAddAttribute, AttributeName: "Note", Value: "note"}, + {Type: edit_asset.OpRemoveAttribute, AttributeName: "Acronym"}, + {Type: edit_asset.OpUpdateProperty, Field: "name", Value: "Renamed"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != edit_asset.StatusSuccess { + t.Fatalf("expected success, got %q, results=%+v", out.Status, out.Results) + } + if len(s.patchedAttrs) != 1 || len(s.createdAttrs) != 1 || len(s.deletedAttrIDs) != 1 || len(s.patchedAssets) != 1 { + t.Fatalf("unexpected call distribution: patched=%d created=%d deleted=%d patchedAsset=%d", + len(s.patchedAttrs), len(s.createdAttrs), len(s.deletedAttrIDs), len(s.patchedAssets)) + } +} diff --git a/pkg/tools/register.go b/pkg/tools/register.go index 6845198..7aabf43 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -9,6 +9,7 @@ import ( "github.com/collibra/chip/pkg/tools/create_asset" "github.com/collibra/chip/pkg/tools/discover_business_glossary" "github.com/collibra/chip/pkg/tools/discover_data_assets" + "github.com/collibra/chip/pkg/tools/edit_asset" "github.com/collibra/chip/pkg/tools/get_asset_details" "github.com/collibra/chip/pkg/tools/get_business_term_data" "github.com/collibra/chip/pkg/tools/get_column_semantics" @@ -20,14 +21,14 @@ import ( "github.com/collibra/chip/pkg/tools/get_table_semantics" "github.com/collibra/chip/pkg/tools/list_asset_types" "github.com/collibra/chip/pkg/tools/list_data_contracts" - "github.com/collibra/chip/pkg/tools/prepare_create_asset" "github.com/collibra/chip/pkg/tools/prepare_add_business_term" + "github.com/collibra/chip/pkg/tools/prepare_create_asset" "github.com/collibra/chip/pkg/tools/pull_data_contract_manifest" "github.com/collibra/chip/pkg/tools/push_data_contract_manifest" "github.com/collibra/chip/pkg/tools/remove_data_classification_match" "github.com/collibra/chip/pkg/tools/search_asset_keyword" - "github.com/collibra/chip/pkg/tools/search_data_classification_matches" "github.com/collibra/chip/pkg/tools/search_data_classes" + "github.com/collibra/chip/pkg/tools/search_data_classification_matches" "github.com/collibra/chip/pkg/tools/search_lineage_entities" "github.com/collibra/chip/pkg/tools/search_lineage_transformations" ) @@ -67,6 +68,7 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv toolRegister(server, toolConfig, prepare_create_asset.NewTool(client)) toolRegister(server, toolConfig, add_business_term.NewTool(client)) toolRegister(server, toolConfig, create_asset.NewTool(client)) + toolRegister(server, toolConfig, edit_asset.NewTool(client)) } func toolRegister[In, Out any](server *chip.Server, toolConfig *chip.ServerToolConfig, tool *chip.Tool[In, Out]) {