diff --git a/.gitignore b/.gitignore index 836b852..50fff47 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build .vscode mcp.yaml oas/** +mise.toml diff --git a/pkg/clients/add_business_term_client.go b/pkg/clients/add_business_term_client.go new file mode 100644 index 0000000..90a7fc9 --- /dev/null +++ b/pkg/clients/add_business_term_client.go @@ -0,0 +1,100 @@ +package clients + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// AddBusinessTermAssetRequest is the request body for creating a business term asset. +type AddBusinessTermAssetRequest struct { + Name string `json:"name"` + TypePublicId string `json:"typePublicId"` + DomainId string `json:"domainId"` +} + +// AddBusinessTermAssetResponse is the response from creating a business term asset. +type AddBusinessTermAssetResponse struct { + Id string `json:"id"` +} + +// AddBusinessTermAttributeRequest is the request body for adding an attribute to an asset. +type AddBusinessTermAttributeRequest struct { + AssetId string `json:"assetId"` + TypeId string `json:"typeId"` + Value string `json:"value"` +} + +// AddBusinessTermAttributeResponse is the response from adding an attribute to an asset. +type AddBusinessTermAttributeResponse struct { + Id string `json:"id"` +} + +// CreateBusinessTermAsset creates a new business term asset via POST /rest/2.0/assets. +func CreateBusinessTermAsset(ctx context.Context, client *http.Client, req AddBusinessTermAssetRequest) (*AddBusinessTermAssetResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshaling business term asset request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/assets", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating business term asset request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("creating business term asset: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("creating business term asset: unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + var result AddBusinessTermAssetResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding business term asset response: %w", err) + } + + return &result, nil +} + +// CreateBusinessTermAttribute adds an attribute to an asset via POST /rest/2.0/attributes. +func CreateBusinessTermAttribute(ctx context.Context, client *http.Client, req AddBusinessTermAttributeRequest) (*AddBusinessTermAttributeResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshaling business term attribute request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/attributes", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating business term attribute request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("creating business term attribute: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("creating business term attribute: unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + var result AddBusinessTermAttributeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding business term attribute response: %w", err) + } + + return &result, nil +} diff --git a/pkg/clients/create_asset_client.go b/pkg/clients/create_asset_client.go new file mode 100644 index 0000000..2da08de --- /dev/null +++ b/pkg/clients/create_asset_client.go @@ -0,0 +1,160 @@ +package clients + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// CreateAssetRequest is the request body for POST /rest/2.0/assets. +type CreateAssetRequest struct { + Name string `json:"name"` + TypeID string `json:"typeId"` + DomainID string `json:"domainId"` + DisplayName string `json:"displayName,omitempty"` + ExcludeFromAutoHyperlinking bool `json:"excludeFromAutoHyperlinking,omitempty"` +} + +// CreateAssetResponse is the response from POST /rest/2.0/assets. +type CreateAssetResponse struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Type CreateAssetTypeRef `json:"type"` + Domain CreateAssetDomainRef `json:"domain"` + CreatedBy string `json:"createdBy"` + CreatedOn int64 `json:"createdOn"` + LastModifiedBy string `json:"lastModifiedBy"` + LastModifiedOn int64 `json:"lastModifiedOn"` +} + +// CreateAssetTypeRef is a reference to an asset type in a create asset response. +type CreateAssetTypeRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CreateAssetDomainRef is a reference to a domain in a create asset response. +type CreateAssetDomainRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CreateAttributeRequest is the request body for POST /rest/2.0/attributes. +type CreateAttributeRequest struct { + AssetID string `json:"assetId"` + TypeID string `json:"typeId"` + Value string `json:"value"` +} + +// CreateAttributeResponse is the response from POST /rest/2.0/attributes. +type CreateAttributeResponse struct { + ID string `json:"id"` + Type CreateAttributeTypeRef `json:"type"` + Asset CreateAttributeAssetRef `json:"asset"` + Value string `json:"value"` +} + +// CreateAttributeTypeRef is a reference to an attribute type. +type CreateAttributeTypeRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CreateAttributeAssetRef is a reference to an asset in an attribute response. +type CreateAttributeAssetRef struct { + ID string `json:"id"` +} + +// CreateAsset creates a new asset via POST /rest/2.0/assets. +func CreateAsset(ctx context.Context, client *http.Client, request CreateAssetRequest) (*CreateAssetResponse, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("creating asset: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/rest/2.0/assets", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating 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("creating asset: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("creating asset: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + switch resp.StatusCode { + case http.StatusBadRequest: + return nil, fmt.Errorf("creating asset: bad request (invalid parameters or duplicate name): %s", string(respBody)) + case http.StatusForbidden: + return nil, fmt.Errorf("creating asset: asset type not allowed in domain: %s", string(respBody)) + case http.StatusNotFound: + return nil, fmt.Errorf("creating asset: invalid assetTypeId or domainId: %s", string(respBody)) + default: + return nil, fmt.Errorf("creating asset: unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + } + + var result CreateAssetResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("creating asset: decoding response: %w", err) + } + + return &result, nil +} + +// CreateAttribute creates a new attribute on an asset via POST /rest/2.0/attributes. +func CreateAttribute(ctx context.Context, client *http.Client, request CreateAttributeRequest) (*CreateAttributeResponse, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("creating 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("creating 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("creating attribute: sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("creating attribute: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + switch resp.StatusCode { + case http.StatusBadRequest: + return nil, fmt.Errorf("creating attribute: bad request (invalid parameters): %s", string(respBody)) + case http.StatusNotFound: + return nil, fmt.Errorf("creating attribute: asset or attribute type not found: %s", string(respBody)) + default: + return nil, fmt.Errorf("creating attribute: unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + } + + var result CreateAttributeResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("creating attribute: decoding response: %w", err) + } + + return &result, nil +} diff --git a/pkg/clients/dgc_relation_client.go b/pkg/clients/dgc_relation_client.go index 747cd21..b1d4ab2 100644 --- a/pkg/clients/dgc_relation_client.go +++ b/pkg/clients/dgc_relation_client.go @@ -10,12 +10,12 @@ import ( // Well-known Collibra UUIDs for relation and attribute types. const ( - DefinitionAttributeTypeID = "00000000-0000-0000-0000-000000003008" - DataAttributeRepresentsMeasureRelID = "00000000-0000-0000-0000-000000007200" - GenericConnectedAssetRelID = "00000000-0000-0000-0000-000000007038" - ColumnToTableRelID = "00000000-0000-0000-0000-000000007042" - DataAttributeRelID1 = "00000000-0000-0000-0000-000000007094" - DataAttributeRelID2 = "cd000000-0000-0000-0000-000000000023" + DefinitionAttributeTypeID = "00000000-0000-0000-0000-000000000202" + MeasureIsCalculatedUsingDataElementRelID = "00000000-0000-0000-0000-000000007200" + BusinessAssetRepresentsDataAssetRelID = "00000000-0000-0000-0000-000000007038" + ColumnIsPartOfTableRelID = "00000000-0000-0000-0000-000000007042" + DataAttributeRepresentsColumnRelID = "00000000-0000-0000-0000-000000007094" + ColumnIsSourceForDataAttributeRelID = "00000000-0000-0000-0000-120000000011" ) type RelationsQueryParams struct { @@ -164,7 +164,7 @@ func FindColumnsForDataAttribute(ctx context.Context, client *http.Client, dataA seen := make(map[string]struct{}) result := make([]ConnectedAsset, 0) - for _, relID := range []string{DataAttributeRelID1, DataAttributeRelID2} { + for _, relID := range []string{DataAttributeRepresentsColumnRelID, ColumnIsSourceForDataAttributeRelID} { assets, err := FindConnectedAssets(ctx, client, dataAttributeID, relID) if err != nil { return nil, err diff --git a/pkg/clients/prepare_add_business_term_client.go b/pkg/clients/prepare_add_business_term_client.go new file mode 100644 index 0000000..b522e1b --- /dev/null +++ b/pkg/clients/prepare_add_business_term_client.go @@ -0,0 +1,347 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// --- Domain types --- + +// PrepareAddBusinessTermDomain represents a Collibra domain. +type PrepareAddBusinessTermDomain struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PrepareAddBusinessTermDomainsResponse is the paged response for listing domains. +type PrepareAddBusinessTermDomainsResponse struct { + Total int `json:"total"` + Results []PrepareAddBusinessTermDomain `json:"results"` +} + +// --- Asset type --- + +// PrepareAddBusinessTermAssetType represents a Collibra asset type. +type PrepareAddBusinessTermAssetType struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// --- Assignments --- + +// PrepareAddBusinessTermAssignmentTypeRef is a reference to an attribute type within an assignment. +type PrepareAddBusinessTermAssignmentTypeRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PrepareAddBusinessTermAssignment represents a flattened attribute assignment for an asset type. +// This is derived from the raw API response which nests characteristic type references. +type PrepareAddBusinessTermAssignment struct { + ID string `json:"id"` + AttributeType PrepareAddBusinessTermAssignmentTypeRef `json:"attributeType"` + Min int `json:"min"` + Max int `json:"max"` +} + +// prepareAddBusinessTermResourceRef is a resource reference with a discriminator. +type prepareAddBusinessTermResourceRef struct { + ID string `json:"id"` + Name string `json:"name"` + ResourceDiscriminator string `json:"resourceDiscriminator"` +} + +// prepareAddBusinessTermCharacteristicTypeRef represents a characteristic type reference from the API. +type prepareAddBusinessTermCharacteristicTypeRef struct { + ID string `json:"id"` + AssignedResourceReference prepareAddBusinessTermResourceRef `json:"assignedResourceReference"` + MinimumOccurrences int `json:"minimumOccurrences"` + MaximumOccurrences *int `json:"maximumOccurrences"` +} + +// prepareAddBusinessTermRawAssignment represents the raw API response for an assignment. +type prepareAddBusinessTermRawAssignment struct { + ID string `json:"id"` + AssignedCharacteristicTypeReferences []prepareAddBusinessTermCharacteristicTypeRef `json:"assignedCharacteristicTypeReferences"` +} + +// --- Attribute type --- + +// PrepareAddBusinessTermRelationType represents relation type information within an attribute type. +type PrepareAddBusinessTermRelationType struct { + ID string `json:"id"` + Role string `json:"role"` + CoRole string `json:"coRole"` + Direction string `json:"direction"` + TargetType PrepareAddBusinessTermAssignmentTypeRef `json:"targetType"` + SourceType PrepareAddBusinessTermAssignmentTypeRef `json:"sourceType"` +} + +// PrepareAddBusinessTermConstraints represents validation constraints for an attribute type. +type PrepareAddBusinessTermConstraints struct { + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` +} + +// PrepareAddBusinessTermAttributeType represents a full attribute type with schema details. +// Fields are mapped from the Collibra API response where the "kind" comes from +// attributeTypeDiscriminator and structural fields like constraints/relationType +// are not part of the standard attribute type API response. +type PrepareAddBusinessTermAttributeType struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Required bool `json:"required"` + AllowedValues []string `json:"allowedValues"` + Constraints *PrepareAddBusinessTermConstraints `json:"constraints,omitempty"` + Description string `json:"description"` + RelationType *PrepareAddBusinessTermRelationType `json:"relationType,omitempty"` +} + +// prepareAddBusinessTermRawAttributeType represents the raw API response for an attribute type. +type prepareAddBusinessTermRawAttributeType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AttributeTypeDiscriminator string `json:"attributeTypeDiscriminator"` + StringType string `json:"stringType,omitempty"` + AllowedValues []string `json:"allowedValues,omitempty"` +} + +// --- Assets for duplicate check --- + +// PrepareAddBusinessTermAsset represents an asset returned from a search. +type PrepareAddBusinessTermAsset struct { + ID string `json:"id"` + Name string `json:"name"` + Domain PrepareAddBusinessTermDomain `json:"domain"` +} + +// PrepareAddBusinessTermAssetsResponse is the paged response for searching assets. +type PrepareAddBusinessTermAssetsResponse struct { + Total int `json:"total"` + Results []PrepareAddBusinessTermAsset `json:"results"` +} + +// --- Client Functions --- + +// PrepareAddBusinessTermListDomains lists all available domains. +func PrepareAddBusinessTermListDomains(ctx context.Context, client *http.Client) ([]PrepareAddBusinessTermDomain, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/rest/2.0/domains", nil) + if err != nil { + return nil, fmt.Errorf("creating list domains request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("listing domains: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("listing domains: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareAddBusinessTermDomainsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding domains response: %w", err) + } + + return result.Results, nil +} + +// PrepareAddBusinessTermGetDomain gets a specific domain by ID. +func PrepareAddBusinessTermGetDomain(ctx context.Context, client *http.Client, domainID string) (*PrepareAddBusinessTermDomain, error) { + path := fmt.Sprintf("/rest/2.0/domains/%s", url.PathEscape(domainID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("creating get domain request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting domain %s: %w", domainID, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting domain %s: status %d: %s", domainID, resp.StatusCode, string(body)) + } + + var domain PrepareAddBusinessTermDomain + if err := json.NewDecoder(resp.Body).Decode(&domain); err != nil { + return nil, fmt.Errorf("decoding domain response: %w", err) + } + + return &domain, nil +} + +// PrepareAddBusinessTermGetAssetType gets an asset type by its public ID. +func PrepareAddBusinessTermGetAssetType(ctx context.Context, client *http.Client, publicID string) (*PrepareAddBusinessTermAssetType, error) { + path := fmt.Sprintf("/rest/2.0/assetTypes/publicId/%s", url.PathEscape(publicID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("creating get asset type request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting asset type %s: %w", publicID, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting asset type %s: status %d: %s", publicID, resp.StatusCode, string(body)) + } + + var assetType PrepareAddBusinessTermAssetType + if err := json.NewDecoder(resp.Body).Decode(&assetType); err != nil { + return nil, fmt.Errorf("decoding asset type response: %w", err) + } + + return &assetType, nil +} + +// PrepareAddBusinessTermGetAssignments gets attribute assignments for an asset type. +// The Collibra API returns a plain JSON array of assignment objects, each containing +// nested assignedCharacteristicTypeReferences. This function flattens them into +// a list of PrepareAddBusinessTermAssignment for easier consumption. +func PrepareAddBusinessTermGetAssignments(ctx context.Context, client *http.Client, assetTypeID string) ([]PrepareAddBusinessTermAssignment, error) { + path := fmt.Sprintf("/rest/2.0/assignments/assetType/%s", url.PathEscape(assetTypeID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("creating get assignments request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting assignments for asset type %s: %w", assetTypeID, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting assignments for asset type %s: status %d: %s", assetTypeID, resp.StatusCode, string(body)) + } + + var rawAssignments []prepareAddBusinessTermRawAssignment + if err := json.NewDecoder(resp.Body).Decode(&rawAssignments); err != nil { + return nil, fmt.Errorf("decoding assignments response: %w", err) + } + + // Flatten: extract each characteristic type reference as an individual assignment. + // Only include attribute types (discriminator ending with "AttributeType"), + // skipping relation types and complex relation types. + var assignments []PrepareAddBusinessTermAssignment + for _, raw := range rawAssignments { + for _, ref := range raw.AssignedCharacteristicTypeReferences { + disc := ref.AssignedResourceReference.ResourceDiscriminator + if disc != "" && !strings.HasSuffix(disc, "AttributeType") { + continue + } + maxVal := 0 + if ref.MaximumOccurrences != nil { + maxVal = *ref.MaximumOccurrences + } + assignments = append(assignments, PrepareAddBusinessTermAssignment{ + ID: ref.ID, + AttributeType: PrepareAddBusinessTermAssignmentTypeRef{ + ID: ref.AssignedResourceReference.ID, + Name: ref.AssignedResourceReference.Name, + }, + Min: ref.MinimumOccurrences, + Max: maxVal, + }) + } + } + + return assignments, nil +} + +// PrepareAddBusinessTermGetAttributeType gets the full attribute type schema by ID. +// The Collibra API returns attributeTypeDiscriminator (e.g. "StringAttributeType") +// which is mapped to the Kind field. Fields like constraints and relationType are +// not part of the standard API response and will be nil. +func PrepareAddBusinessTermGetAttributeType(ctx context.Context, client *http.Client, id string) (*PrepareAddBusinessTermAttributeType, error) { + path := fmt.Sprintf("/rest/2.0/attributeTypes/%s", url.PathEscape(id)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("creating get attribute type request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting attribute type %s: %w", id, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting attribute type %s: status %d: %s", id, resp.StatusCode, string(body)) + } + + var raw prepareAddBusinessTermRawAttributeType + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decoding attribute type response: %w", err) + } + + return &PrepareAddBusinessTermAttributeType{ + ID: raw.ID, + Name: raw.Name, + Kind: raw.AttributeTypeDiscriminator, + Description: raw.Description, + AllowedValues: raw.AllowedValues, + }, nil +} + +// PrepareAddBusinessTermSearchAssets searches for assets by name and type ID for duplicate detection. +func PrepareAddBusinessTermSearchAssets(ctx context.Context, client *http.Client, name string, typeID string) ([]PrepareAddBusinessTermAsset, error) { + reqURL := "/rest/2.0/assets" + params := url.Values{} + if name != "" { + params.Set("name", name) + } + if typeID != "" { + params.Set("typeId", typeID) + } + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating search assets request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("searching assets: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("searching assets: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareAddBusinessTermAssetsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding assets response: %w", err) + } + + return result.Results, nil +} diff --git a/pkg/clients/prepare_create_asset_client.go b/pkg/clients/prepare_create_asset_client.go new file mode 100644 index 0000000..094be69 --- /dev/null +++ b/pkg/clients/prepare_create_asset_client.go @@ -0,0 +1,279 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// PrepareCreateAssetStatus represents the status of asset creation readiness. +type PrepareCreateAssetStatus string + +const ( + StatusReady PrepareCreateAssetStatus = "ready" + StatusIncomplete PrepareCreateAssetStatus = "incomplete" + StatusNeedsClarification PrepareCreateAssetStatus = "needs_clarification" + StatusDuplicateFound PrepareCreateAssetStatus = "duplicate_found" +) + +// PrepareCreateAssetType represents an asset type from the API. +type PrepareCreateAssetType struct { + ID string `json:"id"` + PublicID string `json:"publicId"` + Name string `json:"name"` +} + +// PrepareCreateAssetTypeListResponse is the response from listing asset types. +type PrepareCreateAssetTypeListResponse struct { + Results []PrepareCreateAssetType `json:"results"` + Total int `json:"total"` +} + +// PrepareCreateDomain represents a domain from the API. +type PrepareCreateDomain struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PrepareCreateDomainListResponse is the response from listing domains. +type PrepareCreateDomainListResponse struct { + Results []PrepareCreateDomain `json:"results"` + Total int `json:"total"` +} + + +// PrepareCreateAttributeType represents an attribute type with full schema. +type PrepareCreateAttributeType struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Required bool `json:"required"` + Constraints *PrepareCreateConstraints `json:"constraints,omitempty"` + AllowedValues []string `json:"allowedValues,omitempty"` + Direction string `json:"direction,omitempty"` + TargetAssetType *PrepareCreateAssetType `json:"targetAssetType,omitempty"` +} + +// PrepareCreateConstraints represents attribute validation constraints. +type PrepareCreateConstraints struct { + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` +} + +// PrepareCreateAssetResult represents an existing asset found during duplicate check. +type PrepareCreateAssetResult struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PrepareCreateAssetSearchResponse is the response from searching assets. +type PrepareCreateAssetSearchResponse struct { + Results []PrepareCreateAssetResult `json:"results"` + Total int `json:"total"` +} + +// ListAssetTypesForPrepare lists asset types, limited to the given count. +func ListAssetTypesForPrepare(ctx context.Context, client *http.Client, limit int) ([]PrepareCreateAssetType, int, error) { + reqURL := fmt.Sprintf("/rest/2.0/assetTypes?limit=%d&offset=0", limit) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, 0, fmt.Errorf("creating list asset types request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("listing asset types: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, 0, fmt.Errorf("listing asset types: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareCreateAssetTypeListResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, 0, fmt.Errorf("decoding asset types response: %w", err) + } + return result.Results, result.Total, nil +} + +// GetAssetTypeByPublicID resolves an asset type by its publicId. +func GetAssetTypeByPublicID(ctx context.Context, client *http.Client, publicID string) (*PrepareCreateAssetType, error) { + reqURL := fmt.Sprintf("/rest/2.0/assetTypes/publicId/%s", url.PathEscape(publicID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating get asset type request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting asset type: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("asset type with publicId %q not found", publicID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting asset type: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareCreateAssetType + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding asset type response: %w", err) + } + return &result, nil +} + +// ListDomainsForPrepare lists domains, limited to the given count. +func ListDomainsForPrepare(ctx context.Context, client *http.Client, limit int) ([]PrepareCreateDomain, int, error) { + reqURL := fmt.Sprintf("/rest/2.0/domains?limit=%d&offset=0", limit) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, 0, fmt.Errorf("creating list domains request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("listing domains: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, 0, fmt.Errorf("listing domains: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareCreateDomainListResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, 0, fmt.Errorf("decoding domains response: %w", err) + } + return result.Results, result.Total, nil +} + +// GetDomainByID gets a specific domain by its ID. +func GetDomainByID(ctx context.Context, client *http.Client, domainID string) (*PrepareCreateDomain, 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("creating get domain request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting domain: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("domain with id %q not found", domainID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting domain: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareCreateDomain + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding domain response: %w", err) + } + return &result, nil +} + +// GetAvailableAssetTypesForDomain returns the asset types allowed in a given domain. +func GetAvailableAssetTypesForDomain(ctx context.Context, client *http.Client, domainID string) ([]PrepareCreateAssetType, error) { + reqURL := fmt.Sprintf("/rest/2.0/assignments/domain/%s/assetTypes", url.PathEscape(domainID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating get available asset types request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting available asset types for domain: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting available asset types for domain: status %d: %s", resp.StatusCode, string(body)) + } + + var result []PrepareCreateAssetType + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding available asset types response: %w", err) + } + return result, nil +} + +// GetAttributeTypeByID gets the full attribute type schema by ID. +func GetAttributeTypeByID(ctx context.Context, client *http.Client, attrTypeID string) (*PrepareCreateAttributeType, error) { + reqURL := fmt.Sprintf("/rest/2.0/attributeTypes/%s", url.PathEscape(attrTypeID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating get attribute type request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting attribute type: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting attribute type: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareCreateAttributeType + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding attribute type response: %w", err) + } + return &result, nil +} + +// SearchAssetsForDuplicate searches for existing assets by name, type, and domain. +func SearchAssetsForDuplicate(ctx context.Context, client *http.Client, name string, assetTypeID string, domainID string) ([]PrepareCreateAssetResult, error) { + params := url.Values{} + params.Set("name", name) + params.Set("typeId", assetTypeID) + params.Set("domainId", domainID) + params.Set("limit", "1") + + reqURL := fmt.Sprintf("/rest/2.0/assets?%s", params.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating search assets request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("searching assets: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("searching assets: status %d: %s", resp.StatusCode, string(body)) + } + + var result PrepareCreateAssetSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding asset search response: %w", err) + } + return result.Results, nil +} diff --git a/pkg/tools/add_business_term/tool.go b/pkg/tools/add_business_term/tool.go new file mode 100644 index 0000000..d34b9b7 --- /dev/null +++ b/pkg/tools/add_business_term/tool.go @@ -0,0 +1,87 @@ +package add_business_term + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +const ( + // BusinessTermTypeID is the fixed type public ID for Business Term assets. + BusinessTermTypeID = "BusinessTerm" + // DefinitionAttributeTypeID is the type ID for the Definition attribute. + DefinitionAttributeTypeID = "00000000-0000-0000-0000-000000000202" +) + +// InputAttribute represents an additional attribute to add to the business term. +type InputAttribute struct { + TypeId string `json:"typeId" jsonschema:"UUID of the attribute type"` + Value string `json:"value" jsonschema:"Value for the attribute"` +} + +// Input is the input for the add_business_term tool. +type Input struct { + Name string `json:"name" jsonschema:"Name of the business term to create"` + DomainId string `json:"domainId" jsonschema:"UUID of the domain to create the business term in"` + Definition string `json:"definition,omitempty" jsonschema:"Optional. Definition text for the business term"` + Attributes []InputAttribute `json:"attributes,omitempty" jsonschema:"Optional. Additional attributes to add to the business term, each with a type_id and value"` +} + +// Output is the output of the add_business_term tool. +type Output struct { + AssetId string `json:"assetId" jsonschema:"UUID of the created business term asset"` +} + +// NewTool creates a new add_business_term tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "add_business_term", + Description: "Create a business term asset with definition and optional attributes in Collibra.", + Handler: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + // Step 1: Create the business term asset + assetResp, err := clients.CreateBusinessTermAsset(ctx, collibraClient, clients.AddBusinessTermAssetRequest{ + Name: input.Name, + TypePublicId: BusinessTermTypeID, + DomainId: input.DomainId, + }) + if err != nil { + return Output{}, err + } + + assetId := assetResp.Id + + // Step 2: Add definition attribute if provided + if input.Definition != "" { + _, err := clients.CreateBusinessTermAttribute(ctx, collibraClient, clients.AddBusinessTermAttributeRequest{ + AssetId: assetId, + TypeId: DefinitionAttributeTypeID, + Value: input.Definition, + }) + if err != nil { + return Output{}, err + } + } + + // Step 3: Add additional attributes if provided + for _, attr := range input.Attributes { + _, err := clients.CreateBusinessTermAttribute(ctx, collibraClient, clients.AddBusinessTermAttributeRequest{ + AssetId: assetId, + TypeId: attr.TypeId, + Value: attr.Value, + }) + if err != nil { + return Output{}, err + } + } + + return Output{AssetId: assetId}, nil + } +} diff --git a/pkg/tools/add_business_term/tool_test.go b/pkg/tools/add_business_term/tool_test.go new file mode 100644 index 0000000..90a6339 --- /dev/null +++ b/pkg/tools/add_business_term/tool_test.go @@ -0,0 +1,178 @@ +package add_business_term_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/add_business_term" + "github.com/collibra/chip/pkg/tools/testutil" +) + +func TestAddBusinessTermSuccess(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.AddBusinessTermAssetRequest) (int, clients.AddBusinessTermAssetResponse) { + if req.Name != "Revenue" { + t.Errorf("expected name 'Revenue', got '%s'", req.Name) + } + if req.TypePublicId != "BusinessTerm" { + t.Errorf("expected typePublicId 'BusinessTerm', got '%s'", req.TypePublicId) + } + if req.DomainId != "domain-uuid-123" { + t.Errorf("expected domainId 'domain-uuid-123', got '%s'", req.DomainId) + } + return http.StatusCreated, clients.AddBusinessTermAssetResponse{Id: "new-asset-uuid-456"} + })) + + handler.Handle("/rest/2.0/attributes", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.AddBusinessTermAttributeRequest) (int, clients.AddBusinessTermAttributeResponse) { + if req.AssetId != "new-asset-uuid-456" { + t.Errorf("expected assetId 'new-asset-uuid-456', got '%s'", req.AssetId) + } + if req.TypeId != "00000000-0000-0000-0000-000000000202" { + t.Errorf("expected definition typeId '00000000-0000-0000-0000-000000000202', got '%s'", req.TypeId) + } + if req.Value != "Total income generated from sales" { + t.Errorf("expected value 'Total income generated from sales', got '%s'", req.Value) + } + return http.StatusCreated, clients.AddBusinessTermAttributeResponse{Id: "attr-uuid-789"} + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := add_business_term.NewTool(client).Handler(t.Context(), add_business_term.Input{ + Name: "Revenue", + DomainId: "domain-uuid-123", + Definition: "Total income generated from sales", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if output.AssetId != "new-asset-uuid-456" { + t.Errorf("expected asset_id 'new-asset-uuid-456', got '%s'", output.AssetId) + } +} + +func TestAddBusinessTermAssetCreationError(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"duplicate term name"}`)) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := add_business_term.NewTool(client).Handler(t.Context(), add_business_term.Input{ + Name: "Revenue", + DomainId: "invalid-domain", + }) + if err == nil { + t.Fatal("expected error for bad request, got nil") + } +} + +func TestAddBusinessTermNoDefinitionNoAttributes(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.AddBusinessTermAssetRequest) (int, clients.AddBusinessTermAssetResponse) { + return http.StatusCreated, clients.AddBusinessTermAssetResponse{Id: "asset-no-def-123"} + })) + + attributeCalled := false + handler.Handle("/rest/2.0/attributes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attributeCalled = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"should-not-be-called"}`)) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := add_business_term.NewTool(client).Handler(t.Context(), add_business_term.Input{ + Name: "Simple Term", + DomainId: "domain-uuid-456", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if output.AssetId != "asset-no-def-123" { + t.Errorf("expected asset_id 'asset-no-def-123', got '%s'", output.AssetId) + } + if attributeCalled { + t.Error("expected attributes endpoint not to be called when no definition or attributes provided") + } +} + +func TestAddBusinessTermWithAdditionalAttributes(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.AddBusinessTermAssetRequest) (int, clients.AddBusinessTermAssetResponse) { + return http.StatusCreated, clients.AddBusinessTermAssetResponse{Id: "asset-with-attrs-789"} + })) + + attrCount := 0 + handler.Handle("/rest/2.0/attributes", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.AddBusinessTermAttributeRequest) (int, clients.AddBusinessTermAttributeResponse) { + attrCount++ + if req.AssetId != "asset-with-attrs-789" { + t.Errorf("expected assetId 'asset-with-attrs-789', got '%s'", req.AssetId) + } + return http.StatusCreated, clients.AddBusinessTermAttributeResponse{Id: "attr-" + req.TypeId} + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := add_business_term.NewTool(client).Handler(t.Context(), add_business_term.Input{ + Name: "Complex Term", + DomainId: "domain-uuid-789", + Definition: "A complex business term", + Attributes: []add_business_term.InputAttribute{ + {TypeId: "custom-type-1", Value: "custom value 1"}, + {TypeId: "custom-type-2", Value: "custom value 2"}, + }, + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if output.AssetId != "asset-with-attrs-789" { + t.Errorf("expected asset_id 'asset-with-attrs-789', got '%s'", output.AssetId) + } + // 1 definition + 2 additional attributes = 3 total + if attrCount != 3 { + t.Errorf("expected 3 attribute calls, got %d", attrCount) + } +} + +func TestAddBusinessTermAttributeCreationError(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.AddBusinessTermAssetRequest) (int, clients.AddBusinessTermAssetResponse) { + return http.StatusCreated, clients.AddBusinessTermAssetResponse{Id: "asset-attr-err-123"} + })) + + handler.Handle("/rest/2.0/attributes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"internal server error"}`)) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := add_business_term.NewTool(client).Handler(t.Context(), add_business_term.Input{ + Name: "Failing Term", + DomainId: "domain-uuid-123", + Definition: "This should fail on attribute creation", + }) + if err == nil { + t.Fatal("expected error when attribute creation fails, got nil") + } +} diff --git a/pkg/tools/add_data_classification_match.go b/pkg/tools/add_data_classification_match/tool.go similarity index 62% rename from pkg/tools/add_data_classification_match.go rename to pkg/tools/add_data_classification_match/tool.go index e3afa06..dacd03c 100644 --- a/pkg/tools/add_data_classification_match.go +++ b/pkg/tools/add_data_classification_match/tool.go @@ -1,4 +1,4 @@ -package tools +package add_data_classification_match import ( "context" @@ -10,29 +10,29 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type AddDataClassificationMatchInput struct { +type Input struct { AssetID string `json:"assetId" jsonschema:"Required. The UUID of the asset to classify (e.g., '9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8')"` ClassificationID string `json:"classificationId" jsonschema:"Required. The UUID of the data classification/data class to apply (e.g., 'be45c001-b173-48ff-ac91-3f6e45868c8b')"` } -type AddDataClassificationMatchOutput struct { +type Output struct { Match *clients.DataClassificationMatch `json:"match,omitempty" jsonschema:"The created classification match with all its properties"` Success bool `json:"success" jsonschema:"Whether the classification was successfully applied to the asset"` Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed"` } -func NewAddDataClassificationMatchTool(collibraClient *http.Client) *chip.Tool[AddDataClassificationMatchInput, AddDataClassificationMatchOutput] { - return &chip.Tool[AddDataClassificationMatchInput, AddDataClassificationMatchOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "add_data_classification_match", Description: "Associate a data classification (data class) with a specific data asset in Collibra. Requires both the asset UUID and the classification UUID.", - Handler: handleAddClassificationMatch(collibraClient), + Handler: handler(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog"}, } } -func handleAddClassificationMatch(collibraClient *http.Client) chip.ToolHandlerFunc[AddDataClassificationMatchInput, AddDataClassificationMatchOutput] { - return func(ctx context.Context, input AddDataClassificationMatchInput) (AddDataClassificationMatchOutput, error) { - output, isNotValid := validateClassificationMatchInput(input) +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + output, isNotValid := validateInput(input) if isNotValid { return output, nil } @@ -44,33 +44,33 @@ func handleAddClassificationMatch(collibraClient *http.Client) chip.ToolHandlerF match, err := clients.AddDataClassificationMatch(ctx, collibraClient, request) if err != nil { - return AddDataClassificationMatchOutput{ + return Output{ Success: false, Error: fmt.Sprintf("Failed to add classification match: %s", err.Error()), }, nil } - return AddDataClassificationMatchOutput{ + return Output{ Match: match, Success: true, }, nil } } -func validateClassificationMatchInput(input AddDataClassificationMatchInput) (AddDataClassificationMatchOutput, bool) { +func validateInput(input Input) (Output, bool) { if strings.TrimSpace(input.AssetID) == "" { - return AddDataClassificationMatchOutput{ + return Output{ Success: false, Error: "Asset ID is required", }, true } if strings.TrimSpace(input.ClassificationID) == "" { - return AddDataClassificationMatchOutput{ + return Output{ Success: false, Error: "Classification ID is required", }, true } - return AddDataClassificationMatchOutput{}, false + return Output{}, false } diff --git a/pkg/tools/add_data_classification_match_test.go b/pkg/tools/add_data_classification_match/tool_test.go similarity index 80% rename from pkg/tools/add_data_classification_match_test.go rename to pkg/tools/add_data_classification_match/tool_test.go index ca35e47..67ae6aa 100644 --- a/pkg/tools/add_data_classification_match_test.go +++ b/pkg/tools/add_data_classification_match/tool_test.go @@ -1,16 +1,17 @@ -package tools_test +package add_data_classification_match_test import ( "net/http" "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/add_data_classification_match" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestAddClassificationMatch_Success(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/rest/catalog/1.0/dataClassification/classificationMatches", StringHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/rest/catalog/1.0/dataClassification/classificationMatches", testutil.StringHandlerOut(func(r *http.Request) (int, string) { if r.Method != "POST" { t.Errorf("Expected POST request, got %s", r.Method) } @@ -39,14 +40,14 @@ func TestAddClassificationMatch_Success(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - input := tools.AddDataClassificationMatchInput{ + input := tools.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -76,11 +77,11 @@ func TestAddClassificationMatch_Success(t *testing.T) { func TestAddClassificationMatch_MissingAssetID(t *testing.T) { client := &http.Client{} - input := tools.AddDataClassificationMatchInput{ + input := tools.Input{ ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -98,11 +99,11 @@ func TestAddClassificationMatch_MissingAssetID(t *testing.T) { func TestAddClassificationMatch_MissingClassificationID(t *testing.T) { client := &http.Client{} - input := tools.AddDataClassificationMatchInput{ + input := tools.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -127,14 +128,14 @@ func TestAddClassificationMatch_AssetNotFound(t *testing.T) { })) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - input := tools.AddDataClassificationMatchInput{ + input := tools.Input{ AssetID: "00000000-0000-0000-0000-000000000000", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -159,14 +160,14 @@ func TestAddClassificationMatch_AlreadyExists(t *testing.T) { })) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - input := tools.AddDataClassificationMatchInput{ + input := tools.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) diff --git a/pkg/tools/create_asset/tool.go b/pkg/tools/create_asset/tool.go new file mode 100644 index 0000000..00825c9 --- /dev/null +++ b/pkg/tools/create_asset/tool.go @@ -0,0 +1,67 @@ +package create_asset + +import ( + "context" + "fmt" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +// Input defines the parameters for the create_asset tool. +type Input struct { + Name string `json:"name" jsonschema:"The name of the asset to create"` + AssetTypeID string `json:"assetTypeId" jsonschema:"The UUID of the asset type (from prepare_create_asset resolved.assetTypeId)"` + DomainID string `json:"domainId" jsonschema:"The UUID of the domain to create the asset in (from prepare_create_asset resolved.domainId)"` + DisplayName string `json:"displayName,omitempty" jsonschema:"Optional. The display name of the asset"` + Attributes map[string]string `json:"attributes,omitempty" jsonschema:"Optional. Map of attribute type UUID to attribute value"` +} + +// Output defines the result of the create_asset tool. +type Output struct { + AssetID string `json:"assetId" jsonschema:"The UUID of the newly created asset"` +} + +// NewTool creates a new create_asset tool instance. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "create_asset", + Description: "Create a new data asset with optional attributes in Collibra.", + Handler: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + // Create the asset + assetReq := clients.CreateAssetRequest{ + Name: input.Name, + TypeID: input.AssetTypeID, + DomainID: input.DomainID, + DisplayName: input.DisplayName, + } + + assetResp, err := clients.CreateAsset(ctx, collibraClient, assetReq) + if err != nil { + return Output{}, err + } + + // If attributes are provided, create each one + for attrTypeID, attrValue := range input.Attributes { + attrReq := clients.CreateAttributeRequest{ + AssetID: assetResp.ID, + TypeID: attrTypeID, + Value: attrValue, + } + + _, err := clients.CreateAttribute(ctx, collibraClient, attrReq) + if err != nil { + return Output{}, fmt.Errorf("asset created (id=%s) but failed to add attribute (typeId=%s): %w", assetResp.ID, attrTypeID, err) + } + } + + return Output{AssetID: assetResp.ID}, nil + } +} diff --git a/pkg/tools/create_asset/tool_test.go b/pkg/tools/create_asset/tool_test.go new file mode 100644 index 0000000..efa00f5 --- /dev/null +++ b/pkg/tools/create_asset/tool_test.go @@ -0,0 +1,322 @@ +package create_asset_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/create_asset" + "github.com/collibra/chip/pkg/tools/testutil" +) + +func TestCreateAssetSuccess(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.CreateAssetRequest) (int, clients.CreateAssetResponse) { + return http.StatusCreated, clients.CreateAssetResponse{ + ID: "asset-uuid-123", + Name: req.Name, + DisplayName: req.Name, + Type: clients.CreateAssetTypeRef{ + ID: req.TypeID, + Name: "Business Term", + }, + Domain: clients.CreateAssetDomainRef{ + ID: req.DomainID, + Name: "Test Domain", + }, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "My New Asset", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.AssetID != "asset-uuid-123" { + t.Errorf("Expected asset ID 'asset-uuid-123', got: '%s'", output.AssetID) + } +} + +func TestCreateAssetWithAttributes(t *testing.T) { + attributesCreated := 0 + + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.CreateAssetRequest) (int, clients.CreateAssetResponse) { + return http.StatusCreated, clients.CreateAssetResponse{ + ID: "asset-uuid-123", + Name: req.Name, + DisplayName: req.Name, + Type: clients.CreateAssetTypeRef{ + ID: req.TypeID, + Name: "Business Term", + }, + Domain: clients.CreateAssetDomainRef{ + ID: req.DomainID, + Name: "Test Domain", + }, + } + })) + handler.Handle("POST /rest/2.0/attributes", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.CreateAttributeRequest) (int, clients.CreateAttributeResponse) { + attributesCreated++ + return http.StatusCreated, clients.CreateAttributeResponse{ + ID: "attr-uuid-" + req.TypeID, + Type: clients.CreateAttributeTypeRef{ + ID: req.TypeID, + Name: "Description", + }, + Asset: clients.CreateAttributeAssetRef{ + ID: req.AssetID, + }, + Value: req.Value, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "Asset With Attrs", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + Attributes: map[string]string{ + "attr-type-1": "Description value", + "attr-type-2": "Definition value", + }, + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.AssetID != "asset-uuid-123" { + t.Errorf("Expected asset ID 'asset-uuid-123', got: '%s'", output.AssetID) + } + + if attributesCreated != 2 { + t.Errorf("Expected 2 attributes created, got: %d", attributesCreated) + } +} + +func TestCreateAssetWithDisplayName(t *testing.T) { + var receivedDisplayName string + + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.CreateAssetRequest) (int, clients.CreateAssetResponse) { + receivedDisplayName = req.DisplayName + return http.StatusCreated, clients.CreateAssetResponse{ + ID: "asset-uuid-123", + Name: req.Name, + DisplayName: req.DisplayName, + Type: clients.CreateAssetTypeRef{ + ID: req.TypeID, + Name: "Business Term", + }, + Domain: clients.CreateAssetDomainRef{ + ID: req.DomainID, + Name: "Test Domain", + }, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "My Asset", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + DisplayName: "My Display Name", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if receivedDisplayName != "My Display Name" { + t.Errorf("Expected display name 'My Display Name', got: '%s'", receivedDisplayName) + } +} + +func TestCreateAssetBadRequest(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "duplicate asset name"}) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "Duplicate Asset", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + }) + if err == nil { + t.Fatal("Expected error for bad request, got nil") + } + + expectedSubstring := "bad request" + if got := err.Error(); !containsSubstring(got, expectedSubstring) { + t.Errorf("Expected error to contain '%s', got: '%s'", expectedSubstring, got) + } +} + +func TestCreateAssetNotFound(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "asset type not found"}) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "Test Asset", + AssetTypeID: "invalid-type-id", + DomainID: "domain-uuid-789", + }) + if err == nil { + t.Fatal("Expected error for not found, got nil") + } + + expectedSubstring := "invalid assetTypeId or domainId" + if got := err.Error(); !containsSubstring(got, expectedSubstring) { + t.Errorf("Expected error to contain '%s', got: '%s'", expectedSubstring, got) + } +} + +func TestCreateAssetForbidden(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "type not allowed in domain"}) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "Test Asset", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + }) + if err == nil { + t.Fatal("Expected error for forbidden, got nil") + } + + expectedSubstring := "type not allowed in domain" + if got := err.Error(); !containsSubstring(got, expectedSubstring) { + t.Errorf("Expected error to contain '%s', got: '%s'", expectedSubstring, got) + } +} + +func TestCreateAssetEmptyAttributes(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.CreateAssetRequest) (int, clients.CreateAssetResponse) { + return http.StatusCreated, clients.CreateAssetResponse{ + ID: "asset-uuid-empty-attrs", + Name: req.Name, + Type: clients.CreateAssetTypeRef{ + ID: req.TypeID, + Name: "Business Term", + }, + Domain: clients.CreateAssetDomainRef{ + ID: req.DomainID, + Name: "Test Domain", + }, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "Asset No Attrs", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + Attributes: map[string]string{}, + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.AssetID != "asset-uuid-empty-attrs" { + t.Errorf("Expected asset ID 'asset-uuid-empty-attrs', got: '%s'", output.AssetID) + } +} + +func TestCreateAssetAttributeFailure(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("POST /rest/2.0/assets", testutil.JsonHandlerInOut(func(_ *http.Request, req clients.CreateAssetRequest) (int, clients.CreateAssetResponse) { + return http.StatusCreated, clients.CreateAssetResponse{ + ID: "asset-uuid-123", + Name: req.Name, + Type: clients.CreateAssetTypeRef{ + ID: req.TypeID, + Name: "Business Term", + }, + Domain: clients.CreateAssetDomainRef{ + ID: req.DomainID, + Name: "Test Domain", + }, + } + })) + handler.Handle("POST /rest/2.0/attributes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "attribute type not found"}) + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := create_asset.NewTool(client).Handler(t.Context(), create_asset.Input{ + Name: "Asset With Bad Attr", + AssetTypeID: "type-uuid-456", + DomainID: "domain-uuid-789", + Attributes: map[string]string{ + "bad-attr-type": "some value", + }, + }) + if err == nil { + t.Fatal("Expected error for attribute creation failure, got nil") + } + + expectedSubstring := "failed to add attribute" + if got := err.Error(); !containsSubstring(got, expectedSubstring) { + t.Errorf("Expected error to contain '%s', got: '%s'", expectedSubstring, got) + } +} + +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/tools/discover_business_glossary.go b/pkg/tools/discover_business_glossary/tool.go similarity index 54% rename from pkg/tools/discover_business_glossary.go rename to pkg/tools/discover_business_glossary/tool.go index 9a72605..d29c7f4 100644 --- a/pkg/tools/discover_business_glossary.go +++ b/pkg/tools/discover_business_glossary/tool.go @@ -1,4 +1,4 @@ -package tools +package discover_business_glossary import ( "context" @@ -8,29 +8,29 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type AskGlossaryInput struct { +type Input struct { Question string `json:"input" jsonschema:"the question to ask the business glossary agent"` } -type AskGlossaryOutput struct { +type Output struct { Answer string `json:"output" jsonschema:"the answer from the business glossary agent"` } -func NewAskGlossaryTool(collibraHttpClient *http.Client) *chip.Tool[AskGlossaryInput, AskGlossaryOutput] { - return &chip.Tool[AskGlossaryInput, AskGlossaryOutput]{ +func NewTool(collibraHttpClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "discover_business_glossary", Description: "Perform a semantic search across business glossary content in Collibra. Ask natural language questions to discover business terms, acronyms, KPIs, and other business glossary content.", - Handler: handleAskGlossary(collibraHttpClient), + Handler: handler(collibraHttpClient), Permissions: []string{"dgc.ai-copilot"}, } } -func handleAskGlossary(collibraClient *http.Client) chip.ToolHandlerFunc[AskGlossaryInput, AskGlossaryOutput] { - return func(ctx context.Context, input AskGlossaryInput) (AskGlossaryOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { response, err := clients.AskGlossary(ctx, collibraClient, input.Question) if err != nil { - return AskGlossaryOutput{}, err + return Output{}, err } - return AskGlossaryOutput{Answer: response}, nil + return Output{Answer: response}, nil } } diff --git a/pkg/tools/discover_business_glossary_test.go b/pkg/tools/discover_business_glossary/tool_test.go similarity index 63% rename from pkg/tools/discover_business_glossary_test.go rename to pkg/tools/discover_business_glossary/tool_test.go index 8de4775..2fddb5f 100644 --- a/pkg/tools/discover_business_glossary_test.go +++ b/pkg/tools/discover_business_glossary/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package discover_business_glossary_test import ( "fmt" @@ -7,12 +7,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/discover_business_glossary" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestAskGlossary(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/rest/aiCopilot/v1/tools/askGlossary", JsonHandlerInOut(func(_ *http.Request, request clients.ToolRequest) (int, clients.ToolResponse) { + handler.Handle("/rest/aiCopilot/v1/tools/askGlossary", testutil.JsonHandlerInOut(func(_ *http.Request, request clients.ToolRequest) (int, clients.ToolResponse) { return http.StatusOK, clients.ToolResponse{ Content: []clients.ToolContent{ {Text: fmt.Sprintf("Q: %s, A: %s", request.Message.Content.Text, "Annual Recurring Revenue")}, @@ -23,8 +24,8 @@ func TestAskGlossary(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewAskGlossaryTool(client).Handler(t.Context(), tools.AskGlossaryInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Question: "What is the definition of ARR?", }) if err != nil { diff --git a/pkg/tools/discover_data_assets.go b/pkg/tools/discover_data_assets/tool.go similarity index 57% rename from pkg/tools/discover_data_assets.go rename to pkg/tools/discover_data_assets/tool.go index c588cf2..7ce2d31 100644 --- a/pkg/tools/discover_data_assets.go +++ b/pkg/tools/discover_data_assets/tool.go @@ -1,4 +1,4 @@ -package tools +package discover_data_assets import ( "context" @@ -8,30 +8,30 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type AskDadInput struct { +type Input struct { Question string `json:"input" jsonschema:"the question to ask the data asset discovery agent"` } -type AskDadOutput struct { +type Output struct { Answer string `json:"output" jsonschema:"the answer from the data asset discovery agent"` } -func NewAskDadTool(collibraClient *http.Client) *chip.Tool[AskDadInput, AskDadOutput] { - return &chip.Tool[AskDadInput, AskDadOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "discover_data_assets", Description: "Perform a semantic search across available data assets in Collibra. Ask natural language questions to discover tables, columns, datasets, and other data assets.", - Handler: handleAskDad(collibraClient), + Handler: handler(collibraClient), Permissions: []string{"dgc.ai-copilot"}, } } -func handleAskDad(collibraClient *http.Client) chip.ToolHandlerFunc[AskDadInput, AskDadOutput] { - return func(ctx context.Context, input AskDadInput) (AskDadOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { response, err := clients.AskDad(ctx, collibraClient, input.Question) if err != nil { - return AskDadOutput{}, err + return Output{}, err } - return AskDadOutput{Answer: response}, nil + return Output{Answer: response}, nil } } diff --git a/pkg/tools/discover_data_assets_test.go b/pkg/tools/discover_data_assets/tool_test.go similarity index 64% rename from pkg/tools/discover_data_assets_test.go rename to pkg/tools/discover_data_assets/tool_test.go index 5e1fd46..63e66b7 100644 --- a/pkg/tools/discover_data_assets_test.go +++ b/pkg/tools/discover_data_assets/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package discover_data_assets_test import ( "fmt" @@ -7,12 +7,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/discover_data_assets" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestAskDad(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/rest/aiCopilot/v1/tools/askDad", JsonHandlerInOut(func(_ *http.Request, request clients.ToolRequest) (int, clients.ToolResponse) { + handler.Handle("/rest/aiCopilot/v1/tools/askDad", testutil.JsonHandlerInOut(func(_ *http.Request, request clients.ToolRequest) (int, clients.ToolResponse) { return http.StatusOK, clients.ToolResponse{ Content: []clients.ToolContent{ {Text: fmt.Sprintf("Q: %s, A: %s", request.Message.Content.Text, "Name, Email, Phone Number")}, @@ -23,8 +24,8 @@ func TestAskDad(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewAskDadTool(client).Handler(t.Context(), tools.AskDadInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Question: "Column names with PII in table users?", }) if err != nil { diff --git a/pkg/tools/get_asset_details.go b/pkg/tools/get_asset_details/tool.go similarity index 82% rename from pkg/tools/get_asset_details.go rename to pkg/tools/get_asset_details/tool.go index e332fad..21054df 100644 --- a/pkg/tools/get_asset_details.go +++ b/pkg/tools/get_asset_details/tool.go @@ -1,4 +1,4 @@ -package tools +package get_asset_details import ( "context" @@ -13,13 +13,13 @@ import ( "github.com/google/uuid" ) -type AssetDetailsInput struct { +type Input struct { AssetID string `json:"assetId" jsonschema:"the UUID of the asset to retrieve details for"` OutgoingRelationsCursor string `json:"outgoingRelationsCursor,omitempty" jsonschema:"Optional. Cursor (asset ID) to fetch the next page of outgoing relations. Use the last relation's target ID from the previous response."` IncomingRelationsCursor string `json:"incomingRelationsCursor,omitempty" jsonschema:"Optional. Cursor (asset ID) to fetch the next page of incoming relations. Use the last relation's source ID from the previous response."` } -type AssetDetailsOutput struct { +type Output struct { Asset *clients.Asset `json:"asset,omitempty" jsonschema:"the detailed asset information if found"` Responsibilities []AssetResponsibility `json:"responsibilities,omitempty" jsonschema:"the responsibilities assigned to this asset, including inherited ones"` ResponsibilitiesStatus string `json:"responsibilitiesStatus,omitempty" jsonschema:"status message for responsibilities, e.g. No responsibilities assigned"` @@ -36,44 +36,29 @@ type AssetResponsibility struct { Inherited bool `json:"inherited" jsonschema:"true if the responsibility is inherited from a parent resource (domain or community), false if directly assigned to this asset"` } -func NewAssetDetailsTool(collibraClient *http.Client) *chip.Tool[AssetDetailsInput, AssetDetailsOutput] { - return &chip.Tool[AssetDetailsInput, AssetDetailsOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "get_asset_details", Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, responsibilities (owners, stewards, and other role assignments), and metadata. Returns up to 100 attributes per type and supports cursor-based pagination for relations (50 per page).", - Handler: handleAssetDetails(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleAssetDetails(collibraClient *http.Client) chip.ToolHandlerFunc[AssetDetailsInput, AssetDetailsOutput] { - return func(ctx context.Context, input AssetDetailsInput) (AssetDetailsOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { assetUUID, err := uuid.Parse(input.AssetID) if err != nil { - return AssetDetailsOutput{ - Error: fmt.Sprintf("Invalid asset ID format: %s", err.Error()), - Found: false, - }, nil + return Output{Error: fmt.Sprintf("Invalid asset ID format: %s", err.Error()), Found: false}, nil } - assets, err := clients.GetAssetSummary( - ctx, - collibraClient, - assetUUID, - input.OutgoingRelationsCursor, - input.IncomingRelationsCursor, - ) + assets, err := clients.GetAssetSummary(ctx, collibraClient, assetUUID, input.OutgoingRelationsCursor, input.IncomingRelationsCursor) if err != nil { - return AssetDetailsOutput{ - Error: fmt.Sprintf("Failed to retrieve asset details: %s", err.Error()), - Found: false, - }, nil + return Output{Error: fmt.Sprintf("Failed to retrieve asset details: %s", err.Error()), Found: false}, nil } if len(assets) == 0 { - return AssetDetailsOutput{ - Error: "Asset not found", - Found: false, - }, nil + return Output{Error: "Asset not found", Found: false}, nil } collibraHost, ok := chip.GetCollibraHost(ctx) @@ -92,7 +77,7 @@ func handleAssetDetails(collibraClient *http.Client) chip.ToolHandlerFunc[AssetD responsibilitiesStatus = "No responsibilities assigned" } - return AssetDetailsOutput{ + return Output{ Asset: &assets[0], Responsibilities: mappedResponsibilities, ResponsibilitiesStatus: responsibilitiesStatus, @@ -106,10 +91,7 @@ func resolveResponsibilities(ctx context.Context, collibraClient *http.Client, r if len(responsibilities) == 0 { return nil } - - // Collect unique owner IDs by type to avoid duplicate lookups ownerNames := resolveOwnerNames(ctx, collibraClient, responsibilities) - result := make([]AssetResponsibility, 0, len(responsibilities)) for _, r := range responsibilities { entry := AssetResponsibility{} @@ -130,21 +112,16 @@ func resolveResponsibilities(ctx context.Context, collibraClient *http.Client, r return result } -// resolveOwnerNames fetches display names for all unique owners in parallel. -// Returns a map of owner ID to resolved display name. func resolveOwnerNames(ctx context.Context, collibraClient *http.Client, responsibilities []clients.Responsibility) map[string]string { - // Deduplicate owners by ID owners := make(map[string]*clients.ResourceRef) for _, r := range responsibilities { if r.Owner != nil { owners[r.Owner.ID] = r.Owner } } - names := make(map[string]string, len(owners)) var mu sync.Mutex var wg sync.WaitGroup - for _, owner := range owners { wg.Add(1) go func(o *clients.ResourceRef) { @@ -155,7 +132,6 @@ func resolveOwnerNames(ctx context.Context, collibraClient *http.Client, respons mu.Unlock() }(owner) } - wg.Wait() return names } diff --git a/pkg/tools/get_asset_details_test.go b/pkg/tools/get_asset_details/tool_test.go similarity index 79% rename from pkg/tools/get_asset_details_test.go rename to pkg/tools/get_asset_details/tool_test.go index 0803840..14a4de3 100644 --- a/pkg/tools/get_asset_details_test.go +++ b/pkg/tools/get_asset_details/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package get_asset_details_test import ( "net/http" @@ -7,14 +7,15 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/get_asset_details" + "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) func TestGetAssetDetails(t *testing.T) { assetId, _ := uuid.NewUUID() handler := http.NewServeMux() - handler.Handle("/graphql/knowledgeGraph/v1", JsonHandlerInOut(func(httpRequest *http.Request, request clients.Request) (int, clients.Response) { + handler.Handle("/graphql/knowledgeGraph/v1", testutil.JsonHandlerInOut(func(httpRequest *http.Request, request clients.Request) (int, clients.Response) { return http.StatusOK, clients.Response{ Data: &clients.AssetQueryData{ Assets: []clients.Asset{ @@ -26,7 +27,7 @@ func TestGetAssetDetails(t *testing.T) { }, } })) - handler.Handle("/rest/2.0/responsibilities", JsonHandlerOut(func(r *http.Request) (int, clients.ResponsibilityPagedResponse) { + handler.Handle("/rest/2.0/responsibilities", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.ResponsibilityPagedResponse) { return http.StatusOK, clients.ResponsibilityPagedResponse{ Total: 0, Offset: 0, @@ -36,9 +37,9 @@ func TestGetAssetDetails(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - output, err := tools.NewAssetDetailsTool(client).Handler(t.Context(), tools.AssetDetailsInput{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ AssetID: assetId.String(), }) if err != nil { @@ -63,7 +64,7 @@ func TestGetAssetDetailsWithResponsibilities(t *testing.T) { assetId, _ := uuid.NewUUID() domainId := "domain-123" handler := http.NewServeMux() - handler.Handle("/graphql/knowledgeGraph/v1", JsonHandlerInOut(func(httpRequest *http.Request, request clients.Request) (int, clients.Response) { + handler.Handle("/graphql/knowledgeGraph/v1", testutil.JsonHandlerInOut(func(httpRequest *http.Request, request clients.Request) (int, clients.Response) { return http.StatusOK, clients.Response{ Data: &clients.AssetQueryData{ Assets: []clients.Asset{ @@ -75,7 +76,7 @@ func TestGetAssetDetailsWithResponsibilities(t *testing.T) { }, } })) - handler.Handle("/rest/2.0/responsibilities", JsonHandlerOut(func(r *http.Request) (int, clients.ResponsibilityPagedResponse) { + handler.Handle("/rest/2.0/responsibilities", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.ResponsibilityPagedResponse) { return http.StatusOK, clients.ResponsibilityPagedResponse{ Total: 3, Offset: 0, @@ -120,7 +121,7 @@ func TestGetAssetDetailsWithResponsibilities(t *testing.T) { }, } })) - handler.Handle("/rest/2.0/users/user-1", JsonHandlerOut(func(r *http.Request) (int, clients.UserResponse) { + handler.Handle("/rest/2.0/users/user-1", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.UserResponse) { return http.StatusOK, clients.UserResponse{ ID: "user-1", UserName: "john.doe", @@ -128,7 +129,7 @@ func TestGetAssetDetailsWithResponsibilities(t *testing.T) { LastName: "Doe", } })) - handler.Handle("/rest/2.0/users/user-2", JsonHandlerOut(func(r *http.Request) (int, clients.UserResponse) { + handler.Handle("/rest/2.0/users/user-2", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.UserResponse) { return http.StatusOK, clients.UserResponse{ ID: "user-2", UserName: "jane.smith", @@ -136,7 +137,7 @@ func TestGetAssetDetailsWithResponsibilities(t *testing.T) { LastName: "Smith", } })) - handler.Handle("/rest/2.0/userGroups/group-1", JsonHandlerOut(func(r *http.Request) (int, clients.UserGroupResponse) { + handler.Handle("/rest/2.0/userGroups/group-1", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.UserGroupResponse) { return http.StatusOK, clients.UserGroupResponse{ ID: "group-1", Name: "Data Governance Team", @@ -145,9 +146,9 @@ func TestGetAssetDetailsWithResponsibilities(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - output, err := tools.NewAssetDetailsTool(client).Handler(t.Context(), tools.AssetDetailsInput{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ AssetID: assetId.String(), }) if err != nil { diff --git a/pkg/tools/get_business_term_data.go b/pkg/tools/get_business_term_data/tool.go similarity index 62% rename from pkg/tools/get_business_term_data.go rename to pkg/tools/get_business_term_data/tool.go index 06c391e..e83617a 100644 --- a/pkg/tools/get_business_term_data.go +++ b/pkg/tools/get_business_term_data/tool.go @@ -1,4 +1,4 @@ -package tools +package get_business_term_data import ( "context" @@ -8,17 +8,32 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type BusinessTermDataGetInput struct { +type ColumnWithTable struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedTable *AssetWithDescription `json:"connectedTable"` +} + +type AssetWithDescription struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` +} + +type Input struct { BusinessTermID string `json:"businessTermId" jsonschema:"Required. The UUID of the Business Term asset to trace back to physical data assets."` } -type BusinessTermDataGetOutput struct { +type Output struct { BusinessTermID string `json:"businessTermId" jsonschema:"The Business Term asset ID."` - ConnectedPhysicalData []BusinessTermDataAttribute `json:"connectedPhysicalData" jsonschema:"The data attributes with their connected columns and tables."` + ConnectedPhysicalData []Attribute `json:"connectedPhysicalData" jsonschema:"The data attributes with their connected columns and tables."` Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed."` } -type BusinessTermDataAttribute struct { +type Attribute struct { ID string `json:"id"` Name string `json:"name"` AssetType string `json:"assetType"` @@ -26,33 +41,33 @@ type BusinessTermDataAttribute struct { ConnectedColumns []ColumnWithTable `json:"connectedColumns"` } -func NewBusinessTermDataGetTool(collibraClient *http.Client) *chip.Tool[BusinessTermDataGetInput, BusinessTermDataGetOutput] { - return &chip.Tool[BusinessTermDataGetInput, BusinessTermDataGetOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "get_business_term_data", Description: "Retrieve the physical data assets (Columns and Tables) associated with a Business Term via the path Business Term → Data Attribute → Column → Table.", - Handler: handleBusinessTermDataGet(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleBusinessTermDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[BusinessTermDataGetInput, BusinessTermDataGetOutput] { - return func(ctx context.Context, input BusinessTermDataGetInput) (BusinessTermDataGetOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.BusinessTermID == "" { - return BusinessTermDataGetOutput{Error: "businessTermId is required"}, nil + return Output{Error: "businessTermId is required"}, nil } - dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.BusinessTermID, clients.GenericConnectedAssetRelID) + dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.BusinessTermID, clients.BusinessAssetRepresentsDataAssetRelID) if err != nil { - return BusinessTermDataGetOutput{}, err + return Output{}, err } - physicalData := make([]BusinessTermDataAttribute, 0, len(dataAttributes)) + physicalData := make([]Attribute, 0, len(dataAttributes)) for _, da := range dataAttributes { daDescription := clients.FetchDescription(ctx, collibraClient, da.ID) columns, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, da.ID) if err != nil { - return BusinessTermDataGetOutput{}, err + return Output{}, err } columnsWithDetails := make([]ColumnWithTable, 0, len(columns)) @@ -64,9 +79,9 @@ func handleBusinessTermDataGet(collibraClient *http.Client) chip.ToolHandlerFunc Description: clients.FetchDescription(ctx, collibraClient, col.ID), } - tables, err := clients.FindConnectedAssets(ctx, collibraClient, col.ID, clients.ColumnToTableRelID) + tables, err := clients.FindConnectedAssets(ctx, collibraClient, col.ID, clients.ColumnIsPartOfTableRelID) if err != nil { - return BusinessTermDataGetOutput{}, err + return Output{}, err } if len(tables) > 0 { t := tables[0] @@ -81,7 +96,7 @@ func handleBusinessTermDataGet(collibraClient *http.Client) chip.ToolHandlerFunc columnsWithDetails = append(columnsWithDetails, colDetail) } - physicalData = append(physicalData, BusinessTermDataAttribute{ + physicalData = append(physicalData, Attribute{ ID: da.ID, Name: da.Name, AssetType: da.AssetType, @@ -90,7 +105,7 @@ func handleBusinessTermDataGet(collibraClient *http.Client) chip.ToolHandlerFunc }) } - return BusinessTermDataGetOutput{ + return Output{ BusinessTermID: input.BusinessTermID, ConnectedPhysicalData: physicalData, }, nil diff --git a/pkg/tools/get_column_semantics.go b/pkg/tools/get_column_semantics/tool.go similarity index 75% rename from pkg/tools/get_column_semantics.go rename to pkg/tools/get_column_semantics/tool.go index a4fb918..beff244 100644 --- a/pkg/tools/get_column_semantics.go +++ b/pkg/tools/get_column_semantics/tool.go @@ -1,4 +1,4 @@ -package tools +package get_column_semantics import ( "context" @@ -16,11 +16,11 @@ type AssetWithDescription struct { Description string `json:"description"` } -type ColumnSemanticsGetInput struct { +type Input struct { ColumnID string `json:"columnId" jsonschema:"Required. The UUID of the column asset to retrieve semantics for."` } -type ColumnSemanticsGetOutput struct { +type Output struct { Semantics []DataAttributeSemantics `json:"semantics" jsonschema:"The list of data attributes with their connected measures and business assets."` Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed."` } @@ -34,33 +34,33 @@ type DataAttributeSemantics struct { ConnectedBusinessAssets []AssetWithDescription `json:"connectedBusinessAssets"` } -func NewColumnSemanticsGetTool(collibraClient *http.Client) *chip.Tool[ColumnSemanticsGetInput, ColumnSemanticsGetOutput] { - return &chip.Tool[ColumnSemanticsGetInput, ColumnSemanticsGetOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "get_column_semantics", Description: "Retrieve all connected Data Attribute assets for a Column, including descriptions and related Measures and generic business assets with their descriptions.", - Handler: handleColumnSemanticsGet(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleColumnSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[ColumnSemanticsGetInput, ColumnSemanticsGetOutput] { - return func(ctx context.Context, input ColumnSemanticsGetInput) (ColumnSemanticsGetOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.ColumnID == "" { - return ColumnSemanticsGetOutput{Error: "columnId is required"}, nil + return Output{Error: "columnId is required"}, nil } dataAttributes, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, input.ColumnID) if err != nil { - return ColumnSemanticsGetOutput{}, err + return Output{}, err } semantics := make([]DataAttributeSemantics, 0, len(dataAttributes)) for _, da := range dataAttributes { description := clients.FetchDescription(ctx, collibraClient, da.ID) - rawMeasures, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.DataAttributeRepresentsMeasureRelID) + rawMeasures, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.MeasureIsCalculatedUsingDataElementRelID) if err != nil { - return ColumnSemanticsGetOutput{}, err + return Output{}, err } measures := make([]AssetWithDescription, 0, len(rawMeasures)) @@ -73,9 +73,9 @@ func handleColumnSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[ }) } - rawGenericAssets, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.GenericConnectedAssetRelID) + rawGenericAssets, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.BusinessAssetRepresentsDataAssetRelID) if err != nil { - return ColumnSemanticsGetOutput{}, err + return Output{}, err } genericAssets := make([]AssetWithDescription, 0, len(rawGenericAssets)) @@ -98,6 +98,6 @@ func handleColumnSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[ }) } - return ColumnSemanticsGetOutput{Semantics: semantics}, nil + return Output{Semantics: semantics}, nil } } diff --git a/pkg/tools/get_lineage_downstream.go b/pkg/tools/get_lineage_downstream/tool.go similarity index 59% rename from pkg/tools/get_lineage_downstream.go rename to pkg/tools/get_lineage_downstream/tool.go index 7d2b492..0ee1caa 100644 --- a/pkg/tools/get_lineage_downstream.go +++ b/pkg/tools/get_lineage_downstream/tool.go @@ -1,4 +1,4 @@ -package tools +package get_lineage_downstream import ( "context" @@ -8,24 +8,42 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type GetLineageDownstreamInput struct { +type Input struct { EntityId string `json:"entityId" jsonschema:"Required. ID of the entity to trace downstream from. Can be numeric string or DGC UUID."` EntityType string `json:"entityType,omitempty" jsonschema:"Optional. Filter to only include entities of this type (e.g. 'table', 'report'). Useful when you only care about specific downstream asset types."` Limit int `json:"limit,omitempty" jsonschema:"Optional. Max relations per page. Default: 20, Min: 1, Max: 100."` Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` } -func NewGetLineageDownstreamTool(collibraClient *http.Client) *chip.Tool[GetLineageDownstreamInput, clients.GetLineageDirectionalOutput] { - return &chip.Tool[GetLineageDownstreamInput, clients.GetLineageDirectionalOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, clients.GetLineageDirectionalOutput] { + return &chip.Tool[Input, clients.GetLineageDirectionalOutput]{ Name: "get_lineage_downstream", Description: "Get the downstream technical lineage graph for a data entity -- all direct and indirect consumer entities that are impacted by it, along with the transformations connecting them. This traces through all data objects across external systems (including unregistered assets, temporary tables, and source code), not just assets in the Collibra Data Catalog. Use this to answer \"What depends on this data?\" or \"If this table changes, what else is affected?\" Essential for impact analysis before modifying or deprecating a data asset. Results are paginated.", - Handler: handleGetLineageDownstream(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleGetLineageDownstream(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageDownstreamInput, clients.GetLineageDirectionalOutput] { - return func(ctx context.Context, input GetLineageDownstreamInput) (clients.GetLineageDirectionalOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, clients.GetLineageDirectionalOutput] { + return func(ctx context.Context, input Input) (clients.GetLineageDirectionalOutput, error) { return handleLineageDirectional(ctx, collibraClient, input.EntityId, input.EntityType, input.Limit, input.Cursor, clients.GetLineageDownstream) } } + +func handleLineageDirectional( + ctx context.Context, + collibraClient *http.Client, + entityId, entityType string, + limit int, + cursor string, + fetch func(context.Context, *http.Client, string, string, int, string) (*clients.GetLineageDirectionalOutput, error), +) (clients.GetLineageDirectionalOutput, error) { + if entityId == "" { + return clients.GetLineageDirectionalOutput{Error: "entityId is required"}, nil + } + result, err := fetch(ctx, collibraClient, entityId, entityType, limit, cursor) + if err != nil { + return clients.GetLineageDirectionalOutput{}, err + } + return *result, nil +} diff --git a/pkg/tools/get_lineage_downstream_test.go b/pkg/tools/get_lineage_downstream/tool_test.go similarity index 76% rename from pkg/tools/get_lineage_downstream_test.go rename to pkg/tools/get_lineage_downstream/tool_test.go index 85ecf6a..048bfa5 100644 --- a/pkg/tools/get_lineage_downstream_test.go +++ b/pkg/tools/get_lineage_downstream/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package get_lineage_downstream_test import ( "net/http" @@ -6,12 +6,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/get_lineage_downstream" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestGetLineageDownstream(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/downstream", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/downstream", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "relations": []map[string]any{ { @@ -27,8 +28,8 @@ func TestGetLineageDownstream(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageDownstreamTool(client).Handler(t.Context(), tools.GetLineageDownstreamInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ EntityId: "entity-1", }) if err != nil { @@ -63,15 +64,15 @@ func TestGetLineageDownstream(t *testing.T) { func TestGetLineageDownstreamNotFound(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-unknown/downstream", JsonHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-unknown/downstream", testutil.JsonHandlerOut(func(r *http.Request) (int, string) { return http.StatusNotFound, "entity not found" })) server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageDownstreamTool(client).Handler(t.Context(), tools.GetLineageDownstreamInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ EntityId: "entity-unknown", }) if err != nil { @@ -91,8 +92,8 @@ func TestGetLineageDownstreamMissingId(t *testing.T) { server := httptest.NewServer(http.NewServeMux()) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageDownstreamTool(client).Handler(t.Context(), tools.GetLineageDownstreamInput{}) + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{}) if err != nil { t.Fatalf("Expected no error, got: %v", err) } diff --git a/pkg/tools/get_lineage_entity.go b/pkg/tools/get_lineage_entity/tool.go similarity index 71% rename from pkg/tools/get_lineage_entity.go rename to pkg/tools/get_lineage_entity/tool.go index 9a08e1d..d61d6dd 100644 --- a/pkg/tools/get_lineage_entity.go +++ b/pkg/tools/get_lineage_entity/tool.go @@ -1,4 +1,4 @@ -package tools +package get_lineage_entity import ( "context" @@ -8,21 +8,21 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type GetLineageEntityInput struct { +type Input struct { EntityId string `json:"entityId" jsonschema:"Required. Unique identifier of the data entity. Can be a numeric string (e.g. '12345') or a DGC UUID (e.g. '550e8400-e29b-41d4-a716-446655440000')."` } -func NewGetLineageEntityTool(collibraClient *http.Client) *chip.Tool[GetLineageEntityInput, clients.GetLineageEntityOutput] { - return &chip.Tool[GetLineageEntityInput, clients.GetLineageEntityOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, clients.GetLineageEntityOutput] { + return &chip.Tool[Input, clients.GetLineageEntityOutput]{ Name: "get_lineage_entity", Description: "Get detailed metadata about a specific data entity in the technical lineage graph. Technical lineage covers all data objects across external systems -- including source code, transformations, and temporary tables -- regardless of whether they are registered in Collibra (unlike business lineage, which only covers assets ingested into the Data Catalog). An entity represents any tracked data asset such as a table, column, file, report, API endpoint, or topic. Returns the entity's name, type, source systems, parent entity, and linked Data Governance Catalog (DGC) identifier. Use this when you have an entity ID from a lineage traversal, search result, or user input and need its full details.", - Handler: handleGetLineageEntity(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleGetLineageEntity(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageEntityInput, clients.GetLineageEntityOutput] { - return func(ctx context.Context, input GetLineageEntityInput) (clients.GetLineageEntityOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, clients.GetLineageEntityOutput] { + return func(ctx context.Context, input Input) (clients.GetLineageEntityOutput, error) { if input.EntityId == "" { return clients.GetLineageEntityOutput{Found: false, Error: "entityId is required"}, nil } diff --git a/pkg/tools/get_lineage_entity_test.go b/pkg/tools/get_lineage_entity/tool_test.go similarity index 72% rename from pkg/tools/get_lineage_entity_test.go rename to pkg/tools/get_lineage_entity/tool_test.go index ca8c84a..8071b5f 100644 --- a/pkg/tools/get_lineage_entity_test.go +++ b/pkg/tools/get_lineage_entity/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package get_lineage_entity_test import ( "net/http" @@ -6,12 +6,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/get_lineage_entity" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestGetLineageEntity(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1", JsonHandlerOut(func(r *http.Request) (int, clients.LineageEntity) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.LineageEntity) { return http.StatusOK, clients.LineageEntity{ Id: "entity-1", Name: "my_table", @@ -22,8 +23,8 @@ func TestGetLineageEntity(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageEntityTool(client).Handler(t.Context(), tools.GetLineageEntityInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ EntityId: "entity-1", }) if err != nil { @@ -49,15 +50,15 @@ func TestGetLineageEntity(t *testing.T) { func TestGetLineageEntityNotFound(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-unknown", JsonHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-unknown", testutil.JsonHandlerOut(func(r *http.Request) (int, string) { return http.StatusNotFound, "entity not found" })) server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageEntityTool(client).Handler(t.Context(), tools.GetLineageEntityInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ EntityId: "entity-unknown", }) if err != nil { @@ -77,8 +78,8 @@ func TestGetLineageEntityMissingId(t *testing.T) { server := httptest.NewServer(http.NewServeMux()) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageEntityTool(client).Handler(t.Context(), tools.GetLineageEntityInput{}) + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{}) if err != nil { t.Fatalf("Expected no error, got: %v", err) } diff --git a/pkg/tools/get_lineage_transformation.go b/pkg/tools/get_lineage_transformation/tool.go similarity index 64% rename from pkg/tools/get_lineage_transformation.go rename to pkg/tools/get_lineage_transformation/tool.go index 07ea994..6ecfb6d 100644 --- a/pkg/tools/get_lineage_transformation.go +++ b/pkg/tools/get_lineage_transformation/tool.go @@ -1,4 +1,4 @@ -package tools +package get_lineage_transformation import ( "context" @@ -8,21 +8,21 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type GetLineageTransformationInput struct { +type Input struct { TransformationId string `json:"transformationId" jsonschema:"Required. ID of the transformation to be fetched (e.g. '67890')."` } -func NewGetLineageTransformationTool(collibraClient *http.Client) *chip.Tool[GetLineageTransformationInput, clients.GetLineageTransformationOutput] { - return &chip.Tool[GetLineageTransformationInput, clients.GetLineageTransformationOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, clients.GetLineageTransformationOutput] { + return &chip.Tool[Input, clients.GetLineageTransformationOutput]{ Name: "get_lineage_transformation", Description: "Get detailed information about a specific data transformation, including its SQL or script logic. A transformation represents a data processing activity (ETL job, SQL query, script, etc.) that connects source entities to target entities in the lineage graph. Use this when you found a transformation ID in an upstream/downstream lineage result and want to see what the transformation actually does -- the SQL query, script content, or processing logic.", - Handler: handleGetLineageTransformation(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleGetLineageTransformation(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageTransformationInput, clients.GetLineageTransformationOutput] { - return func(ctx context.Context, input GetLineageTransformationInput) (clients.GetLineageTransformationOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, clients.GetLineageTransformationOutput] { + return func(ctx context.Context, input Input) (clients.GetLineageTransformationOutput, error) { if input.TransformationId == "" { return clients.GetLineageTransformationOutput{Found: false, Error: "transformationId is required"}, nil } diff --git a/pkg/tools/get_lineage_transformation_test.go b/pkg/tools/get_lineage_transformation/tool_test.go similarity index 74% rename from pkg/tools/get_lineage_transformation_test.go rename to pkg/tools/get_lineage_transformation/tool_test.go index ef59766..a0a99bf 100644 --- a/pkg/tools/get_lineage_transformation_test.go +++ b/pkg/tools/get_lineage_transformation/tool_test.go @@ -1,16 +1,17 @@ -package tools_test +package get_lineage_transformation_test import ( "net/http" "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/get_lineage_transformation" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestGetLineageTransformation(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations/transform-1", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations/transform-1", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "id": "transform-1", "name": "etl_sales_daily", @@ -22,8 +23,8 @@ func TestGetLineageTransformation(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageTransformationTool(client).Handler(t.Context(), tools.GetLineageTransformationInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ TransformationId: "transform-1", }) if err != nil { @@ -49,15 +50,15 @@ func TestGetLineageTransformation(t *testing.T) { func TestGetLineageTransformationNotFound(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations/transform-unknown", JsonHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations/transform-unknown", testutil.JsonHandlerOut(func(r *http.Request) (int, string) { return http.StatusNotFound, "transformation not found" })) server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageTransformationTool(client).Handler(t.Context(), tools.GetLineageTransformationInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ TransformationId: "transform-unknown", }) if err != nil { @@ -77,8 +78,8 @@ func TestGetLineageTransformationMissingId(t *testing.T) { server := httptest.NewServer(http.NewServeMux()) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageTransformationTool(client).Handler(t.Context(), tools.GetLineageTransformationInput{}) + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{}) if err != nil { t.Fatalf("Expected no error, got: %v", err) } diff --git a/pkg/tools/get_lineage_upstream.go b/pkg/tools/get_lineage_upstream/tool.go similarity index 79% rename from pkg/tools/get_lineage_upstream.go rename to pkg/tools/get_lineage_upstream/tool.go index 6fa5ca0..4dd591a 100644 --- a/pkg/tools/get_lineage_upstream.go +++ b/pkg/tools/get_lineage_upstream/tool.go @@ -1,4 +1,4 @@ -package tools +package get_lineage_upstream import ( "context" @@ -8,24 +8,24 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type GetLineageUpstreamInput struct { +type Input struct { EntityId string `json:"entityId" jsonschema:"Required. ID of the entity to trace upstream from. Can be numeric string or DGC UUID."` EntityType string `json:"entityType,omitempty" jsonschema:"Optional. Filter to only include entities of this type (e.g. 'table', 'column'). Useful when you only care about specific upstream asset types."` Limit int `json:"limit,omitempty" jsonschema:"Optional. Max relations per page. Default: 20, Min: 1, Max: 100."` Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` } -func NewGetLineageUpstreamTool(collibraClient *http.Client) *chip.Tool[GetLineageUpstreamInput, clients.GetLineageDirectionalOutput] { - return &chip.Tool[GetLineageUpstreamInput, clients.GetLineageDirectionalOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, clients.GetLineageDirectionalOutput] { + return &chip.Tool[Input, clients.GetLineageDirectionalOutput]{ Name: "get_lineage_upstream", Description: "Get the upstream technical lineage graph for a data entity -- all direct and indirect source entities that feed data into it, along with the transformations connecting them. This traces through all data objects across external systems (including unregistered assets, temporary tables, and source code), not just assets in the Collibra Data Catalog. Use this to answer \"Where does this data come from?\" or \"What are the sources feeding this table?\" Each relation in the result connects a source entity to a target entity through one or more transformations. Results are paginated.", - Handler: handleGetLineageUpstream(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleGetLineageUpstream(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageUpstreamInput, clients.GetLineageDirectionalOutput] { - return func(ctx context.Context, input GetLineageUpstreamInput) (clients.GetLineageDirectionalOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, clients.GetLineageDirectionalOutput] { + return func(ctx context.Context, input Input) (clients.GetLineageDirectionalOutput, error) { return handleLineageDirectional(ctx, collibraClient, input.EntityId, input.EntityType, input.Limit, input.Cursor, clients.GetLineageUpstream) } } diff --git a/pkg/tools/get_lineage_upstream_test.go b/pkg/tools/get_lineage_upstream/tool_test.go similarity index 76% rename from pkg/tools/get_lineage_upstream_test.go rename to pkg/tools/get_lineage_upstream/tool_test.go index 43b97b4..015d4df 100644 --- a/pkg/tools/get_lineage_upstream_test.go +++ b/pkg/tools/get_lineage_upstream/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package get_lineage_upstream_test import ( "net/http" @@ -6,12 +6,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/get_lineage_upstream" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestGetLineageUpstream(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/upstream", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/upstream", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "relations": []map[string]any{ { @@ -27,8 +28,8 @@ func TestGetLineageUpstream(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageUpstreamTool(client).Handler(t.Context(), tools.GetLineageUpstreamInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ EntityId: "entity-1", }) if err != nil { @@ -63,15 +64,15 @@ func TestGetLineageUpstream(t *testing.T) { func TestGetLineageUpstreamNotFound(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-unknown/upstream", JsonHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-unknown/upstream", testutil.JsonHandlerOut(func(r *http.Request) (int, string) { return http.StatusNotFound, "entity not found" })) server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageUpstreamTool(client).Handler(t.Context(), tools.GetLineageUpstreamInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ EntityId: "entity-unknown", }) if err != nil { @@ -91,8 +92,8 @@ func TestGetLineageUpstreamMissingId(t *testing.T) { server := httptest.NewServer(http.NewServeMux()) defer server.Close() - client := newClient(server) - output, err := tools.NewGetLineageUpstreamTool(client).Handler(t.Context(), tools.GetLineageUpstreamInput{}) + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{}) if err != nil { t.Fatalf("Expected no error, got: %v", err) } diff --git a/pkg/tools/get_measure_data.go b/pkg/tools/get_measure_data/tool.go similarity index 66% rename from pkg/tools/get_measure_data.go rename to pkg/tools/get_measure_data/tool.go index 0ac5652..6a0056c 100644 --- a/pkg/tools/get_measure_data.go +++ b/pkg/tools/get_measure_data/tool.go @@ -1,4 +1,4 @@ -package tools +package get_measure_data import ( "context" @@ -8,6 +8,13 @@ import ( "github.com/collibra/chip/pkg/clients" ) +type AssetWithDescription struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` +} + // ColumnWithTable represents a column and its parent table in traversal tool outputs. type ColumnWithTable struct { ID string `json:"id"` @@ -17,47 +24,47 @@ type ColumnWithTable struct { ConnectedTable *AssetWithDescription `json:"connectedTable"` } -type MeasureDataGetInput struct { +type Input struct { MeasureID string `json:"measureId" jsonschema:"Required. The UUID of the measure asset to trace back to its underlying physical columns."` } -type MeasureDataGetOutput struct { - DataHierarchy []MeasureDataAttribute `json:"dataHierarchy" jsonschema:"The list of data attributes with their connected columns and tables."` +type Output struct { + DataHierarchy []Attribute `json:"dataHierarchy" jsonschema:"The list of data attributes with their connected columns and tables."` Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed."` } -type MeasureDataAttribute struct { +type Attribute struct { ID string `json:"id"` Name string `json:"name"` AssetType string `json:"assetType"` ConnectedColumns []ColumnWithTable `json:"connectedColumns"` } -func NewMeasureDataGetTool(collibraClient *http.Client) *chip.Tool[MeasureDataGetInput, MeasureDataGetOutput] { - return &chip.Tool[MeasureDataGetInput, MeasureDataGetOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "get_measure_data", Description: "Retrieve all underlying Column assets connected to a Measure via the path Measure → Data Attribute → Column, including each Column's description and parent Table.", - Handler: handleMeasureDataGet(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleMeasureDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[MeasureDataGetInput, MeasureDataGetOutput] { - return func(ctx context.Context, input MeasureDataGetInput) (MeasureDataGetOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.MeasureID == "" { - return MeasureDataGetOutput{Error: "measureId is required"}, nil + return Output{Error: "measureId is required"}, nil } - dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.MeasureID, clients.DataAttributeRepresentsMeasureRelID) + dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.MeasureID, clients.MeasureIsCalculatedUsingDataElementRelID) if err != nil { - return MeasureDataGetOutput{}, err + return Output{}, err } - hierarchy := make([]MeasureDataAttribute, 0, len(dataAttributes)) + hierarchy := make([]Attribute, 0, len(dataAttributes)) for _, da := range dataAttributes { columns, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, da.ID) if err != nil { - return MeasureDataGetOutput{}, err + return Output{}, err } columnsWithDetails := make([]ColumnWithTable, 0, len(columns)) @@ -69,9 +76,9 @@ func handleMeasureDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[Meas Description: clients.FetchDescription(ctx, collibraClient, col.ID), } - tables, err := clients.FindConnectedAssets(ctx, collibraClient, col.ID, clients.ColumnToTableRelID) + tables, err := clients.FindConnectedAssets(ctx, collibraClient, col.ID, clients.ColumnIsPartOfTableRelID) if err != nil { - return MeasureDataGetOutput{}, err + return Output{}, err } if len(tables) > 0 { t := tables[0] @@ -86,7 +93,7 @@ func handleMeasureDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[Meas columnsWithDetails = append(columnsWithDetails, colDetail) } - hierarchy = append(hierarchy, MeasureDataAttribute{ + hierarchy = append(hierarchy, Attribute{ ID: da.ID, Name: da.Name, AssetType: da.AssetType, @@ -94,6 +101,6 @@ func handleMeasureDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[Meas }) } - return MeasureDataGetOutput{DataHierarchy: hierarchy}, nil + return Output{DataHierarchy: hierarchy}, nil } } diff --git a/pkg/tools/get_table_semantics.go b/pkg/tools/get_table_semantics/tool.go similarity index 78% rename from pkg/tools/get_table_semantics.go rename to pkg/tools/get_table_semantics/tool.go index 88d20cd..efa8e8c 100644 --- a/pkg/tools/get_table_semantics.go +++ b/pkg/tools/get_table_semantics/tool.go @@ -1,4 +1,4 @@ -package tools +package get_table_semantics import ( "context" @@ -8,11 +8,18 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type TableSemanticsGetInput struct { +type AssetWithDescription struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` +} + +type Input struct { TableID string `json:"tableId" jsonschema:"Required. The UUID of the Table asset to retrieve semantics for."` } -type TableSemanticsGetOutput struct { +type Output struct { TableID string `json:"tableId" jsonschema:"The Table asset ID."` SemanticHierarchy []ColumnWithSemantics `json:"semanticHierarchy" jsonschema:"The semantic hierarchy of columns with their data attributes and measures."` Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed."` @@ -34,24 +41,24 @@ type DataAttributeWithMeasures struct { ConnectedMeasures []AssetWithDescription `json:"connectedMeasures"` } -func NewTableSemanticsGetTool(collibraClient *http.Client) *chip.Tool[TableSemanticsGetInput, TableSemanticsGetOutput] { - return &chip.Tool[TableSemanticsGetInput, TableSemanticsGetOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "get_table_semantics", Description: "Retrieve the semantic layer for a Table asset: Columns, their Data Attributes, and connected Measures. Answers 'What is the semantic context of this table?' or 'Which metrics use data from this table?'.", - Handler: handleTableSemanticsGet(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleTableSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[TableSemanticsGetInput, TableSemanticsGetOutput] { - return func(ctx context.Context, input TableSemanticsGetInput) (TableSemanticsGetOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.TableID == "" { - return TableSemanticsGetOutput{Error: "tableId is required"}, nil + return Output{Error: "tableId is required"}, nil } - rawColumns, err := clients.FindConnectedAssets(ctx, collibraClient, input.TableID, clients.ColumnToTableRelID) + rawColumns, err := clients.FindConnectedAssets(ctx, collibraClient, input.TableID, clients.ColumnIsPartOfTableRelID) if err != nil { - return TableSemanticsGetOutput{}, err + return Output{}, err } columns := make([]ColumnWithSemantics, 0, len(rawColumns)) @@ -60,16 +67,16 @@ func handleTableSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[T dataAttributes, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, col.ID) if err != nil { - return TableSemanticsGetOutput{}, err + return Output{}, err } das := make([]DataAttributeWithMeasures, 0, len(dataAttributes)) for _, da := range dataAttributes { daDescription := clients.FetchDescription(ctx, collibraClient, da.ID) - rawMeasures, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.DataAttributeRepresentsMeasureRelID) + rawMeasures, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.MeasureIsCalculatedUsingDataElementRelID) if err != nil { - return TableSemanticsGetOutput{}, err + return Output{}, err } measures := make([]AssetWithDescription, 0, len(rawMeasures)) @@ -100,7 +107,7 @@ func handleTableSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[T }) } - return TableSemanticsGetOutput{ + return Output{ TableID: input.TableID, SemanticHierarchy: columns, }, nil diff --git a/pkg/tools/list_asset_types.go b/pkg/tools/list_asset_types/tool.go similarity index 81% rename from pkg/tools/list_asset_types.go rename to pkg/tools/list_asset_types/tool.go index 21fd3a4..1a683e0 100644 --- a/pkg/tools/list_asset_types.go +++ b/pkg/tools/list_asset_types/tool.go @@ -1,4 +1,4 @@ -package tools +package list_asset_types import ( "context" @@ -8,12 +8,12 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type ListAssetTypesInput struct { +type Input struct { Limit int `json:"limit,omitempty" jsonschema:"Optional. Maximum number of results to return. The maximum allowed limit is 1000. Default: 100."` Offset int `json:"offset,omitempty" jsonschema:"Optional. Index of first result (pagination offset). Default: 0."` } -type ListAssetTypesOutput struct { +type Output struct { Total int64 `json:"total" jsonschema:"The total number of asset types available matching the search criteria"` Offset int64 `json:"offset" jsonschema:"The offset for the results"` Limit int64 `json:"limit" jsonschema:"The maximum number of results returned"` @@ -32,24 +32,24 @@ type AssetType struct { Product string `json:"product,omitempty" jsonschema:"The product to which this asset type is linked"` } -func NewListAssetTypesTool(collibraClient *http.Client) *chip.Tool[ListAssetTypesInput, ListAssetTypesOutput] { - return &chip.Tool[ListAssetTypesInput, ListAssetTypesOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "list_asset_types", Description: "List asset types available in Collibra with their properties and metadata.", - Handler: handleListAssetTypes(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleListAssetTypes(collibraClient *http.Client) chip.ToolHandlerFunc[ListAssetTypesInput, ListAssetTypesOutput] { - return func(ctx context.Context, input ListAssetTypesInput) (ListAssetTypesOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.Limit == 0 { input.Limit = 100 } response, err := clients.ListAssetTypes(ctx, collibraClient, input.Limit, input.Offset) if err != nil { - return ListAssetTypesOutput{}, err + return Output{}, err } assetTypes := make([]AssetType, len(response.Results)) @@ -67,7 +67,7 @@ func handleListAssetTypes(collibraClient *http.Client) chip.ToolHandlerFunc[List } } - return ListAssetTypesOutput{ + return Output{ Total: response.Total, Offset: response.Offset, Limit: response.Limit, diff --git a/pkg/tools/list_asset_types_test.go b/pkg/tools/list_asset_types/tool_test.go similarity index 79% rename from pkg/tools/list_asset_types_test.go rename to pkg/tools/list_asset_types/tool_test.go index c7c4c1f..ab41603 100644 --- a/pkg/tools/list_asset_types_test.go +++ b/pkg/tools/list_asset_types/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package list_asset_types_test import ( "net/http" @@ -6,14 +6,15 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/list_asset_types" + "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) func TestListAssetTypes(t *testing.T) { assetTypeId, _ := uuid.NewUUID() handler := http.NewServeMux() - handler.Handle("/rest/2.0/assetTypes", JsonHandlerOut(func(httpRequest *http.Request) (int, clients.AssetTypePagedResponse) { + handler.Handle("/rest/2.0/assetTypes", testutil.JsonHandlerOut(func(httpRequest *http.Request) (int, clients.AssetTypePagedResponse) { return http.StatusOK, clients.AssetTypePagedResponse{ Total: 1, Offset: 0, @@ -37,8 +38,8 @@ func TestListAssetTypes(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewListAssetTypesTool(client).Handler(t.Context(), tools.ListAssetTypesInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Limit: 100, }) if err != nil { diff --git a/pkg/tools/list_data_contracts.go b/pkg/tools/list_data_contracts/tool.go similarity index 56% rename from pkg/tools/list_data_contracts.go rename to pkg/tools/list_data_contracts/tool.go index 9833343..733651c 100644 --- a/pkg/tools/list_data_contracts.go +++ b/pkg/tools/list_data_contracts/tool.go @@ -1,4 +1,4 @@ -package tools +package list_data_contracts import ( "context" @@ -8,55 +8,55 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type ListDataContractsInput struct { +type Input struct { ManifestFilter string `json:"manifestId,omitempty" jsonschema:"Optional. Filter by the unique identifier of the Data Contract manifest."` Cursor string `json:"cursor,omitempty" jsonschema:"Optional. The cursor pointing to the first resource to be included in the response. This cursor must have been extracted from a previous API call response."` Limit int `json:"limit,omitempty" jsonschema:"Optional. Maximum number of results to return. The maximum allowed limit is 500. Default: 100."` } -type ListDataContractsOutput struct { - Total *int `json:"total,omitempty" jsonschema:"The total number of data contracts available matching the search criteria (only included if includeTotal was true)"` - Limit int `json:"limit" jsonschema:"The maximum number of results returned"` - NextCursor string `json:"nextCursor,omitempty" jsonschema:"The cursor pointing to the next page. If missing, there are no additional pages available."` - Contracts []DataContract `json:"contracts" jsonschema:"The list of data contracts"` +type Output struct { + Total *int `json:"total,omitempty" jsonschema:"The total number of data contracts available matching the search criteria (only included if includeTotal was true)"` + Limit int `json:"limit" jsonschema:"The maximum number of results returned"` + NextCursor string `json:"nextCursor,omitempty" jsonschema:"The cursor pointing to the next page. If missing, there are no additional pages available."` + Contracts []Contract `json:"contracts" jsonschema:"The list of data contracts"` } -type DataContract struct { +type Contract struct { ID string `json:"id" jsonschema:"The UUID of the data contract asset"` DomainID string `json:"domainId" jsonschema:"The UUID of the domain where the data contract asset is located"` ManifestID string `json:"manifestId" jsonschema:"The unique identifier of the data contract manifest"` } -func NewListDataContractsTool(collibraClient *http.Client) *chip.Tool[ListDataContractsInput, ListDataContractsOutput] { - return &chip.Tool[ListDataContractsInput, ListDataContractsOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "list_data_contract", Description: "List data contracts available in Collibra. Returns a paginated list of data contract metadata, sorted by the last modified date in descending order.", - Handler: handleListDataContracts(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleListDataContracts(collibraClient *http.Client) chip.ToolHandlerFunc[ListDataContractsInput, ListDataContractsOutput] { - return func(ctx context.Context, input ListDataContractsInput) (ListDataContractsOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.Limit == 0 { input.Limit = 100 } response, err := clients.ListDataContracts(ctx, collibraClient, input.Cursor, input.Limit, input.ManifestFilter) if err != nil { - return ListDataContractsOutput{}, err + return Output{}, err } - contracts := make([]DataContract, len(response.Items)) + contracts := make([]Contract, len(response.Items)) for i, dc := range response.Items { - contracts[i] = DataContract{ + contracts[i] = Contract{ ID: dc.ID, DomainID: dc.DomainID, ManifestID: dc.ManifestID, } } - output := ListDataContractsOutput{ + output := Output{ Limit: response.Limit, NextCursor: response.NextCursor, Contracts: contracts, diff --git a/pkg/tools/list_data_contracts_test.go b/pkg/tools/list_data_contracts/tool_test.go similarity index 77% rename from pkg/tools/list_data_contracts_test.go rename to pkg/tools/list_data_contracts/tool_test.go index ae00f70..c27e421 100644 --- a/pkg/tools/list_data_contracts_test.go +++ b/pkg/tools/list_data_contracts/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package list_data_contracts_test import ( "net/http" @@ -6,7 +6,8 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/list_data_contracts" + "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -16,7 +17,7 @@ func TestListDataContracts(t *testing.T) { manifestId := "test-manifest-123" handler := http.NewServeMux() - handler.Handle("/rest/dataProduct/v1/dataContracts", JsonHandlerOut(func(httpRequest *http.Request) (int, clients.DataContractListPaginated) { + handler.Handle("/rest/dataProduct/v1/dataContracts", testutil.JsonHandlerOut(func(httpRequest *http.Request) (int, clients.DataContractListPaginated) { return http.StatusOK, clients.DataContractListPaginated{ Items: []clients.DataContract{ { @@ -33,8 +34,8 @@ func TestListDataContracts(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewListDataContractsTool(client).Handler(t.Context(), tools.ListDataContractsInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Limit: 100, }) if err != nil { @@ -70,7 +71,7 @@ func TestListDataContractsWithTotal(t *testing.T) { total := 42 handler := http.NewServeMux() - handler.Handle("/rest/dataProduct/v1/dataContracts", JsonHandlerOut(func(httpRequest *http.Request) (int, clients.DataContractListPaginated) { + handler.Handle("/rest/dataProduct/v1/dataContracts", testutil.JsonHandlerOut(func(httpRequest *http.Request) (int, clients.DataContractListPaginated) { return http.StatusOK, clients.DataContractListPaginated{ Items: []clients.DataContract{ { @@ -88,8 +89,8 @@ func TestListDataContractsWithTotal(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewListDataContractsTool(client).Handler(t.Context(), tools.ListDataContractsInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Limit: 100, }) if err != nil { diff --git a/pkg/tools/prepare_add_business_term/tool.go b/pkg/tools/prepare_add_business_term/tool.go new file mode 100644 index 0000000..0a90dd8 --- /dev/null +++ b/pkg/tools/prepare_add_business_term/tool.go @@ -0,0 +1,234 @@ +package prepare_add_business_term + +import ( + "context" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +const businessTermPublicID = "BusinessTerm" + +// Input represents the input parameters for the prepare_add_business_term tool. +type Input struct { + Name string `json:"name" jsonschema:"The name of the business term to add"` + DomainName string `json:"domain_name,omitempty" jsonschema:"Optional. The domain name to resolve for the business term"` + DomainID string `json:"domain_id,omitempty" jsonschema:"Optional. The domain ID if already known"` + Description string `json:"description,omitempty" jsonschema:"Optional. A description for the business term"` +} + +// Output represents the structured result of the preparation check. +type Output struct { + Status string `json:"status" jsonschema:"Status of the preparation: ready, incomplete, needs_clarification, or duplicate_found"` + Message string `json:"message" jsonschema:"Human-readable explanation of the status"` + ResolvedDomain *DomainInfo `json:"resolved_domain,omitempty" jsonschema:"Optional. The resolved domain information"` + Duplicates []DuplicateAssetInfo `json:"duplicates,omitempty" jsonschema:"Optional. List of existing assets that may be duplicates"` + AttributeSchema []AttributeSchemaEntry `json:"attribute_schema,omitempty" jsonschema:"Optional. Full attribute schema for the business term type"` + AvailableDomains []DomainInfo `json:"available_domains,omitempty" jsonschema:"Optional. Available domains for selection when domain is missing or ambiguous"` +} + +// DomainInfo represents a resolved domain. +type DomainInfo struct { + ID string `json:"id" jsonschema:"Domain ID"` + Name string `json:"name" jsonschema:"Domain name"` +} + +// DuplicateAssetInfo represents an existing asset that may be a duplicate. +type DuplicateAssetInfo struct { + ID string `json:"id" jsonschema:"Asset ID"` + Name string `json:"name" jsonschema:"Asset name"` + Domain DomainInfo `json:"domain" jsonschema:"Domain of the asset"` +} + +// AttributeSchemaEntry represents the full schema for a single attribute type. +type AttributeSchemaEntry struct { + ID string `json:"id" jsonschema:"Attribute type ID"` + Name string `json:"name" jsonschema:"Attribute type name"` + Kind string `json:"kind" jsonschema:"Attribute data type"` + Required bool `json:"required" jsonschema:"Whether this attribute is mandatory"` + Constraints *AttributeConstraints `json:"constraints,omitempty" jsonschema:"Optional. Validation rules and limits"` + AllowedValues []string `json:"allowed_values,omitempty" jsonschema:"Optional. Permitted values if constrained"` + RelationType *RelationTypeInfo `json:"relation_type,omitempty" jsonschema:"Optional. Relation type with direction and target"` +} + +// AttributeConstraints represents validation constraints for an attribute. +type AttributeConstraints struct { + MinLength *int `json:"min_length,omitempty" jsonschema:"Optional. Minimum string length"` + MaxLength *int `json:"max_length,omitempty" jsonschema:"Optional. Maximum string length"` +} + +// RelationTypeInfo represents a relation type with direction and target. +type RelationTypeInfo struct { + ID string `json:"id" jsonschema:"Relation type ID"` + Role string `json:"role" jsonschema:"Role name"` + CoRole string `json:"co_role" jsonschema:"Co-role name"` + Direction string `json:"direction" jsonschema:"Direction of the relation"` + TargetType TypeRef `json:"target_type" jsonschema:"Target type reference"` +} + +// TypeRef is a simple reference to a type by ID and name. +type TypeRef struct { + ID string `json:"id" jsonschema:"Type ID"` + Name string `json:"name" jsonschema:"Type name"` +} + +// NewTool creates a new prepare_add_business_term tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "prepare_add_business_term", + Description: "Validate business term data, resolve domains, check for duplicates, and hydrate attribute schemas. Returns structured status with pre-fetched options for missing fields.", + Handler: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + // Step 1: List all domains for resolution and pre-fetching options. + domains, err := clients.PrepareAddBusinessTermListDomains(ctx, collibraClient) + if err != nil { + return Output{}, err + } + + availableDomains := make([]DomainInfo, len(domains)) + for i, d := range domains { + availableDomains[i] = DomainInfo{ID: d.ID, Name: d.Name} + } + + // Step 2: Resolve domain. + var resolvedDomain *DomainInfo + + if input.DomainID != "" { + // Validate that the provided domain ID exists. + domain, err := clients.PrepareAddBusinessTermGetDomain(ctx, collibraClient, input.DomainID) + if err != nil { + return Output{}, err + } + resolvedDomain = &DomainInfo{ID: domain.ID, Name: domain.Name} + } else if input.DomainName != "" { + // Resolve domain by name — check for exact (case-insensitive) matches. + var matches []DomainInfo + for _, d := range domains { + if strings.EqualFold(d.Name, input.DomainName) { + matches = append(matches, DomainInfo{ID: d.ID, Name: d.Name}) + } + } + + switch len(matches) { + case 1: + resolvedDomain = &matches[0] + case 0: + // No match — domain remains unresolved, will result in incomplete status. + default: + // Multiple matches — needs clarification. + return Output{ + Status: "needs_clarification", + Message: "Multiple domains match the provided name. Please select one.", + AvailableDomains: matches, + }, nil + } + } + + // Step 3: Get business term asset type configuration. + assetType, err := clients.PrepareAddBusinessTermGetAssetType(ctx, collibraClient, businessTermPublicID) + if err != nil { + return Output{}, err + } + + // Step 4: Retrieve attribute assignments for the business term type. + assignments, err := clients.PrepareAddBusinessTermGetAssignments(ctx, collibraClient, assetType.ID) + if err != nil { + return Output{}, err + } + + // Step 5: Hydrate full attribute schemas. + attributeSchema := make([]AttributeSchemaEntry, 0, len(assignments)) + for _, assignment := range assignments { + attrType, err := clients.PrepareAddBusinessTermGetAttributeType(ctx, collibraClient, assignment.AttributeType.ID) + if err != nil { + return Output{}, err + } + + entry := AttributeSchemaEntry{ + ID: attrType.ID, + Name: attrType.Name, + Kind: attrType.Kind, + Required: attrType.Required || assignment.Min > 0, + AllowedValues: attrType.AllowedValues, + } + + if attrType.Constraints != nil { + entry.Constraints = &AttributeConstraints{ + MinLength: attrType.Constraints.MinLength, + MaxLength: attrType.Constraints.MaxLength, + } + } + + if attrType.RelationType != nil { + entry.RelationType = &RelationTypeInfo{ + ID: attrType.RelationType.ID, + Role: attrType.RelationType.Role, + CoRole: attrType.RelationType.CoRole, + Direction: attrType.RelationType.Direction, + TargetType: TypeRef{ + ID: attrType.RelationType.TargetType.ID, + Name: attrType.RelationType.TargetType.Name, + }, + } + } + + attributeSchema = append(attributeSchema, entry) + } + + // Step 6: Search for duplicate assets. + var duplicates []DuplicateAssetInfo + if input.Name != "" { + assets, err := clients.PrepareAddBusinessTermSearchAssets(ctx, collibraClient, input.Name, assetType.ID) + if err != nil { + return Output{}, err + } + + for _, a := range assets { + duplicates = append(duplicates, DuplicateAssetInfo{ + ID: a.ID, + Name: a.Name, + Domain: DomainInfo{ + ID: a.Domain.ID, + Name: a.Domain.Name, + }, + }) + } + } + + // Step 7: Determine status. + if len(duplicates) > 0 { + return Output{ + Status: "duplicate_found", + Message: "Existing business terms match the provided name.", + ResolvedDomain: resolvedDomain, + Duplicates: duplicates, + AttributeSchema: attributeSchema, + AvailableDomains: availableDomains, + }, nil + } + + if input.Name == "" || resolvedDomain == nil { + return Output{ + Status: "incomplete", + Message: "Missing required fields. Please provide name and domain.", + ResolvedDomain: resolvedDomain, + AttributeSchema: attributeSchema, + AvailableDomains: availableDomains, + }, nil + } + + return Output{ + Status: "ready", + Message: "All required data is present and validated. Ready to add business term.", + ResolvedDomain: resolvedDomain, + AttributeSchema: attributeSchema, + }, nil + } +} diff --git a/pkg/tools/prepare_add_business_term/tool_test.go b/pkg/tools/prepare_add_business_term/tool_test.go new file mode 100644 index 0000000..e0bc0eb --- /dev/null +++ b/pkg/tools/prepare_add_business_term/tool_test.go @@ -0,0 +1,434 @@ +package prepare_add_business_term_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/prepare_add_business_term" + "github.com/collibra/chip/pkg/tools/testutil" +) + +// testDomains returns a standard set of test domains. +func testDomains() clients.PrepareAddBusinessTermDomainsResponse { + return clients.PrepareAddBusinessTermDomainsResponse{ + Total: 2, + Results: []clients.PrepareAddBusinessTermDomain{ + {ID: "domain-1", Name: "Finance"}, + {ID: "domain-2", Name: "Marketing"}, + }, + } +} + +// testAssetType returns a standard test business term asset type. +func testAssetType() clients.PrepareAddBusinessTermAssetType { + return clients.PrepareAddBusinessTermAssetType{ + ID: "asset-type-1", + Name: "Business Term", + } +} + +// testRawAssignment represents the raw API response shape for assignments. +type testRawAssignment struct { + ID string `json:"id"` + AssignedCharacteristicTypeReferences []testCharacteristicTypeRef `json:"assignedCharacteristicTypeReferences"` +} + +// testCharacteristicTypeRef represents a characteristic type reference in the raw API. +type testCharacteristicTypeRef struct { + ID string `json:"id"` + AssignedResourceReference testNamedRef `json:"assignedResourceReference"` + MinimumOccurrences int `json:"minimumOccurrences"` + MaximumOccurrences *int `json:"maximumOccurrences"` +} + +// testNamedRef is a simple id+name+discriminator reference. +type testNamedRef struct { + ID string `json:"id"` + Name string `json:"name"` + ResourceDiscriminator string `json:"resourceDiscriminator,omitempty"` +} + +// testRawAttributeType represents the raw API response shape for an attribute type. +type testRawAttributeType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AttributeTypeDiscriminator string `json:"attributeTypeDiscriminator"` +} + +// testAssignments returns a standard set of test assignments in the raw API format. +func testAssignments() []testRawAssignment { + maxOcc := 1 + return []testRawAssignment{ + { + ID: "assignment-1", + AssignedCharacteristicTypeReferences: []testCharacteristicTypeRef{ + { + ID: "ref-1", + AssignedResourceReference: testNamedRef{ID: "attr-type-1", Name: "Definition", ResourceDiscriminator: "StringAttributeType"}, + MinimumOccurrences: 1, + MaximumOccurrences: &maxOcc, + }, + }, + }, + } +} + +// testAttributeType returns a standard test attribute type in the raw API format. +func testAttributeType() testRawAttributeType { + return testRawAttributeType{ + ID: "attr-type-1", + Name: "Definition", + Description: "The definition of the business term", + AttributeTypeDiscriminator: "StringAttributeType", + } +} + +// registerCommonHandlers registers handlers for domain list, asset type, assignments, and attribute types. +func registerCommonHandlers(mux *http.ServeMux) { + mux.Handle("GET /rest/2.0/domains", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermDomainsResponse) { + return http.StatusOK, testDomains() + })) + + mux.Handle("GET /rest/2.0/domains/{id}", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomain) { + id := r.PathValue("id") + for _, d := range testDomains().Results { + if d.ID == id { + return http.StatusOK, d + } + } + return http.StatusNotFound, clients.PrepareAddBusinessTermDomain{} + })) + + mux.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetType) { + return http.StatusOK, testAssetType() + })) + + mux.Handle("GET /rest/2.0/assignments/assetType/{assetTypeId}", testutil.JsonHandlerOut(func(_ *http.Request) (int, []testRawAssignment) { + return http.StatusOK, testAssignments() + })) + + mux.Handle("GET /rest/2.0/attributeTypes/{id}", testutil.JsonHandlerOut(func(_ *http.Request) (int, testRawAttributeType) { + return http.StatusOK, testAttributeType() + })) +} + +func TestPrepareAddBusinessTermReady(t *testing.T) { + mux := http.NewServeMux() + registerCommonHandlers(mux) + + // No duplicates found. + mux.Handle("GET /rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetsResponse{Total: 0, Results: []clients.PrepareAddBusinessTermAsset{}} + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + DomainID: "domain-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got: '%s'", output.Status) + } + if output.ResolvedDomain == nil { + t.Fatalf("Expected resolved domain, got nil") + } + if output.ResolvedDomain.ID != "domain-1" { + t.Errorf("Expected resolved domain ID 'domain-1', got: '%s'", output.ResolvedDomain.ID) + } + if output.ResolvedDomain.Name != "Finance" { + t.Errorf("Expected resolved domain name 'Finance', got: '%s'", output.ResolvedDomain.Name) + } + if len(output.AttributeSchema) != 1 { + t.Fatalf("Expected 1 attribute schema entry, got: %d", len(output.AttributeSchema)) + } + schema := output.AttributeSchema[0] + if schema.ID != "attr-type-1" { + t.Errorf("Expected attribute ID 'attr-type-1', got: '%s'", schema.ID) + } + if schema.Kind != "StringAttributeType" { + t.Errorf("Expected attribute kind 'StringAttributeType', got: '%s'", schema.Kind) + } + if !schema.Required { + t.Errorf("Expected attribute to be required") + } + if len(output.Duplicates) != 0 { + t.Errorf("Expected no duplicates, got: %d", len(output.Duplicates)) + } +} + +func TestPrepareAddBusinessTermReadyWithDomainName(t *testing.T) { + mux := http.NewServeMux() + registerCommonHandlers(mux) + + mux.Handle("GET /rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetsResponse{Total: 0, Results: []clients.PrepareAddBusinessTermAsset{}} + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + DomainName: "finance", // case-insensitive match + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got: '%s'", output.Status) + } + if output.ResolvedDomain == nil { + t.Fatalf("Expected resolved domain, got nil") + } + if output.ResolvedDomain.ID != "domain-1" { + t.Errorf("Expected resolved domain ID 'domain-1', got: '%s'", output.ResolvedDomain.ID) + } +} + +func TestPrepareAddBusinessTermIncomplete_MissingName(t *testing.T) { + mux := http.NewServeMux() + registerCommonHandlers(mux) + + // No duplicate search when name is empty — assets endpoint should not be called, + // but register it anyway for safety. + mux.Handle("GET /rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetsResponse{Total: 0, Results: []clients.PrepareAddBusinessTermAsset{}} + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "", + DomainID: "domain-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "incomplete" { + t.Errorf("Expected status 'incomplete', got: '%s'", output.Status) + } + if len(output.AvailableDomains) == 0 { + t.Errorf("Expected available domains to be pre-fetched") + } + if len(output.AttributeSchema) == 0 { + t.Errorf("Expected attribute schema to be hydrated") + } +} + +func TestPrepareAddBusinessTermIncomplete_MissingDomain(t *testing.T) { + mux := http.NewServeMux() + registerCommonHandlers(mux) + + mux.Handle("GET /rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetsResponse{Total: 0, Results: []clients.PrepareAddBusinessTermAsset{}} + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + // No domain provided. + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "incomplete" { + t.Errorf("Expected status 'incomplete', got: '%s'", output.Status) + } + if output.ResolvedDomain != nil { + t.Errorf("Expected nil resolved domain, got: %+v", output.ResolvedDomain) + } + if len(output.AvailableDomains) != 2 { + t.Errorf("Expected 2 available domains, got: %d", len(output.AvailableDomains)) + } +} + +func TestPrepareAddBusinessTermNeedsClarification(t *testing.T) { + mux := http.NewServeMux() + + // Return two domains with the same name to trigger clarification. + mux.Handle("GET /rest/2.0/domains", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermDomainsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainsResponse{ + Total: 2, + Results: []clients.PrepareAddBusinessTermDomain{ + {ID: "domain-a", Name: "Sales"}, + {ID: "domain-b", Name: "Sales"}, + }, + } + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + DomainName: "Sales", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "needs_clarification" { + t.Errorf("Expected status 'needs_clarification', got: '%s'", output.Status) + } + if len(output.AvailableDomains) != 2 { + t.Errorf("Expected 2 matching domains, got: %d", len(output.AvailableDomains)) + } +} + +func TestPrepareAddBusinessTermDuplicateFound(t *testing.T) { + mux := http.NewServeMux() + registerCommonHandlers(mux) + + // Return a duplicate asset. + mux.Handle("GET /rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetsResponse{ + Total: 1, + Results: []clients.PrepareAddBusinessTermAsset{ + { + ID: "existing-asset-1", + Name: "Revenue", + Domain: clients.PrepareAddBusinessTermDomain{ + ID: "domain-1", + Name: "Finance", + }, + }, + }, + } + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + DomainID: "domain-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "duplicate_found" { + t.Errorf("Expected status 'duplicate_found', got: '%s'", output.Status) + } + if len(output.Duplicates) != 1 { + t.Fatalf("Expected 1 duplicate, got: %d", len(output.Duplicates)) + } + if output.Duplicates[0].ID != "existing-asset-1" { + t.Errorf("Expected duplicate ID 'existing-asset-1', got: '%s'", output.Duplicates[0].ID) + } + if output.Duplicates[0].Domain.Name != "Finance" { + t.Errorf("Expected duplicate domain name 'Finance', got: '%s'", output.Duplicates[0].Domain.Name) + } + if len(output.AttributeSchema) == 0 { + t.Errorf("Expected attribute schema to be present on duplicate_found") + } +} + +func TestPrepareAddBusinessTermAPIError(t *testing.T) { + mux := http.NewServeMux() + + // Domains endpoint returns an error. + mux.Handle("GET /rest/2.0/domains", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + _, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + DomainID: "domain-1", + }) + if err == nil { + t.Fatalf("Expected error, got nil") + } +} + +func TestPrepareAddBusinessTermAssetTypeAPIError(t *testing.T) { + mux := http.NewServeMux() + + // Domains works fine. + mux.Handle("GET /rest/2.0/domains", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermDomainsResponse) { + return http.StatusOK, testDomains() + })) + + // Asset type endpoint returns error. + mux.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + _, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + }) + if err == nil { + t.Fatalf("Expected error for asset type API failure, got nil") + } +} + +func TestPrepareAddBusinessTermEmptyAssignments(t *testing.T) { + mux := http.NewServeMux() + + mux.Handle("GET /rest/2.0/domains", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermDomainsResponse) { + return http.StatusOK, testDomains() + })) + mux.Handle("GET /rest/2.0/domains/{id}", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermDomain) { + return http.StatusOK, clients.PrepareAddBusinessTermDomain{ID: "domain-1", Name: "Finance"} + })) + mux.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetType) { + return http.StatusOK, testAssetType() + })) + + // Empty assignments — no attributes configured. + mux.Handle("GET /rest/2.0/assignments/assetType/{assetTypeId}", testutil.JsonHandlerOut(func(_ *http.Request) (int, []testRawAssignment) { + return http.StatusOK, []testRawAssignment{} + })) + + mux.Handle("GET /rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareAddBusinessTermAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetsResponse{Total: 0, Results: []clients.PrepareAddBusinessTermAsset{}} + })) + + server := httptest.NewServer(mux) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_add_business_term.NewTool(client).Handler(t.Context(), prepare_add_business_term.Input{ + Name: "Revenue", + DomainID: "domain-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got: '%s'", output.Status) + } + if len(output.AttributeSchema) != 0 { + t.Errorf("Expected 0 attribute schema entries, got: %d", len(output.AttributeSchema)) + } +} diff --git a/pkg/tools/prepare_create_asset/tool.go b/pkg/tools/prepare_create_asset/tool.go new file mode 100644 index 0000000..0480f68 --- /dev/null +++ b/pkg/tools/prepare_create_asset/tool.go @@ -0,0 +1,233 @@ +package prepare_create_asset + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +const maxOptions = 20 + +// Input defines the input parameters for the prepare_create_asset tool. +type Input struct { + AssetName string `json:"assetName" jsonschema:"The name of the asset to create"` + AssetTypeID string `json:"assetTypeId,omitempty" jsonschema:"Optional. The publicId of the asset type"` + DomainID string `json:"domainId,omitempty" jsonschema:"Optional. The ID of the target domain"` + AttributeTypeIDs []string `json:"attributeTypeIds,omitempty" jsonschema:"Optional. List of attribute type IDs to hydrate schema for"` +} + +// AssetTypeOption represents an asset type option returned when the asset type is missing. +type AssetTypeOption struct { + ID string `json:"id" jsonschema:"The internal ID of the asset type"` + PublicID string `json:"publicId" jsonschema:"The public ID of the asset type"` + Name string `json:"name" jsonschema:"The name of the asset type"` +} + +// ResolvedInfo contains the resolved UUIDs needed by create_asset. +type ResolvedInfo struct { + AssetTypeID string `json:"assetTypeId" jsonschema:"The resolved UUID of the asset type — pass this to create_asset"` + AssetTypeName string `json:"assetTypeName" jsonschema:"The resolved name of the asset type"` + DomainID string `json:"domainId" jsonschema:"The resolved UUID of the domain — pass this to create_asset"` + DomainName string `json:"domainName" jsonschema:"The resolved name of the domain"` +} + +// DomainOption represents a domain option returned when the domain is missing. +type DomainOption struct { + ID string `json:"id" jsonschema:"The ID of the domain"` + Name string `json:"name" jsonschema:"The name of the domain"` +} + +// AttributeSchema represents the full schema for an attribute type. +type AttributeSchema struct { + ID string `json:"id" jsonschema:"The ID of the attribute type"` + Name string `json:"name" jsonschema:"The name of the attribute type"` + Kind string `json:"kind" jsonschema:"The data type of the attribute"` + Required bool `json:"required" jsonschema:"Whether the attribute is mandatory"` + Constraints *clients.PrepareCreateConstraints `json:"constraints,omitempty" jsonschema:"Optional. Validation constraints for the attribute"` + AllowedValues []string `json:"allowedValues,omitempty" jsonschema:"Optional. List of permitted values if restricted"` + Direction string `json:"direction,omitempty" jsonschema:"Optional. Direction for relation attributes"` + TargetAssetType *AssetTypeOption `json:"targetAssetType,omitempty" jsonschema:"Optional. Target asset type for relation attributes"` +} + +// DuplicateAsset represents an existing asset found during duplicate checking. +type DuplicateAsset struct { + ID string `json:"id" jsonschema:"The ID of the duplicate asset"` + Name string `json:"name" jsonschema:"The name of the duplicate asset"` +} + +// Output defines the output of the prepare_create_asset tool. +type Output struct { + Status string `json:"status" jsonschema:"The preparation status: ready, incomplete, needs_clarification, or duplicate_found"` + Message string `json:"message" jsonschema:"A human-readable message explaining the status"` + Resolved *ResolvedInfo `json:"resolved,omitempty" jsonschema:"Optional. Resolved UUIDs for asset type and domain — present when status is ready. Pass these to create_asset."` + AssetTypeOptions []AssetTypeOption `json:"assetTypeOptions,omitempty" jsonschema:"Optional. Available asset types when asset type is missing"` + DomainOptions []DomainOption `json:"domainOptions,omitempty" jsonschema:"Optional. Available domains when domain is missing"` + OptionsTruncated bool `json:"optionsTruncated" jsonschema:"Whether options were truncated to the maximum limit of 20"` + AttributeSchema []AttributeSchema `json:"attributeSchema,omitempty" jsonschema:"Optional. Full attribute schemas for the asset type"` + Duplicates []DuplicateAsset `json:"duplicates,omitempty" jsonschema:"Optional. Existing assets that may be duplicates"` +} + +// NewTool creates the prepare_create_asset tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "prepare_create_asset", + Description: "Resolve asset type, domain, hydrate full attribute schema, check duplicates — return structured status for asset creation readiness.", + Handler: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + truncated := false + + // If asset type is missing, return incomplete with options + if input.AssetTypeID == "" { + assetTypes, total, err := clients.ListAssetTypesForPrepare(ctx, collibraClient, maxOptions+1) + if err != nil { + return Output{}, err + } + if total > maxOptions { + truncated = true + } + if len(assetTypes) > maxOptions { + assetTypes = assetTypes[:maxOptions] + } + options := make([]AssetTypeOption, len(assetTypes)) + for i, at := range assetTypes { + options[i] = AssetTypeOption{ID: at.ID, PublicID: at.PublicID, Name: at.Name} + } + return Output{ + Status: string(clients.StatusIncomplete), + Message: "Asset type is required. Please select from the available options.", + AssetTypeOptions: options, + OptionsTruncated: truncated, + }, nil + } + + // If domain is missing, return incomplete with options + if input.DomainID == "" { + domains, total, err := clients.ListDomainsForPrepare(ctx, collibraClient, maxOptions+1) + if err != nil { + return Output{}, err + } + if total > maxOptions { + truncated = true + } + if len(domains) > maxOptions { + domains = domains[:maxOptions] + } + options := make([]DomainOption, len(domains)) + for i, d := range domains { + options[i] = DomainOption{ID: d.ID, Name: d.Name} + } + return Output{ + Status: string(clients.StatusIncomplete), + Message: "Domain is required. Please select from the available options.", + DomainOptions: options, + OptionsTruncated: truncated, + }, nil + } + + // Resolve asset type by publicId + assetType, err := clients.GetAssetTypeByPublicID(ctx, collibraClient, input.AssetTypeID) + if err != nil { + return Output{ + Status: string(clients.StatusNeedsClarification), + Message: "Could not resolve asset type: " + err.Error(), + }, nil + } + + // Validate domain exists + domain, err := clients.GetDomainByID(ctx, collibraClient, input.DomainID) + if err != nil { + return Output{ + Status: string(clients.StatusNeedsClarification), + Message: "Could not resolve domain: " + err.Error(), + }, nil + } + + // Validate asset type is allowed in the target domain + allowedTypes, err := clients.GetAvailableAssetTypesForDomain(ctx, collibraClient, domain.ID) + if err != nil { + return Output{}, err + } + + domainAllowed := false + for _, at := range allowedTypes { + if at.ID == assetType.ID { + domainAllowed = true + break + } + } + if !domainAllowed { + return Output{ + Status: string(clients.StatusNeedsClarification), + Message: "Asset type \"" + assetType.Name + "\" is not allowed in domain \"" + domain.Name + "\". Please select a valid combination.", + }, nil + } + + // Check for duplicates + duplicates, err := clients.SearchAssetsForDuplicate(ctx, collibraClient, input.AssetName, assetType.ID, domain.ID) + if err != nil { + return Output{}, err + } + if len(duplicates) > 0 { + dups := make([]DuplicateAsset, len(duplicates)) + for i, d := range duplicates { + dups[i] = DuplicateAsset{ID: d.ID, Name: d.Name} + } + return Output{ + Status: string(clients.StatusDuplicateFound), + Message: "An asset with the same name already exists in this domain.", + Duplicates: dups, + }, nil + } + + // Hydrate attribute schemas + var schemas []AttributeSchema + for _, attrID := range input.AttributeTypeIDs { + attrType, err := clients.GetAttributeTypeByID(ctx, collibraClient, attrID) + if err != nil { + return Output{}, err + } + schema := AttributeSchema{ + ID: attrType.ID, + Name: attrType.Name, + Kind: attrType.Kind, + Required: attrType.Required, + } + if attrType.Constraints != nil { + schema.Constraints = attrType.Constraints + } + if len(attrType.AllowedValues) > 0 { + schema.AllowedValues = attrType.AllowedValues + } + if attrType.Direction != "" { + schema.Direction = attrType.Direction + } + if attrType.TargetAssetType != nil { + schema.TargetAssetType = &AssetTypeOption{ + ID: attrType.TargetAssetType.ID, + PublicID: attrType.TargetAssetType.PublicID, + Name: attrType.TargetAssetType.Name, + } + } + schemas = append(schemas, schema) + } + + return Output{ + Status: string(clients.StatusReady), + Message: "All validations passed. Ready to create asset \"" + input.AssetName + "\" of type \"" + assetType.Name + "\" in domain \"" + domain.Name + "\".", + Resolved: &ResolvedInfo{ + AssetTypeID: assetType.ID, + AssetTypeName: assetType.Name, + DomainID: domain.ID, + DomainName: domain.Name, + }, + AttributeSchema: schemas, + }, nil + } +} diff --git a/pkg/tools/prepare_create_asset/tool_test.go b/pkg/tools/prepare_create_asset/tool_test.go new file mode 100644 index 0000000..8538dcc --- /dev/null +++ b/pkg/tools/prepare_create_asset/tool_test.go @@ -0,0 +1,456 @@ +package prepare_create_asset_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/prepare_create_asset" + "github.com/collibra/chip/pkg/tools/testutil" +) + +func TestReadyStatus(t *testing.T) { + handler := http.NewServeMux() + + // Resolve asset type by publicId + handler.Handle("/rest/2.0/assetTypes/publicId/DataSet", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetType) { + return http.StatusOK, clients.PrepareCreateAssetType{ + ID: "at-123", PublicID: "DataSet", Name: "Data Set", + } + })) + + // Validate domain + handler.Handle("/rest/2.0/domains/dom-456", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateDomain) { + return http.StatusOK, clients.PrepareCreateDomain{ + ID: "dom-456", Name: "Marketing", + } + })) + + // Available asset types for domain - asset type is allowed + handler.Handle("/rest/2.0/assignments/domain/dom-456/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, []clients.PrepareCreateAssetType) { + return http.StatusOK, []clients.PrepareCreateAssetType{ + {ID: "at-123", PublicID: "DataSet", Name: "Data Set"}, + } + })) + + // No duplicates found + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetSearchResponse) { + return http.StatusOK, clients.PrepareCreateAssetSearchResponse{ + Results: []clients.PrepareCreateAssetResult{}, + Total: 0, + } + })) + + // Attribute type hydration + handler.Handle("/rest/2.0/attributeTypes/attr-1", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAttributeType) { + return http.StatusOK, clients.PrepareCreateAttributeType{ + ID: "attr-1", Name: "Description", Kind: "STRING", Required: true, + AllowedValues: []string{"A", "B"}, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "Campaign Data", + AssetTypeID: "DataSet", + DomainID: "dom-456", + AttributeTypeIDs: []string{"attr-1"}, + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got: %s", output.Status) + } + if len(output.AttributeSchema) != 1 { + t.Fatalf("Expected 1 attribute schema, got: %d", len(output.AttributeSchema)) + } + if output.AttributeSchema[0].Kind != "STRING" { + t.Errorf("Expected kind 'STRING', got: %s", output.AttributeSchema[0].Kind) + } + if !output.AttributeSchema[0].Required { + t.Errorf("Expected attribute to be required") + } + if len(output.AttributeSchema[0].AllowedValues) != 2 { + t.Errorf("Expected 2 allowed values, got: %d", len(output.AttributeSchema[0].AllowedValues)) + } +} + +func TestIncompleteNoAssetType(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetTypeListResponse) { + return http.StatusOK, clients.PrepareCreateAssetTypeListResponse{ + Results: []clients.PrepareCreateAssetType{ + {ID: "at-1", PublicID: "DataSet", Name: "Data Set"}, + {ID: "at-2", PublicID: "Report", Name: "Report"}, + }, + Total: 2, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "My Asset", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "incomplete" { + t.Errorf("Expected status 'incomplete', got: %s", output.Status) + } + if len(output.AssetTypeOptions) != 2 { + t.Errorf("Expected 2 asset type options, got: %d", len(output.AssetTypeOptions)) + } + if output.OptionsTruncated { + t.Errorf("Expected options_truncated to be false") + } +} + +func TestIncompleteNoDomain(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/domains", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateDomainListResponse) { + return http.StatusOK, clients.PrepareCreateDomainListResponse{ + Results: []clients.PrepareCreateDomain{ + {ID: "dom-1", Name: "Marketing"}, + }, + Total: 1, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "My Asset", + AssetTypeID: "DataSet", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "incomplete" { + t.Errorf("Expected status 'incomplete', got: %s", output.Status) + } + if len(output.DomainOptions) != 1 { + t.Errorf("Expected 1 domain option, got: %d", len(output.DomainOptions)) + } +} + +func TestOptionsTruncated(t *testing.T) { + handler := http.NewServeMux() + + // Build 21 asset types to trigger truncation + types := make([]clients.PrepareCreateAssetType, 21) + for i := 0; i < 21; i++ { + types[i] = clients.PrepareCreateAssetType{ + ID: "at-id", PublicID: "pub", Name: "Type", + } + } + + handler.Handle("/rest/2.0/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetTypeListResponse) { + return http.StatusOK, clients.PrepareCreateAssetTypeListResponse{ + Results: types, + Total: 25, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "My Asset", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "incomplete" { + t.Errorf("Expected status 'incomplete', got: %s", output.Status) + } + if !output.OptionsTruncated { + t.Errorf("Expected options_truncated to be true") + } + if len(output.AssetTypeOptions) != 20 { + t.Errorf("Expected 20 asset type options, got: %d", len(output.AssetTypeOptions)) + } +} + +func TestNeedsClarificationInvalidAssetType(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes/publicId/BadType", testutil.JsonHandlerOut(func(_ *http.Request) (int, map[string]string) { + return http.StatusNotFound, map[string]string{"error": "not found"} + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "My Asset", + AssetTypeID: "BadType", + DomainID: "dom-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "needs_clarification" { + t.Errorf("Expected status 'needs_clarification', got: %s", output.Status) + } + if !strings.Contains(output.Message, "Could not resolve asset type") { + t.Errorf("Expected message about asset type resolution, got: %s", output.Message) + } +} + +func TestNeedsClarificationDomainNotAllowed(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes/publicId/DataSet", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetType) { + return http.StatusOK, clients.PrepareCreateAssetType{ + ID: "at-123", PublicID: "DataSet", Name: "Data Set", + } + })) + + handler.Handle("/rest/2.0/domains/dom-999", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateDomain) { + return http.StatusOK, clients.PrepareCreateDomain{ + ID: "dom-999", Name: "Restricted Domain", + } + })) + + // Available asset types for domain - asset type NOT in the list + handler.Handle("/rest/2.0/assignments/domain/dom-999/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, []clients.PrepareCreateAssetType) { + return http.StatusOK, []clients.PrepareCreateAssetType{ + {ID: "at-other", PublicID: "Report", Name: "Report"}, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "My Asset", + AssetTypeID: "DataSet", + DomainID: "dom-999", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "needs_clarification" { + t.Errorf("Expected status 'needs_clarification', got: %s", output.Status) + } + if !strings.Contains(output.Message, "not allowed") { + t.Errorf("Expected message about domain not allowed, got: %s", output.Message) + } +} + +func TestDuplicateFound(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes/publicId/DataSet", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetType) { + return http.StatusOK, clients.PrepareCreateAssetType{ + ID: "at-123", PublicID: "DataSet", Name: "Data Set", + } + })) + + handler.Handle("/rest/2.0/domains/dom-456", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateDomain) { + return http.StatusOK, clients.PrepareCreateDomain{ + ID: "dom-456", Name: "Marketing", + } + })) + + handler.Handle("/rest/2.0/assignments/domain/dom-456/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, []clients.PrepareCreateAssetType) { + return http.StatusOK, []clients.PrepareCreateAssetType{ + {ID: "at-123", PublicID: "DataSet", Name: "Data Set"}, + } + })) + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetSearchResponse) { + return http.StatusOK, clients.PrepareCreateAssetSearchResponse{ + Results: []clients.PrepareCreateAssetResult{ + {ID: "existing-1", Name: "Campaign Data"}, + }, + Total: 1, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "Campaign Data", + AssetTypeID: "DataSet", + DomainID: "dom-456", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "duplicate_found" { + t.Errorf("Expected status 'duplicate_found', got: %s", output.Status) + } + if len(output.Duplicates) != 1 { + t.Fatalf("Expected 1 duplicate, got: %d", len(output.Duplicates)) + } + if output.Duplicates[0].ID != "existing-1" { + t.Errorf("Expected duplicate ID 'existing-1', got: %s", output.Duplicates[0].ID) + } +} + +func TestReadyWithRelationAttribute(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes/publicId/DataSet", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetType) { + return http.StatusOK, clients.PrepareCreateAssetType{ + ID: "at-123", PublicID: "DataSet", Name: "Data Set", + } + })) + + handler.Handle("/rest/2.0/domains/dom-456", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateDomain) { + return http.StatusOK, clients.PrepareCreateDomain{ + ID: "dom-456", Name: "Marketing", + } + })) + + handler.Handle("/rest/2.0/assignments/domain/dom-456/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, []clients.PrepareCreateAssetType) { + return http.StatusOK, []clients.PrepareCreateAssetType{ + {ID: "at-123", PublicID: "DataSet", Name: "Data Set"}, + } + })) + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetSearchResponse) { + return http.StatusOK, clients.PrepareCreateAssetSearchResponse{ + Results: []clients.PrepareCreateAssetResult{}, + Total: 0, + } + })) + + handler.Handle("/rest/2.0/attributeTypes/rel-1", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAttributeType) { + return http.StatusOK, clients.PrepareCreateAttributeType{ + ID: "rel-1", Name: "Owner", Kind: "RELATION", Required: false, + Direction: "OUTGOING", + TargetAssetType: &clients.PrepareCreateAssetType{ + ID: "at-999", PublicID: "Person", Name: "Person", + }, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "Campaign Data", + AssetTypeID: "DataSet", + DomainID: "dom-456", + AttributeTypeIDs: []string{"rel-1"}, + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got: %s", output.Status) + } + if len(output.AttributeSchema) != 1 { + t.Fatalf("Expected 1 attribute schema, got: %d", len(output.AttributeSchema)) + } + schema := output.AttributeSchema[0] + if schema.Direction != "OUTGOING" { + t.Errorf("Expected direction 'OUTGOING', got: %s", schema.Direction) + } + if schema.TargetAssetType == nil { + t.Fatal("Expected target asset type to be set") + } + if schema.TargetAssetType.Name != "Person" { + t.Errorf("Expected target asset type name 'Person', got: %s", schema.TargetAssetType.Name) + } +} + +func TestReadyNoAttributes(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes/publicId/DataSet", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetType) { + return http.StatusOK, clients.PrepareCreateAssetType{ + ID: "at-123", PublicID: "DataSet", Name: "Data Set", + } + })) + + handler.Handle("/rest/2.0/domains/dom-456", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateDomain) { + return http.StatusOK, clients.PrepareCreateDomain{ + ID: "dom-456", Name: "Marketing", + } + })) + + handler.Handle("/rest/2.0/assignments/domain/dom-456/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, []clients.PrepareCreateAssetType) { + return http.StatusOK, []clients.PrepareCreateAssetType{ + {ID: "at-123", PublicID: "DataSet", Name: "Data Set"}, + } + })) + + handler.Handle("/rest/2.0/assets", testutil.JsonHandlerOut(func(_ *http.Request) (int, clients.PrepareCreateAssetSearchResponse) { + return http.StatusOK, clients.PrepareCreateAssetSearchResponse{ + Results: []clients.PrepareCreateAssetResult{}, + Total: 0, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "Campaign Data", + AssetTypeID: "DataSet", + DomainID: "dom-456", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got: %s", output.Status) + } + if len(output.AttributeSchema) != 0 { + t.Errorf("Expected 0 attribute schemas, got: %d", len(output.AttributeSchema)) + } +} + +func TestAPIErrorOnAssetTypeList(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("/rest/2.0/assetTypes", testutil.JsonHandlerOut(func(_ *http.Request) (int, map[string]string) { + return http.StatusInternalServerError, map[string]string{"error": "server error"} + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + _, err := prepare_create_asset.NewTool(client).Handler(t.Context(), prepare_create_asset.Input{ + AssetName: "My Asset", + }) + if err == nil { + t.Fatal("Expected error for server error response") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("Expected error to contain status code 500, got: %s", err.Error()) + } +} diff --git a/pkg/tools/pull_data_contract_manifest.go b/pkg/tools/pull_data_contract_manifest/tool.go similarity index 63% rename from pkg/tools/pull_data_contract_manifest.go rename to pkg/tools/pull_data_contract_manifest/tool.go index 33ec6ba..a6301c6 100644 --- a/pkg/tools/pull_data_contract_manifest.go +++ b/pkg/tools/pull_data_contract_manifest/tool.go @@ -1,4 +1,4 @@ -package tools +package pull_data_contract_manifest import ( "context" @@ -10,30 +10,30 @@ import ( "github.com/google/uuid" ) -type PullDataContractManifestInput struct { +type Input struct { DataContractID string `json:"dataContractId" jsonschema:"The UUID of the data contract asset (which is an asset type with ID 00000000-0000-0000-0000-000000050003) for which to download the active manifest version"` } -type PullDataContractManifestOutput struct { +type Output struct { Manifest string `json:"manifest,omitempty" jsonschema:"The content of the active data contract manifest file"` Error string `json:"error,omitempty" jsonschema:"Error message if the manifest could not be retrieved"` Found bool `json:"found" jsonschema:"Whether the manifest was found"` } -func NewPullDataContractManifestTool(collibraClient *http.Client) *chip.Tool[PullDataContractManifestInput, PullDataContractManifestOutput] { - return &chip.Tool[PullDataContractManifestInput, PullDataContractManifestOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "pull_data_contract_manifest", Description: "Download the manifest file for the currently active version of a specific data contract. Returns the manifest content as a string.", - Handler: handlePullDataContractManifest(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handlePullDataContractManifest(collibraClient *http.Client) chip.ToolHandlerFunc[PullDataContractManifestInput, PullDataContractManifestOutput] { - return func(ctx context.Context, input PullDataContractManifestInput) (PullDataContractManifestOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { dataContractUUID, err := uuid.Parse(input.DataContractID) if err != nil { - return PullDataContractManifestOutput{ + return Output{ Error: fmt.Sprintf("Invalid data contract ID format: %s", err.Error()), Found: false, }, nil @@ -41,13 +41,13 @@ func handlePullDataContractManifest(collibraClient *http.Client) chip.ToolHandle manifest, err := clients.PullActiveDataContractManifest(ctx, collibraClient, dataContractUUID.String()) if err != nil { - return PullDataContractManifestOutput{ + return Output{ Error: fmt.Sprintf("Failed to download manifest: %s", err.Error()), Found: false, }, nil } - return PullDataContractManifestOutput{ + return Output{ Manifest: string(manifest), Found: true, }, nil diff --git a/pkg/tools/pull_data_contract_manifest_test.go b/pkg/tools/pull_data_contract_manifest/tool_test.go similarity index 76% rename from pkg/tools/pull_data_contract_manifest_test.go rename to pkg/tools/pull_data_contract_manifest/tool_test.go index bcbbc06..15e4b96 100644 --- a/pkg/tools/pull_data_contract_manifest_test.go +++ b/pkg/tools/pull_data_contract_manifest/tool_test.go @@ -1,11 +1,12 @@ -package tools_test +package pull_data_contract_manifest_test import ( "net/http" "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/pull_data_contract_manifest" + "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -20,15 +21,15 @@ func TestPullDataContractManifest(t *testing.T) { ` handler := http.NewServeMux() - handler.Handle("/rest/dataProduct/v1/dataContracts/"+contractId.String()+"/activeVersion/manifest", StringHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/rest/dataProduct/v1/dataContracts/"+contractId.String()+"/activeVersion/manifest", testutil.StringHandlerOut(func(r *http.Request) (int, string) { return http.StatusOK, manifestContent })) server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewPullDataContractManifestTool(client).Handler(t.Context(), tools.PullDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ DataContractID: contractId.String(), }) if err != nil { @@ -52,8 +53,8 @@ func TestPullDataContractManifestInvalidUUID(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() - client := newClient(server) - output, err := tools.NewPullDataContractManifestTool(client).Handler(t.Context(), tools.PullDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ DataContractID: "invalid-uuid", }) if err != nil { @@ -78,8 +79,8 @@ func TestPullDataContractManifestNotFound(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewPullDataContractManifestTool(client).Handler(t.Context(), tools.PullDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ DataContractID: contractId.String(), }) if err != nil { diff --git a/pkg/tools/push_data_contract_manifest.go b/pkg/tools/push_data_contract_manifest/tool.go similarity index 79% rename from pkg/tools/push_data_contract_manifest.go rename to pkg/tools/push_data_contract_manifest/tool.go index 4032cdc..864a73a 100644 --- a/pkg/tools/push_data_contract_manifest.go +++ b/pkg/tools/push_data_contract_manifest/tool.go @@ -1,4 +1,4 @@ -package tools +package push_data_contract_manifest import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type PushDataContractManifestInput struct { +type Input struct { ManifestID string `json:"manifestId,omitempty" jsonschema:"The unique identifier of the data contract as specified in the manifest. If omitted and a manifest that adheres to the Open Data Contract Standard is provided, the manifestID will be parsed automatically. Maximum length: 200 characters."` Manifest string `json:"manifest" jsonschema:"The content of the data contract manifest file"` Version string `json:"version,omitempty" jsonschema:"Optional. The version of the data contract manifest being uploaded. If omitted, the version will be parsed automatically from the manifest unless it does not adhere to the Open Data Contract Standard. Maximum length: 100 characters."` @@ -17,7 +17,7 @@ type PushDataContractManifestInput struct { Active bool `json:"active,omitempty" jsonschema:"Optional. Set to true to make this data contract manifest version the active version. This will automatically deactivate the previous active version. The active version is the one that's exposed through the data contract asset. Defaults to true."` } -type PushDataContractManifestOutput struct { +type Output struct { ID string `json:"id,omitempty" jsonschema:"The UUID of the data contract asset"` DomainID string `json:"domainId,omitempty" jsonschema:"The UUID of the domain where the data contract asset is located"` ManifestID string `json:"manifestId,omitempty" jsonschema:"The unique identifier of the data contract manifest"` @@ -25,19 +25,19 @@ type PushDataContractManifestOutput struct { Success bool `json:"success" jsonschema:"Whether the manifest was successfully uploaded"` } -func NewPushDataContractManifestTool(collibraClient *http.Client) *chip.Tool[PushDataContractManifestInput, PushDataContractManifestOutput] { - return &chip.Tool[PushDataContractManifestInput, PushDataContractManifestOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "push_data_contract_manifest", Description: "Upload a new version of a data contract manifest to Collibra. The manifestID and version are automatically parsed from the manifest content if it adheres to the Open Data Contract Standard.", - Handler: handlePushDataContractManifest(collibraClient), + Handler: handler(collibraClient), Permissions: []string{"dgc.data-contract"}, } } -func handlePushDataContractManifest(collibraClient *http.Client) chip.ToolHandlerFunc[PushDataContractManifestInput, PushDataContractManifestOutput] { - return func(ctx context.Context, input PushDataContractManifestInput) (PushDataContractManifestOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.Manifest == "" { - return PushDataContractManifestOutput{ + return Output{ Error: "Manifest content is required", Success: false, }, nil @@ -53,13 +53,13 @@ func handlePushDataContractManifest(collibraClient *http.Client) chip.ToolHandle response, err := clients.PushDataContractManifest(ctx, collibraClient, req) if err != nil { - return PushDataContractManifestOutput{ + return Output{ Error: fmt.Sprintf("Failed to upload manifest: %s", err.Error()), Success: false, }, nil } - return PushDataContractManifestOutput{ + return Output{ ID: response.ID, DomainID: response.DomainID, ManifestID: response.ManifestID, diff --git a/pkg/tools/push_data_contract_manifest_test.go b/pkg/tools/push_data_contract_manifest/tool_test.go similarity index 88% rename from pkg/tools/push_data_contract_manifest_test.go rename to pkg/tools/push_data_contract_manifest/tool_test.go index d24e8ca..78a844d 100644 --- a/pkg/tools/push_data_contract_manifest_test.go +++ b/pkg/tools/push_data_contract_manifest/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package push_data_contract_manifest_test import ( "io" @@ -8,7 +8,8 @@ import ( "strings" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/push_data_contract_manifest" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestPushDataContractManifest(t *testing.T) { @@ -69,8 +70,8 @@ description: This is a sample data contract manifest` server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewPushDataContractManifestTool(client).Handler(t.Context(), tools.PushDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: manifestContent, }) if err != nil { @@ -144,8 +145,8 @@ func TestPushDataContractManifestWithOptionalParams(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewPushDataContractManifestTool(client).Handler(t.Context(), tools.PushDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: manifestContent, ManifestID: "test-manifest-456", Version: "1.0.0", @@ -169,8 +170,8 @@ func TestPushDataContractManifestEmptyManifest(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() - client := newClient(server) - output, err := tools.NewPushDataContractManifestTool(client).Handler(t.Context(), tools.PushDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: "", }) if err != nil { @@ -198,8 +199,8 @@ kind: DataContract` server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewPushDataContractManifestTool(client).Handler(t.Context(), tools.PushDataContractManifestInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: manifestContent, }) if err != nil { diff --git a/pkg/tools/register.go b/pkg/tools/register.go new file mode 100644 index 0000000..6845198 --- /dev/null +++ b/pkg/tools/register.go @@ -0,0 +1,76 @@ +package tools + +import ( + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/tools/add_business_term" + "github.com/collibra/chip/pkg/tools/add_data_classification_match" + "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/get_asset_details" + "github.com/collibra/chip/pkg/tools/get_business_term_data" + "github.com/collibra/chip/pkg/tools/get_column_semantics" + "github.com/collibra/chip/pkg/tools/get_lineage_downstream" + "github.com/collibra/chip/pkg/tools/get_lineage_entity" + "github.com/collibra/chip/pkg/tools/get_lineage_transformation" + "github.com/collibra/chip/pkg/tools/get_lineage_upstream" + "github.com/collibra/chip/pkg/tools/get_measure_data" + "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/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_lineage_entities" + "github.com/collibra/chip/pkg/tools/search_lineage_transformations" +) + +// CopilotToolNames lists tool names that are routed to the copilot service. +// Used by chip-service to direct these requests to the copilot backend +// instead of the standard DGC API. +var CopilotToolNames = []string{ + "discover_data_assets", + "discover_business_glossary", +} + +func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.ServerToolConfig) { + toolRegister(server, toolConfig, discover_data_assets.NewTool(client)) + toolRegister(server, toolConfig, discover_business_glossary.NewTool(client)) + toolRegister(server, toolConfig, get_asset_details.NewTool(client)) + toolRegister(server, toolConfig, search_asset_keyword.NewTool(client)) + toolRegister(server, toolConfig, search_data_classes.NewTool(client)) + toolRegister(server, toolConfig, list_asset_types.NewTool(client)) + toolRegister(server, toolConfig, add_data_classification_match.NewTool(client)) + toolRegister(server, toolConfig, search_data_classification_matches.NewTool(client)) + toolRegister(server, toolConfig, remove_data_classification_match.NewTool(client)) + toolRegister(server, toolConfig, list_data_contracts.NewTool(client)) + toolRegister(server, toolConfig, push_data_contract_manifest.NewTool(client)) + toolRegister(server, toolConfig, pull_data_contract_manifest.NewTool(client)) + toolRegister(server, toolConfig, prepare_add_business_term.NewTool(client)) + toolRegister(server, toolConfig, get_business_term_data.NewTool(client)) + toolRegister(server, toolConfig, get_column_semantics.NewTool(client)) + toolRegister(server, toolConfig, get_lineage_downstream.NewTool(client)) + toolRegister(server, toolConfig, get_lineage_entity.NewTool(client)) + toolRegister(server, toolConfig, get_lineage_transformation.NewTool(client)) + toolRegister(server, toolConfig, get_lineage_upstream.NewTool(client)) + toolRegister(server, toolConfig, get_measure_data.NewTool(client)) + toolRegister(server, toolConfig, get_table_semantics.NewTool(client)) + toolRegister(server, toolConfig, search_lineage_entities.NewTool(client)) + toolRegister(server, toolConfig, search_lineage_transformations.NewTool(client)) + toolRegister(server, toolConfig, prepare_create_asset.NewTool(client)) + toolRegister(server, toolConfig, add_business_term.NewTool(client)) + toolRegister(server, toolConfig, create_asset.NewTool(client)) +} + +func toolRegister[In, Out any](server *chip.Server, toolConfig *chip.ServerToolConfig, tool *chip.Tool[In, Out]) { + if toolConfig.IsToolEnabled(tool.Name) { + chip.RegisterTool(server, tool) + } +} diff --git a/pkg/tools/remove_data_classification_match.go b/pkg/tools/remove_data_classification_match/tool.go similarity index 53% rename from pkg/tools/remove_data_classification_match.go rename to pkg/tools/remove_data_classification_match/tool.go index 5a8477e..deb8007 100644 --- a/pkg/tools/remove_data_classification_match.go +++ b/pkg/tools/remove_data_classification_match/tool.go @@ -1,4 +1,4 @@ -package tools +package remove_data_classification_match import ( "context" @@ -10,52 +10,52 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type RemoveDataClassificationMatchInput struct { +type Input struct { ClassificationMatchID string `json:"classificationMatchId" jsonschema:"Required. The UUID of the classification match to remove (e.g., '12345678-1234-1234-1234-123456789abc')"` } -type RemoveDataClassificationMatchOutput struct { +type Output struct { Success bool `json:"success" jsonschema:"Whether the classification match was successfully removed"` Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed"` } -func NewRemoveDataClassificationMatchTool(collibraClient *http.Client) *chip.Tool[RemoveDataClassificationMatchInput, RemoveDataClassificationMatchOutput] { - return &chip.Tool[RemoveDataClassificationMatchInput, RemoveDataClassificationMatchOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "remove_data_classification_match", Description: "Remove a classification match (association between a data class and an asset) from Collibra. Requires the UUID of the classification match to remove.", - Handler: handleRemoveDataClassificationMatch(collibraClient), + Handler: handler(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog", "dgc.data-classes-edit"}, } } -func handleRemoveDataClassificationMatch(collibraClient *http.Client) chip.ToolHandlerFunc[RemoveDataClassificationMatchInput, RemoveDataClassificationMatchOutput] { - return func(ctx context.Context, input RemoveDataClassificationMatchInput) (RemoveDataClassificationMatchOutput, error) { - output, isNotValid := validateRemoveClassificationMatchInput(input) +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + output, isNotValid := validateInput(input) if isNotValid { return output, nil } err := clients.RemoveDataClassificationMatch(ctx, collibraClient, input.ClassificationMatchID) if err != nil { - return RemoveDataClassificationMatchOutput{ + return Output{ Success: false, Error: fmt.Sprintf("Failed to remove classification match: %s", err.Error()), }, nil } - return RemoveDataClassificationMatchOutput{ + return Output{ Success: true, }, nil } } -func validateRemoveClassificationMatchInput(input RemoveDataClassificationMatchInput) (RemoveDataClassificationMatchOutput, bool) { +func validateInput(input Input) (Output, bool) { if strings.TrimSpace(input.ClassificationMatchID) == "" { - return RemoveDataClassificationMatchOutput{ + return Output{ Success: false, Error: "Classification Match ID is required", }, true } - return RemoveDataClassificationMatchOutput{}, false + return Output{}, false } diff --git a/pkg/tools/remove_data_classification_match_test.go b/pkg/tools/remove_data_classification_match/tool_test.go similarity index 72% rename from pkg/tools/remove_data_classification_match_test.go rename to pkg/tools/remove_data_classification_match/tool_test.go index bae5f84..1411d1a 100644 --- a/pkg/tools/remove_data_classification_match_test.go +++ b/pkg/tools/remove_data_classification_match/tool_test.go @@ -1,16 +1,17 @@ -package tools_test +package remove_data_classification_match_test import ( "net/http" "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/remove_data_classification_match" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestRemoveClassificationMatch_Success(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/rest/catalog/1.0/dataClassification/classificationMatches/12345678-1234-1234-1234-123456789abc", StringHandlerOut(func(r *http.Request) (int, string) { + handler.Handle("/rest/catalog/1.0/dataClassification/classificationMatches/12345678-1234-1234-1234-123456789abc", testutil.StringHandlerOut(func(r *http.Request) (int, string) { if r.Method != "DELETE" { t.Errorf("Expected DELETE request, got %s", r.Method) } @@ -20,13 +21,13 @@ func TestRemoveClassificationMatch_Success(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - input := tools.RemoveDataClassificationMatchInput{ + input := tools.Input{ ClassificationMatchID: "12345678-1234-1234-1234-123456789abc", } - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -40,9 +41,9 @@ func TestRemoveClassificationMatch_Success(t *testing.T) { func TestRemoveClassificationMatch_MissingClassificationMatchID(t *testing.T) { client := &http.Client{} - input := tools.RemoveDataClassificationMatchInput{} + input := tools.Input{} - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -63,13 +64,13 @@ func TestRemoveClassificationMatch_NotFound(t *testing.T) { })) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - input := tools.RemoveDataClassificationMatchInput{ + input := tools.Input{ ClassificationMatchID: "00000000-0000-0000-0000-000000000000", } - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -94,13 +95,13 @@ func TestRemoveClassificationMatch_ServerError(t *testing.T) { })) defer server.Close() - client := newClient(server) + client := testutil.NewClient(server) - input := tools.RemoveDataClassificationMatchInput{ + input := tools.Input{ ClassificationMatchID: "12345678-1234-1234-1234-123456789abc", } - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := tools.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) diff --git a/pkg/tools/search_asset_keyword.go b/pkg/tools/search_asset_keyword/tool.go similarity index 81% rename from pkg/tools/search_asset_keyword.go rename to pkg/tools/search_asset_keyword/tool.go index 5a3b845..9937827 100644 --- a/pkg/tools/search_asset_keyword.go +++ b/pkg/tools/search_asset_keyword/tool.go @@ -1,4 +1,4 @@ -package tools +package search_asset_keyword import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type SearchKeywordInput struct { +type Input struct { Query string `json:"query" jsonschema:"Required. The keyword query to search for."` Limit int `json:"limit,omitempty" jsonschema:"Optional. Maximum number of results to return. The maximum value is 1000. Default: 50."` Offset int `json:"offset,omitempty" jsonschema:"Optional. Index of first result (pagination offset). Default: 0."` @@ -22,12 +22,12 @@ type SearchKeywordInput struct { CreatedByFilter []string `json:"createdByFilter,omitempty" jsonschema:"Optional. Filter by resources created by the specified user UUIDs."` } -type SearchKeywordOutput struct { - Total int `json:"total" jsonschema:"The total number of results available matching the search criteria"` - Results []SearchKeywordResource `json:"results" jsonschema:"The list of search results"` +type Output struct { + Total int `json:"total" jsonschema:"The total number of results available matching the search criteria"` + Results []Resource `json:"results" jsonschema:"The list of search results"` } -type SearchKeywordResource struct { +type Resource struct { ResourceType string `json:"resourceType" jsonschema:"The type of the resource (e.g., Asset, Domain, Community, User, UserGroup)"` ID string `json:"id" jsonschema:"The unique identifier of the resource"` CreatedBy string `json:"createdBy" jsonschema:"The user who created the resource"` @@ -36,17 +36,17 @@ type SearchKeywordResource struct { Name string `json:"name" jsonschema:"The name of the resource"` } -func NewSearchKeywordTool(collibraClient *http.Client) *chip.Tool[SearchKeywordInput, SearchKeywordOutput] { - return &chip.Tool[SearchKeywordInput, SearchKeywordOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "search_asset_keyword", Description: "Perform a wildcard keyword search for assets in the Collibra knowledge graph. Supports filtering by resource type, community, domain, asset type, status, and creator.", - Handler: handleSearchKeyword(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleSearchKeyword(collibraClient *http.Client) chip.ToolHandlerFunc[SearchKeywordInput, SearchKeywordOutput] { - return func(ctx context.Context, input SearchKeywordInput) (SearchKeywordOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { if input.Limit == 0 { input.Limit = 50 } @@ -55,7 +55,7 @@ func handleSearchKeyword(collibraClient *http.Client) chip.ToolHandlerFunc[Searc searchResponse, err := clients.SearchKeyword(ctx, collibraClient, input.Query, input.ResourceTypeFilters, filters, input.Limit, input.Offset) if err != nil { - return SearchKeywordOutput{}, err + return Output{}, err } output := mapSearchResponseToOutput(searchResponse) @@ -64,7 +64,7 @@ func handleSearchKeyword(collibraClient *http.Client) chip.ToolHandlerFunc[Searc } } -func buildSearchFilters(input SearchKeywordInput) []clients.SearchFilter { +func buildSearchFilters(input Input) []clients.SearchFilter { var searchFilters []clients.SearchFilter if len(input.CommunityFilter) > 0 { @@ -118,10 +118,10 @@ func formatTimestamp(milliseconds int64) string { return t.Format(time.RFC3339) } -func mapSearchResponseToOutput(searchResponse *clients.SearchResponse) SearchKeywordOutput { - resources := make([]SearchKeywordResource, len(searchResponse.Results)) +func mapSearchResponseToOutput(searchResponse *clients.SearchResponse) Output { + resources := make([]Resource, len(searchResponse.Results)) for i, result := range searchResponse.Results { - resources[i] = SearchKeywordResource{ + resources[i] = Resource{ ResourceType: result.Resource.ResourceType, ID: result.Resource.ID, CreatedBy: result.Resource.CreatedBy, @@ -131,7 +131,7 @@ func mapSearchResponseToOutput(searchResponse *clients.SearchResponse) SearchKey } } - return SearchKeywordOutput{ + return Output{ Total: searchResponse.Total, Results: resources, } diff --git a/pkg/tools/search_asset_keyword_test.go b/pkg/tools/search_asset_keyword/tool_test.go similarity index 68% rename from pkg/tools/search_asset_keyword_test.go rename to pkg/tools/search_asset_keyword/tool_test.go index 7d0898d..180b58d 100644 --- a/pkg/tools/search_asset_keyword_test.go +++ b/pkg/tools/search_asset_keyword/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package search_asset_keyword_test import ( "net/http" @@ -6,14 +6,15 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/search_asset_keyword" + "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) func TestKeywordSearch(t *testing.T) { assetId, _ := uuid.NewUUID() handler := http.NewServeMux() - handler.Handle("/rest/2.0/search", JsonHandlerInOut(func(httpRequest *http.Request, request clients.SearchRequest) (int, clients.SearchResponse) { + handler.Handle("/rest/2.0/search", testutil.JsonHandlerInOut(func(httpRequest *http.Request, request clients.SearchRequest) (int, clients.SearchResponse) { return http.StatusOK, clients.SearchResponse{ Total: 1, Results: []clients.SearchResult{ @@ -31,8 +32,8 @@ func TestKeywordSearch(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchKeywordTool(client).Handler(t.Context(), tools.SearchKeywordInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Query: "revenue", }) if err != nil { diff --git a/pkg/tools/search_data_classes.go b/pkg/tools/search_data_classes/tool.go similarity index 69% rename from pkg/tools/search_data_classes.go rename to pkg/tools/search_data_classes/tool.go index 28a4276..32ce780 100644 --- a/pkg/tools/search_data_classes.go +++ b/pkg/tools/search_data_classes/tool.go @@ -1,4 +1,4 @@ -package tools +package search_data_classes import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type SearchDataClassesInput struct { +type Input struct { Name string `json:"name,omitempty" jsonschema:"Optional. Filter by data class name. The name of a Data Class. Matching is case-insensitive and supports partial matches."` Description string `json:"description,omitempty" jsonschema:"Optional. Filter by description. The description of a Data Class. Matching is case-insensitive and supports partial matches."` ContainsRules bool `json:"containsRules,omitempty" jsonschema:"Optional. If true, only data classes that have rules are returned. Filters the Data Classes based on whether or not they contain rules. Example: true."` @@ -17,41 +17,41 @@ type SearchDataClassesInput struct { Offset int `json:"offset,omitempty" jsonschema:"Optional. Index of first result (pagination offset). Default: 0."` } -type SearchDataClassesOutput struct { +type Output struct { Total int `json:"total" jsonschema:"Total number of matching data classes"` Count int `json:"count" jsonschema:"Number of data classes returned in this response"` DataClasses []clients.DataClass `json:"dataClasses" jsonschema:"List of data classes"` Error string `json:"error,omitempty" jsonschema:"HTTP or other error message if the request failed"` } -func NewSearchDataClassesTool(collibraClient *http.Client) *chip.Tool[SearchDataClassesInput, SearchDataClassesOutput] { - return &chip.Tool[SearchDataClassesInput, SearchDataClassesOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "search_data_class", Description: "Search for data classes in Collibra's classification service. Supports filtering by name, description, and whether they contain rules.", - Handler: handleSearchDataClasses(collibraClient), + Handler: handler(collibraClient), Permissions: []string{"dgc.data-classes-read"}, } } -func handleSearchDataClasses(collibraClient *http.Client) chip.ToolHandlerFunc[SearchDataClassesInput, SearchDataClassesOutput] { - return func(ctx context.Context, input SearchDataClassesInput) (SearchDataClassesOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { input.sanitizePagination() params := buildQueryParams(input) results, total, err := clients.SearchDataClasses(ctx, collibraClient, params) if err != nil { - return SearchDataClassesOutput{Error: err.Error(), Total: total, Count: 0, DataClasses: results}, nil + return Output{Error: err.Error(), Total: total, Count: 0, DataClasses: results}, nil } if len(results) == 0 { - return SearchDataClassesOutput{Total: total, Count: 0, DataClasses: results}, nil + return Output{Total: total, Count: 0, DataClasses: results}, nil } - return SearchDataClassesOutput{Total: total, Count: len(results), DataClasses: results}, nil + return Output{Total: total, Count: len(results), DataClasses: results}, nil } } -func (in *SearchDataClassesInput) sanitizePagination() { +func (in *Input) sanitizePagination() { if in.Limit < 0 { in.Limit = 0 } @@ -60,7 +60,7 @@ func (in *SearchDataClassesInput) sanitizePagination() { } } -func buildQueryParams(in SearchDataClassesInput) clients.DataClassQueryParams { +func buildQueryParams(in Input) clients.DataClassQueryParams { params := &clients.DataClassQueryParams{ Description: strings.TrimSpace(in.Description), diff --git a/pkg/tools/search_data_classes_test.go b/pkg/tools/search_data_classes/tool_test.go similarity index 66% rename from pkg/tools/search_data_classes_test.go rename to pkg/tools/search_data_classes/tool_test.go index b6a865f..41f1be9 100644 --- a/pkg/tools/search_data_classes_test.go +++ b/pkg/tools/search_data_classes/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package search_data_classes_test import ( "net/http" @@ -6,12 +6,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/search_data_classes" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestFindDataClasses(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/rest/classification/v1/dataClasses", JsonHandlerOut(func(httpRequest *http.Request) (int, clients.DataClassesResponse) { + handler.Handle("/rest/classification/v1/dataClasses", testutil.JsonHandlerOut(func(httpRequest *http.Request) (int, clients.DataClassesResponse) { return http.StatusOK, clients.DataClassesResponse{ Results: []clients.DataClass{{Description: httpRequest.URL.Query().Encode()}}, } @@ -20,8 +21,8 @@ func TestFindDataClasses(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchDataClassesTool(client).Handler(t.Context(), tools.SearchDataClassesInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Name: "Question", }) if err != nil { diff --git a/pkg/tools/search_data_classification_matches.go b/pkg/tools/search_data_classification_matches/tool.go similarity index 68% rename from pkg/tools/search_data_classification_matches.go rename to pkg/tools/search_data_classification_matches/tool.go index 6457e71..7c38feb 100644 --- a/pkg/tools/search_data_classification_matches.go +++ b/pkg/tools/search_data_classification_matches/tool.go @@ -1,4 +1,4 @@ -package tools +package search_data_classification_matches import ( "context" @@ -8,7 +8,7 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type SearchClassificationMatchesInput struct { +type Input struct { AssetIDs []string `json:"assetIds,omitempty" jsonschema:"Optional. Filter by asset IDs. The list of asset IDs (with Column types) to filter the search results."` Statuses []string `json:"statuses,omitempty" jsonschema:"Optional. Filter by classification match status. Valid values: ACCEPTED, REJECTED, SUGGESTED."` ClassificationIDs []string `json:"classificationIds,omitempty" jsonschema:"Optional. Filter by classification IDs. The list of classification IDs to filter the search results."` @@ -18,41 +18,41 @@ type SearchClassificationMatchesInput struct { CountLimit int `json:"countLimit,omitempty" jsonschema:"Optional. Limits the number of elements that will be counted. -1 will count everything, 0 will skip counting. Default: -1."` } -type SearchClassificationMatchesOutput struct { +type Output struct { Total int `json:"total" jsonschema:"Total number of matching classification matches"` Count int `json:"count" jsonschema:"Number of classification matches returned in this response"` ClassificationMatches []clients.DataClassificationMatch `json:"classificationMatches" jsonschema:"List of classification matches"` Error string `json:"error,omitempty" jsonschema:"HTTP or other error message if the request failed"` } -func NewSearchClassificationMatchesTool(collibraClient *http.Client) *chip.Tool[SearchClassificationMatchesInput, SearchClassificationMatchesOutput] { - return &chip.Tool[SearchClassificationMatchesInput, SearchClassificationMatchesOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ Name: "search_data_classification_match", Description: "Search for classification matches (associations between data classes and assets) in Collibra. Supports filtering by asset IDs, statuses (ACCEPTED/REJECTED/SUGGESTED), classification IDs, and asset type IDs.", - Handler: handleSearchClassificationMatches(collibraClient), + Handler: handler(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog"}, } } -func handleSearchClassificationMatches(collibraClient *http.Client) chip.ToolHandlerFunc[SearchClassificationMatchesInput, SearchClassificationMatchesOutput] { - return func(ctx context.Context, input SearchClassificationMatchesInput) (SearchClassificationMatchesOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { input.sanitizePagination() - params := buildClassificationMatchQueryParams(input) + params := buildQueryParams(input) results, total, err := clients.SearchDataClassificationMatches(ctx, collibraClient, params) if err != nil { - return SearchClassificationMatchesOutput{Error: err.Error(), Total: int(total), Count: 0, ClassificationMatches: results}, nil + return Output{Error: err.Error(), Total: int(total), Count: 0, ClassificationMatches: results}, nil } if len(results) == 0 { - return SearchClassificationMatchesOutput{Total: int(total), Count: 0, ClassificationMatches: results}, nil + return Output{Total: int(total), Count: 0, ClassificationMatches: results}, nil } - return SearchClassificationMatchesOutput{Total: int(total), Count: len(results), ClassificationMatches: results}, nil + return Output{Total: int(total), Count: len(results), ClassificationMatches: results}, nil } } -func (in *SearchClassificationMatchesInput) sanitizePagination() { +func (in *Input) sanitizePagination() { if in.Limit < 0 { in.Limit = 0 } @@ -64,7 +64,7 @@ func (in *SearchClassificationMatchesInput) sanitizePagination() { } } -func buildClassificationMatchQueryParams(in SearchClassificationMatchesInput) clients.DataClassificationMatchQueryParams { +func buildQueryParams(in Input) clients.DataClassificationMatchQueryParams { params := clients.DataClassificationMatchQueryParams{ AssetIDs: in.AssetIDs, Statuses: in.Statuses, diff --git a/pkg/tools/search_data_classification_matches_test.go b/pkg/tools/search_data_classification_matches/tool_test.go similarity index 78% rename from pkg/tools/search_data_classification_matches_test.go rename to pkg/tools/search_data_classification_matches/tool_test.go index e799617..e5f87df 100644 --- a/pkg/tools/search_data_classification_matches_test.go +++ b/pkg/tools/search_data_classification_matches/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package search_data_classification_matches_test import ( "net/http" @@ -6,12 +6,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/search_data_classification_matches" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestFindClassificationMatches(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/rest/catalog/1.0/dataClassification/classificationMatches/bulk", JsonHandlerOut(func(httpRequest *http.Request) (int, clients.PagedResponseDataClassificationMatch) { + handler.Handle("/rest/catalog/1.0/dataClassification/classificationMatches/bulk", testutil.JsonHandlerOut(func(httpRequest *http.Request) (int, clients.PagedResponseDataClassificationMatch) { return http.StatusOK, clients.PagedResponseDataClassificationMatch{ Total: 1, Offset: 0, @@ -36,8 +37,8 @@ func TestFindClassificationMatches(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchClassificationMatchesTool(client).Handler(t.Context(), tools.SearchClassificationMatchesInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Statuses: []string{"ACCEPTED"}, Limit: 50, }) diff --git a/pkg/tools/search_lineage_entities.go b/pkg/tools/search_lineage_entities/tool.go similarity index 76% rename from pkg/tools/search_lineage_entities.go rename to pkg/tools/search_lineage_entities/tool.go index 11b5db6..e66970f 100644 --- a/pkg/tools/search_lineage_entities.go +++ b/pkg/tools/search_lineage_entities/tool.go @@ -1,4 +1,4 @@ -package tools +package search_lineage_entities import ( "context" @@ -8,7 +8,7 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type SearchLineageEntitiesInput struct { +type Input struct { NameContains string `json:"nameContains,omitempty" jsonschema:"Optional. Partial match on entity name (case insensitive). Min: 1, Max: 256 chars. Example: 'sales'"` Type string `json:"type,omitempty" jsonschema:"Optional. Exact match on entity type. Common types: table, column, file, report, apiEndpoint, topic. Example: 'table'"` DgcId string `json:"dgcId,omitempty" jsonschema:"Optional. Filter by Data Governance Catalog UUID. Use to find the lineage entity linked to a specific Collibra catalog asset."` @@ -16,17 +16,17 @@ type SearchLineageEntitiesInput struct { Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` } -func NewSearchLineageEntitiesTool(collibraClient *http.Client) *chip.Tool[SearchLineageEntitiesInput, clients.SearchLineageEntitiesOutput] { - return &chip.Tool[SearchLineageEntitiesInput, clients.SearchLineageEntitiesOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, clients.SearchLineageEntitiesOutput] { + return &chip.Tool[Input, clients.SearchLineageEntitiesOutput]{ Name: "search_lineage_entities", Description: "Search for data entities in the technical lineage graph by name, type, or DGC identifier. Technical lineage covers all data objects across external systems -- including source code, transformations, and temporary tables -- regardless of whether they are registered in Collibra (unlike business lineage, which only covers assets ingested into the Data Catalog). Returns a paginated list of matching entities. This is typically the starting tool when you don't have a specific entity ID -- for example, to find all tables with \"sales\" in the name, or to find the lineage entity linked to a specific Collibra catalog asset via its DGC UUID. Supports partial name matching (case insensitive).", - Handler: handleSearchLineageEntities(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleSearchLineageEntities(collibraClient *http.Client) chip.ToolHandlerFunc[SearchLineageEntitiesInput, clients.SearchLineageEntitiesOutput] { - return func(ctx context.Context, input SearchLineageEntitiesInput) (clients.SearchLineageEntitiesOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, clients.SearchLineageEntitiesOutput] { + return func(ctx context.Context, input Input) (clients.SearchLineageEntitiesOutput, error) { result, err := clients.SearchLineageEntities(ctx, collibraClient, input.NameContains, input.Type, input.DgcId, input.Limit, input.Cursor) if err != nil { return clients.SearchLineageEntitiesOutput{}, err diff --git a/pkg/tools/search_lineage_entities_test.go b/pkg/tools/search_lineage_entities/tool_test.go similarity index 75% rename from pkg/tools/search_lineage_entities_test.go rename to pkg/tools/search_lineage_entities/tool_test.go index f388812..012b112 100644 --- a/pkg/tools/search_lineage_entities_test.go +++ b/pkg/tools/search_lineage_entities/tool_test.go @@ -1,16 +1,17 @@ -package tools_test +package search_lineage_entities_test import ( "net/http" "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/search_lineage_entities" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestSearchLineageEntities(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "results": []map[string]any{ { @@ -26,8 +27,8 @@ func TestSearchLineageEntities(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchLineageEntitiesTool(client).Handler(t.Context(), tools.SearchLineageEntitiesInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ NameContains: "sales", }) if err != nil { @@ -58,7 +59,7 @@ func TestSearchLineageEntities(t *testing.T) { func TestSearchLineageEntitiesNotFound(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "results": []map[string]any{}, } @@ -67,8 +68,8 @@ func TestSearchLineageEntitiesNotFound(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchLineageEntitiesTool(client).Handler(t.Context(), tools.SearchLineageEntitiesInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ NameContains: "nonexistent_table", }) if err != nil { diff --git a/pkg/tools/search_lineage_transformations.go b/pkg/tools/search_lineage_transformations/tool.go similarity index 65% rename from pkg/tools/search_lineage_transformations.go rename to pkg/tools/search_lineage_transformations/tool.go index e77669d..4edcbcf 100644 --- a/pkg/tools/search_lineage_transformations.go +++ b/pkg/tools/search_lineage_transformations/tool.go @@ -1,4 +1,4 @@ -package tools +package search_lineage_transformations import ( "context" @@ -8,23 +8,23 @@ import ( "github.com/collibra/chip/pkg/clients" ) -type SearchLineageTransformationsInput struct { +type Input struct { NameContains string `json:"nameContains,omitempty" jsonschema:"Optional. Partial match on transformation name (case insensitive). Min: 1, Max: 256 chars. Example: 'etl'"` Limit int `json:"limit,omitempty" jsonschema:"Optional. Max results per page. Default: 20, Min: 1, Max: 100."` Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` } -func NewSearchLineageTransformationsTool(collibraClient *http.Client) *chip.Tool[SearchLineageTransformationsInput, clients.SearchLineageTransformationsOutput] { - return &chip.Tool[SearchLineageTransformationsInput, clients.SearchLineageTransformationsOutput]{ +func NewTool(collibraClient *http.Client) *chip.Tool[Input, clients.SearchLineageTransformationsOutput] { + return &chip.Tool[Input, clients.SearchLineageTransformationsOutput]{ Name: "search_lineage_transformations", Description: "Search for transformations in the technical lineage graph by name. Returns a paginated list of matching transformation summaries. Use this to discover ETL jobs, SQL queries, or other processing activities without knowing their IDs. For example, find all transformations with \"etl\" or \"sales\" in the name. To see the full transformation logic (SQL/script), use get_lineage_transformation with the returned ID.", - Handler: handleSearchLineageTransformations(collibraClient), + Handler: handler(collibraClient), Permissions: []string{}, } } -func handleSearchLineageTransformations(collibraClient *http.Client) chip.ToolHandlerFunc[SearchLineageTransformationsInput, clients.SearchLineageTransformationsOutput] { - return func(ctx context.Context, input SearchLineageTransformationsInput) (clients.SearchLineageTransformationsOutput, error) { +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, clients.SearchLineageTransformationsOutput] { + return func(ctx context.Context, input Input) (clients.SearchLineageTransformationsOutput, error) { result, err := clients.SearchLineageTransformations(ctx, collibraClient, input.NameContains, input.Limit, input.Cursor) if err != nil { return clients.SearchLineageTransformationsOutput{}, err diff --git a/pkg/tools/search_lineage_transformations_test.go b/pkg/tools/search_lineage_transformations/tool_test.go similarity index 74% rename from pkg/tools/search_lineage_transformations_test.go rename to pkg/tools/search_lineage_transformations/tool_test.go index d707b12..45f4b4c 100644 --- a/pkg/tools/search_lineage_transformations_test.go +++ b/pkg/tools/search_lineage_transformations/tool_test.go @@ -1,16 +1,17 @@ -package tools_test +package search_lineage_transformations_test import ( "net/http" "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools" + tools "github.com/collibra/chip/pkg/tools/search_lineage_transformations" + "github.com/collibra/chip/pkg/tools/testutil" ) func TestSearchLineageTransformations(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "results": []map[string]any{ { @@ -26,8 +27,8 @@ func TestSearchLineageTransformations(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchLineageTransformationsTool(client).Handler(t.Context(), tools.SearchLineageTransformationsInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ NameContains: "etl", }) if err != nil { @@ -54,7 +55,7 @@ func TestSearchLineageTransformations(t *testing.T) { func TestSearchLineageTransformationsNotFound(t *testing.T) { handler := http.NewServeMux() - handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { return http.StatusOK, map[string]any{ "results": []map[string]any{}, } @@ -63,8 +64,8 @@ func TestSearchLineageTransformationsNotFound(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() - client := newClient(server) - output, err := tools.NewSearchLineageTransformationsTool(client).Handler(t.Context(), tools.SearchLineageTransformationsInput{ + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ NameContains: "nonexistent_etl", }) if err != nil { diff --git a/pkg/tools/tools_test.go b/pkg/tools/testutil/testutil.go similarity index 95% rename from pkg/tools/tools_test.go rename to pkg/tools/testutil/testutil.go index e1f7678..c7d084a 100644 --- a/pkg/tools/tools_test.go +++ b/pkg/tools/testutil/testutil.go @@ -1,4 +1,4 @@ -package tools_test +package testutil import ( "encoding/json" @@ -27,7 +27,7 @@ func (c *testClient) RoundTrip(request *http.Request) (*http.Response, error) { return c.next.RoundTrip(reqClone) } -func newClient(server *httptest.Server) *http.Client { +func NewClient(server *httptest.Server) *http.Client { return &http.Client{Transport: &testClient{baseURL: server.URL, next: http.DefaultTransport}} } @@ -88,6 +88,7 @@ func HttpHandlerInOut[In, Out any](m Marshaller[Out], u Unmarshaller[In], handle return } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) _, err = w.Write(response) if err != nil { @@ -105,6 +106,7 @@ func HttpHandlerOut[Out any](m Marshaller[Out], handler func(r *http.Request) (i return } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) _, err = w.Write(response) if err != nil { diff --git a/pkg/tools/tools_register.go b/pkg/tools/tools_register.go deleted file mode 100644 index d84951c..0000000 --- a/pkg/tools/tools_register.go +++ /dev/null @@ -1,38 +0,0 @@ -package tools - -import ( - "net/http" - - "github.com/collibra/chip/pkg/chip" -) - -func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.ServerToolConfig) { - toolRegister(server, toolConfig, NewAskDadTool(client)) - toolRegister(server, toolConfig, NewAskGlossaryTool(client)) - toolRegister(server, toolConfig, NewAssetDetailsTool(client)) - toolRegister(server, toolConfig, NewSearchKeywordTool(client)) - toolRegister(server, toolConfig, NewSearchDataClassesTool(client)) - toolRegister(server, toolConfig, NewListAssetTypesTool(client)) - toolRegister(server, toolConfig, NewAddDataClassificationMatchTool(client)) - toolRegister(server, toolConfig, NewSearchClassificationMatchesTool(client)) - toolRegister(server, toolConfig, NewRemoveDataClassificationMatchTool(client)) - toolRegister(server, toolConfig, NewListDataContractsTool(client)) - toolRegister(server, toolConfig, NewPushDataContractManifestTool(client)) - toolRegister(server, toolConfig, NewPullDataContractManifestTool(client)) - toolRegister(server, toolConfig, NewColumnSemanticsGetTool(client)) - toolRegister(server, toolConfig, NewMeasureDataGetTool(client)) - toolRegister(server, toolConfig, NewTableSemanticsGetTool(client)) - toolRegister(server, toolConfig, NewBusinessTermDataGetTool(client)) - toolRegister(server, toolConfig, NewGetLineageEntityTool(client)) - toolRegister(server, toolConfig, NewGetLineageUpstreamTool(client)) - toolRegister(server, toolConfig, NewGetLineageDownstreamTool(client)) - toolRegister(server, toolConfig, NewSearchLineageEntitiesTool(client)) - toolRegister(server, toolConfig, NewGetLineageTransformationTool(client)) - toolRegister(server, toolConfig, NewSearchLineageTransformationsTool(client)) -} - -func toolRegister[In, Out any](server *chip.Server, toolConfig *chip.ServerToolConfig, tool *chip.Tool[In, Out]) { - if toolConfig.IsToolEnabled(tool.Name) { - chip.RegisterTool(server, tool) - } -}