diff --git a/README.md b/README.md index cefaa43..887e9e9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, - [`discover_data_assets`](pkg/tools/discover_data_assets/) - Query available data assets using natural language. Note that this tool leverages Collibra AI and therefore consumes Collibra Units (CUs). **Requires:** `dgc.ai-copilot` - [`get_assessment`](pkg/tools/get_assessment/) - Retrieve conducted assessment(s) from the Assessments application (these are not catalog assets). Direct lookup of a single assessment by name or UUID (or by its linked Assessment Review asset), or a filtered lookup combining name (partial), status, template, conducted asset, and a last-modified range (paginated) - [`get_asset_details`](pkg/tools/get_asset_details/) - Retrieve detailed information about specific assets by UUID, including the asset's assignable attribute schema (every attribute it can hold, including empty ones) +- [`get_asset_operating_model`](pkg/tools/get_asset_operating_model/) - Look up the attributes and relations an asset type has in a given domain (`assetType` accepts a UUID, publicId or name and is required; `domain` accepts a UUID or name and is optional — without it the asset type's default model is returned): which attributes are required, read-only or rich text, which relations apply and which statuses can be set. Replaces `prepare_create_asset` - [`get_business_term_data`](pkg/tools/get_business_term_data/) - Trace a business term back to its connected physical data assets - [`get_column_semantics`](pkg/tools/get_column_semantics/) - Retrieve data attributes, measures, and business assets connected to a column - [`get_data_quality_rule`](pkg/tools/get_dq_rule/) - Read the definition of a single DQ rule (monitor) on a job — its type, SQL, filter, tolerance and active/suppressed state. **Gated:** the `data-quality` capability (`--data-quality`) diff --git a/pkg/clients/get_asset_operating_model_client.go b/pkg/clients/get_asset_operating_model_client.go new file mode 100644 index 0000000..3d81f30 --- /dev/null +++ b/pkg/clients/get_asset_operating_model_client.go @@ -0,0 +1,75 @@ +package clients + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +// ErrAssignmentNotFound is returned when no assignment exists for the asset type +// in the domain, i.e. the asset type is not allowed there. +var ErrAssignmentNotFound = errors.New("assignment not found") + +// GetAssignmentForAssetTypeAndDomain returns the effective assignment for an +// asset of the given type created in the given domain, via +// GET /rest/2.0/assignments/assetType/{assetTypeId}/domain/{domainId}. +// Scope selection and inheritance are resolved server-side, so the result lists +// the attributes and relations that apply, including those inherited from traits +// and ancestor assignments. +func GetAssignmentForAssetTypeAndDomain(ctx context.Context, client *http.Client, assetTypeID, domainID string) (*PrepareCreateScopedAssignment, error) { + reqURL := fmt.Sprintf("/rest/2.0/assignments/assetType/%s/domain/%s", url.PathEscape(assetTypeID), url.PathEscape(domainID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("building get assignment request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("getting assignment for asset type and domain: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("no assignment found for asset type %q in domain %q: %w", assetTypeID, domainID, ErrAssignmentNotFound) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting assignment for asset type and domain: status %d: %s", resp.StatusCode, string(body)) + } + + var raw rawScopedAssignment + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decoding assignment response: %w", err) + } + return emitAssignmentCharacteristics(raw), nil +} + +// ErrNoDefaultAssignment is returned when an asset type has assignments, but +// only scoped ones, so none applies without knowing the domain. +var ErrNoDefaultAssignment = errors.New("no default assignment") + +// GetDefaultAssignmentForAssetType returns the assignment that applies to an +// asset type regardless of domain, via GET /rest/2.0/assignments/assetType/{id}. +// That endpoint returns one assignment per scope; the unscoped one is the +// default. It returns ErrAssignmentNotFound when the asset type has no +// assignments and ErrNoDefaultAssignment when it has only scoped ones. +func GetDefaultAssignmentForAssetType(ctx context.Context, client *http.Client, assetTypeID string) (*PrepareCreateScopedAssignment, error) { + raws, err := fetchRawAssignments(ctx, client, assetTypeID) + if err != nil { + return nil, err + } + if len(raws) == 0 { + return nil, fmt.Errorf("no assignment found for asset type %q: %w", assetTypeID, ErrAssignmentNotFound) + } + for _, raw := range raws { + if raw.Scope == nil { + return emitAssignmentCharacteristics(raw), nil + } + } + return nil, fmt.Errorf("asset type %q has only scoped assignments: %w", assetTypeID, ErrNoDefaultAssignment) +} diff --git a/pkg/clients/get_asset_operating_model_client_test.go b/pkg/clients/get_asset_operating_model_client_test.go new file mode 100644 index 0000000..db31a93 --- /dev/null +++ b/pkg/clients/get_asset_operating_model_client_test.go @@ -0,0 +1,184 @@ +package clients + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + operatingModelAssetTypeID = "00000000-0000-0000-0000-000000000011" + operatingModelDomainID = "00000000-0000-0000-0000-000000000022" + operatingModelPath = "/rest/2.0/assignments/assetType/" + operatingModelAssetTypeID + "/domain/" + operatingModelDomainID +) + +func TestGetAssignmentForAssetTypeAndDomain_ShouldMapAttributesAndRelations(t *testing.T) { + var gotPath string + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": "assignment-1", + "assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-1", "name": "Definition", "resourceDiscriminator": "StringAttributeType"}, + "assignedResourcePublicId": "Definition", "minimumOccurrences": 1, "maximumOccurrences": 1}, + {"assignedResourceReference": {"id": "attr-2", "name": "Score", "resourceDiscriminator": "NumericAttributeType"}, + "minimumOccurrences": 0, "readOnly": true}, + {"assignedResourceReference": {"id": "rel-1", "resourceDiscriminator": "RelationType"}, + "assignedResourcePublicId": "TermIsPartOf", "relationTypeDirection": "TO_TARGET", + "relationTypeRestriction": {"id": "type-9", "name": "Business Term"}} + ], + "traitAssignmentInheritances": [ + {"assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-3", "name": "Note", "resourceDiscriminator": "StringAttributeType"}} + ]} + ] + }`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + got, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotPath != operatingModelPath { + t.Errorf("expected path %q, got %q", operatingModelPath, gotPath) + } + if got.AssignmentID != "assignment-1" { + t.Errorf("expected assignment id assignment-1, got %q", got.AssignmentID) + } + definition, ok := attrByName(got.Attributes, "Definition") + if !ok || !definition.Required || definition.Kind != "StringAttributeType" || definition.AttributeTypePublicID != "Definition" { + t.Errorf("unexpected Definition attribute: %+v", definition) + } + if definition.Max == nil || *definition.Max != 1 { + t.Errorf("expected Definition max 1, got %v", definition.Max) + } + score, ok := attrByName(got.Attributes, "Score") + if !ok || !score.ReadOnly || score.Required { + t.Errorf("expected Score to be optional and read-only, got %+v", score) + } + if _, ok := attrByName(got.Attributes, "Note"); !ok { + t.Errorf("expected trait-inherited attribute Note, got %+v", got.Attributes) + } + rels := relsByID(got.Relations, "rel-1") + if len(rels) != 1 || rels[0].Direction != "TO_TARGET" || rels[0].TargetType == nil || rels[0].TargetType.ID != "type-9" { + t.Errorf("unexpected relations: %+v", got.Relations) + } +} + +func TestGetAssignmentForAssetTypeAndDomain_ShouldReturnErrorWhenNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID) + if !errors.Is(err, ErrAssignmentNotFound) { + t.Fatalf("expected ErrAssignmentNotFound, got %v", err) + } +} + +func TestGetAssignmentForAssetTypeAndDomain_ShouldReturnErrorOnServerFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer server.Close() + + _, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID) + if err == nil || !strings.Contains(err.Error(), "status 500") { + t.Fatalf("expected status 500 error, got %v", err) + } +} + +func TestGetAssignmentForAssetTypeAndDomain_ShouldReturnErrorOnMalformedBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer server.Close() + + _, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID) + if err == nil || !strings.Contains(err.Error(), "decoding") { + t.Fatalf("expected decoding error, got %v", err) + } +} + +const defaultAssignmentPath = "/rest/2.0/assignments/assetType/" + operatingModelAssetTypeID + +func serveDefaultAssignments(t *testing.T, body string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(defaultAssignmentPath, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func TestGetDefaultAssignmentForAssetType_ShouldPickTheUnscopedAssignment(t *testing.T) { + server := serveDefaultAssignments(t, `[ + {"id": "scoped", "scope": {"id": "scope-1", "name": "Scope"}, + "assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-s", "name": "Scoped Only", "resourceDiscriminator": "StringAttributeType"}}]}, + {"id": "default", + "assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-d", "name": "Definition", "resourceDiscriminator": "StringAttributeType"}, "minimumOccurrences": 1}]} + ]`) + + got, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got.AssignmentID != "default" { + t.Errorf("expected the unscoped assignment, got %q", got.AssignmentID) + } + if _, ok := attrByName(got.Attributes, "Definition"); !ok { + t.Errorf("expected Definition, got %+v", got.Attributes) + } + if _, ok := attrByName(got.Attributes, "Scoped Only"); ok { + t.Errorf("scoped attribute must not bleed in, got %+v", got.Attributes) + } +} + +func TestGetDefaultAssignmentForAssetType_ShouldFailWhenOnlyScopedAssignmentsExist(t *testing.T) { + server := serveDefaultAssignments(t, `[{"id": "scoped", "scope": {"id": "scope-1", "name": "Scope"}}]`) + + _, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID) + + if !errors.Is(err, ErrNoDefaultAssignment) { + t.Fatalf("expected ErrNoDefaultAssignment, got %v", err) + } +} + +func TestGetDefaultAssignmentForAssetType_ShouldFailWhenThereAreNoAssignments(t *testing.T) { + server := serveDefaultAssignments(t, `[]`) + + _, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID) + + if !errors.Is(err, ErrAssignmentNotFound) { + t.Fatalf("expected ErrAssignmentNotFound, got %v", err) + } +} + +func TestGetDefaultAssignmentForAssetType_ShouldReturnErrorOnServerFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + _, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID) + + if err == nil || errors.Is(err, ErrAssignmentNotFound) || errors.Is(err, ErrNoDefaultAssignment) { + t.Fatalf("expected a plain failure, got %v", err) + } +} diff --git a/pkg/tools/create_asset/tool.go b/pkg/tools/create_asset/tool.go index 78cd23e..815910f 100644 --- a/pkg/tools/create_asset/tool.go +++ b/pkg/tools/create_asset/tool.go @@ -116,9 +116,9 @@ func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { "Inputs accept human-friendly identifiers: assetType resolves from UUID, publicId, or display name; domain from UUID or display name; status from UUID or status name; attributes by name or typeId. " + "Markdown in RICH_TEXT attribute values (e.g. 'Definition') is converted to HTML server-side so it renders correctly in Collibra. " + "When allowDuplicate is false (the default), an existing asset with the same name in the same (assetType, domain) returns status=duplicate_found without writing. " + - "Attributes the assignment marks read-only (values Collibra calculates, such as a trust score) are rejected with status=validation_error and never need to be supplied; prepare_create_asset flags them with readOnly=true. " + + "Attributes the assignment marks read-only (values Collibra calculates, such as a trust score) are rejected with status=validation_error and never need to be supplied; get_asset_operating_model flags them with readOnly=true. " + "Validation errors return suggestion-rich messages so the agent can self-correct. " + - "Calling prepare_create_asset first is optional — only needed when the agent wants to enumerate options or inspect a type's full attribute schema.", + "Calling get_asset_operating_model first is optional — only needed when the agent wants to inspect the attributes, relations and statuses available for an asset type in a domain.", Handler: handler(collibraClient), Permissions: []string{}, Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(false), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)}, diff --git a/pkg/tools/get_asset_details/tool.go b/pkg/tools/get_asset_details/tool.go index 4a4552e..7743353 100644 --- a/pkg/tools/get_asset_details/tool.go +++ b/pkg/tools/get_asset_details/tool.go @@ -38,7 +38,7 @@ type Output struct { // lets the caller tell an empty attribute apart from one that isn't valid at // all — the GraphQL attribute lists only include attributes that have a value. type AssignableAttribute struct { - Name string `json:"name" jsonschema:"the attribute type display name, e.g. Definition. This is a name only — to get the attribute type's UUID or publicId, call prepare_create_asset (attributeSchema[].attributeTypeId / .publicId)."` + Name string `json:"name" jsonschema:"the attribute type display name, e.g. Definition. This is a name only — to get the attribute type's UUID or publicId, call get_asset_operating_model (attributeSchema[].attributeTypeId / .publicId)."` Required bool `json:"required" jsonschema:"whether the assignment requires this attribute to have a value"` IsSet bool `json:"isSet" jsonschema:"true if the asset currently has a value for this attribute, false if it is empty but settable"` } @@ -55,7 +55,7 @@ func NewTool(collibraClient *http.Client, contextSpecsEnabled bool) *chip.Tool[I return &chip.Tool[Input, Output]{ Name: "get_asset_details", Title: "Get Asset Details", - Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, tags, responsibilities (owners, stewards, and other role assignments), and metadata. Also returns assignableAttributes: every attribute type the asset can hold, with required and isSet flags — use this to tell an empty-but-settable attribute (e.g. an unset Definition) apart from one that isn't valid for the asset. Returns up to 100 attributes per type and supports cursor-based pagination for relations (50 per page). Attributes and relations here carry display names only — for an attribute or relation type's UUID or publicId, call prepare_create_asset (attributeSchema[]/relationTypes[]). Optionally executes a Context Specification against the asset and returns the generated YAML context (requires the context-specifications experimental feature).", + Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, tags, responsibilities (owners, stewards, and other role assignments), and metadata. Also returns assignableAttributes: every attribute type the asset can hold, with required and isSet flags — use this to tell an empty-but-settable attribute (e.g. an unset Definition) apart from one that isn't valid for the asset. Returns up to 100 attributes per type and supports cursor-based pagination for relations (50 per page). Attributes and relations here carry display names only — for an attribute or relation type's UUID or publicId, call get_asset_operating_model (attributeSchema[]/relationTypes[]). Optionally executes a Context Specification against the asset and returns the generated YAML context (requires the context-specifications experimental feature).", Handler: handler(collibraClient, contextSpecsEnabled), Permissions: []string{}, Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: chip.Ptr(false), IdempotentHint: true, OpenWorldHint: chip.Ptr(false)}, diff --git a/pkg/tools/get_asset_operating_model/resolve.go b/pkg/tools/get_asset_operating_model/resolve.go new file mode 100644 index 0000000..a60448f --- /dev/null +++ b/pkg/tools/get_asset_operating_model/resolve.go @@ -0,0 +1,79 @@ +package get_asset_operating_model + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/clients" + "github.com/google/uuid" +) + +// maxNameMatches caps how many name matches are fetched while looking for an +// exact one. +const maxNameMatches = 50 + +// resolveAssetType tries UUID, then publicId, then a case-insensitive exact name +// match, and returns the first strategy that succeeds. +func resolveAssetType(ctx context.Context, collibraClient *http.Client, value string) (*clients.PrepareCreateAssetType, error) { + if isUUID(value) { + if assetType, err := clients.GetAssetTypeByID(ctx, collibraClient, value); err == nil { + return assetType, nil + } + } + if assetType, err := clients.GetAssetTypeByPublicID(ctx, collibraClient, value); err == nil { + return assetType, nil + } + matches, _, err := clients.SearchAssetTypesByName(ctx, collibraClient, value, maxNameMatches) + if err != nil { + return nil, fmt.Errorf("searching for asset type %q: %w", value, err) + } + var exact []clients.PrepareCreateAssetType + for _, match := range matches { + if strings.EqualFold(match.Name, value) { + exact = append(exact, match) + } + } + switch len(exact) { + case 1: + return &exact[0], nil + case 0: + return nil, fmt.Errorf("no asset type matches %q", value) + default: + return nil, fmt.Errorf("asset type %q is ambiguous: %d exact matches, use its UUID or publicId", value, len(exact)) + } +} + +// resolveDomain tries UUID, then a case-insensitive exact name match. Domains +// have no publicId. +func resolveDomain(ctx context.Context, collibraClient *http.Client, value string) (*clients.PrepareCreateDomain, error) { + if isUUID(value) { + if domain, err := clients.GetDomainByID(ctx, collibraClient, value); err == nil { + return domain, nil + } + } + matches, _, err := clients.SearchDomainsByName(ctx, collibraClient, value, maxNameMatches) + if err != nil { + return nil, fmt.Errorf("searching for domain %q: %w", value, err) + } + var exact []clients.PrepareCreateDomain + for _, match := range matches { + if strings.EqualFold(match.Name, value) { + exact = append(exact, match) + } + } + switch len(exact) { + case 1: + return &exact[0], nil + case 0: + return nil, fmt.Errorf("no domain matches %q", value) + default: + return nil, fmt.Errorf("domain %q is ambiguous: %d exact matches, use its UUID", value, len(exact)) + } +} + +// isUUID reports whether s is a UUID in the canonical 8-4-4-4-12 form. +func isUUID(s string) bool { + return len(s) == 36 && uuid.Validate(s) == nil +} diff --git a/pkg/tools/get_asset_operating_model/tool.go b/pkg/tools/get_asset_operating_model/tool.go new file mode 100644 index 0000000..9fecaa4 --- /dev/null +++ b/pkg/tools/get_asset_operating_model/tool.go @@ -0,0 +1,355 @@ +// Package get_asset_operating_model implements the get_asset_operating_model +// MCP tool: a read-only lookup of the attributes and relations an asset type +// has in a given domain of the customer's operating model. Its output mirrors +// prepare_create_asset (status, message, resolved, attributeSchema, +// relationTypes, availableStatuses) so that it can replace that tool. +package get_asset_operating_model + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const complexRelationKind = "ComplexRelationType" + +// Status is the lookup outcome. +type Status string + +const ( + // StatusReady means the asset type and domain were resolved and the + // operating model was retrieved. + StatusReady Status = "ready" + // StatusNeedsClarification means the asset type or domain could not be + // resolved, or the asset type is not allowed in the domain. + StatusNeedsClarification Status = "needs_clarification" +) + +type Input struct { + AssetType string `json:"assetType" jsonschema:"Asset type identifier — accepts a UUID, the type's publicId (e.g. 'BusinessTerm'), or its display name (e.g. 'Business Term'). Resolution tries those in order. Use list_asset_types to find one."` + Domain string `json:"domain,omitempty" jsonschema:"Optional. Domain identifier — accepts a UUID or the domain's display name (case-insensitive). The domain decides which attributes and relations apply. Omit it to get the asset type's default operating model, e.g. before querying; pass it before create_asset."` +} + +type Output struct { + Status Status `json:"status" jsonschema:"ready when the operating model was retrieved; needs_clarification when the asset type or domain could not be resolved or the asset type is not allowed in the domain."` + Message string `json:"message" jsonschema:"Human-readable summary of the outcome."` + Resolved *Resolved `json:"resolved,omitempty" jsonschema:"The resolved asset type and domain. Pass these IDs to create_asset."` + AttributeSchema []AttributeSchemaEntry `json:"attributeSchema,omitempty" jsonschema:"Attribute slots for the asset type in the domain: which attributes are required or read-only, what kind of value they take, and whether the value is rich text."` + RelationTypes []RelationSchemaEntry `json:"relationTypes,omitempty" jsonschema:"Relation slots for the asset type in the domain: the relation roles available, each with the relation type's UUID and publicId."` + AvailableStatuses []StatusOption `json:"availableStatuses,omitempty" jsonschema:"All statuses defined on this instance, so the agent can pick a non-default initial status when calling create_asset."` +} + +// Resolved holds the resolved asset type and domain. +type Resolved struct { + AssetTypeID string `json:"assetTypeId" jsonschema:"UUID of the asset type."` + AssetTypeName string `json:"assetTypeName" jsonschema:"Display name of the asset type."` + AssetTypePublicID string `json:"assetTypePublicId,omitempty" jsonschema:"PublicId of the asset type, when known."` + DomainID string `json:"domainId,omitempty" jsonschema:"UUID of the domain. Absent when no domain was given."` + DomainName string `json:"domainName,omitempty" jsonschema:"Display name of the domain. Absent when no domain was given."` + DomainTypeID string `json:"domainTypeId,omitempty" jsonschema:"UUID of the domain's domain type."` + DomainTypeName string `json:"domainTypeName,omitempty" jsonschema:"Display name of the domain's domain type."` +} + +type AttributeSchemaEntry struct { + AttributeTypeID string `json:"attributeTypeId" jsonschema:"UUID of the attribute type — pass this in attributes[].typeId to create_asset."` + Name string `json:"name" jsonschema:"Display name of the attribute (e.g. 'Definition', 'Note'). Also accepted by create_asset."` + PublicID string `json:"publicId,omitempty" jsonschema:"PublicId of the attribute type."` + Kind string `json:"kind" jsonschema:"Attribute-type discriminator (e.g. 'StringAttributeType', 'NumericAttributeType')."` + Required bool `json:"required" jsonschema:"True when the assignment's minimumOccurrences > 0. Collibra doesn't always enforce this at create time — it can be an attestation/workflow signal rather than a hard create-time requirement."` + Min int `json:"min" jsonschema:"Minimum number of occurrences."` + Max *int `json:"max,omitempty" jsonschema:"Maximum number of occurrences. Absent when unbounded."` + ReadOnly bool `json:"readOnly,omitempty" jsonschema:"True when the assignment marks the attribute read-only because Collibra sets its value. create_asset rejects it in attributes[] and does not require it, even when required is true."` + StringType string `json:"stringType,omitempty" jsonschema:"For string-kind attributes: 'RICH_TEXT' means create_asset will run the value through Markdown→HTML conversion."` + Description string `json:"description,omitempty" jsonschema:"Server-defined description of the attribute."` + AllowedValues []string `json:"allowedValues,omitempty" jsonschema:"Permitted values for list-type attributes."` +} + +type RelationSchemaEntry struct { + RelationTypeID string `json:"relationTypeId" jsonschema:"UUID of the relation type. A relation type assigned in both directions appears once per direction, so the same relationTypeId may occur in two entries that differ by direction."` + PublicID string `json:"publicId,omitempty" jsonschema:"PublicId of the relation type."` + Kind string `json:"kind,omitempty" jsonschema:"Discriminator: 'RelationType' for a simple binary relation, 'ComplexRelationType' for a complex one. Complex relation types carry legs[] instead of a single role/coRole."` + Role string `json:"role" jsonschema:"Forward role name of the relation (e.g. 'is part of'). Empty for complex relation types — see legs[] instead."` + CoRole string `json:"coRole,omitempty" jsonschema:"Reverse role name (e.g. 'contains')."` + Direction string `json:"direction,omitempty" jsonschema:"Direction of the relation from the asset's side: 'TO_TARGET' (the asset is the source leg) or 'TO_SOURCE' (the asset is the target leg)."` + TargetTypeID string `json:"targetTypeId,omitempty" jsonschema:"UUID of the asset type on the other leg."` + TargetTypeName string `json:"targetTypeName,omitempty" jsonschema:"Display name of the asset type on the other leg."` + Legs []LegEntry `json:"legs,omitempty" jsonschema:"For complex relation types only: the two or more legs, each with its own role and asset type. Absent for simple relations."` +} + +type LegEntry struct { + Role string `json:"role" jsonschema:"Role name of this leg."` + CoRole string `json:"coRole,omitempty" jsonschema:"Reverse role name of this leg."` + AssetTypeID string `json:"assetTypeId,omitempty" jsonschema:"UUID of the asset type on this leg."` + AssetTypeName string `json:"assetTypeName,omitempty" jsonschema:"Display name of the asset type on this leg."` + RelationTypePublicID string `json:"relationTypePublicId,omitempty" jsonschema:"PublicId of the simple relation type underlying this leg."` + Min int `json:"min" jsonschema:"Minimum number of occurrences for this leg."` + Max *int `json:"max,omitempty" jsonschema:"Maximum number of occurrences for this leg. Absent when unbounded."` +} + +type StatusOption struct { + ID string `json:"id" jsonschema:"UUID of the status."` + Name string `json:"name" jsonschema:"Display name of the status."` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "get_asset_operating_model", + Title: "Get Asset Operating Model", + Description: "Get the attributes and relations an asset type has in a given domain of this Collibra instance's operating model. Use it before querying so the first query uses valid attribute and relation names, and before create_asset to learn which attributes are required or read-only, which are rich text, which relations apply and which statuses can be set. assetType (UUID, publicId or name) is required. domain (UUID or name) is optional: pass it to get the model that applies in that domain, which is what create_asset needs; omit it to get the asset type's default model. Replaces prepare_create_asset.", + Handler: handler(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: chip.Ptr(false), IdempotentHint: true, OpenWorldHint: chip.Ptr(false)}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + assetTypeValue := strings.TrimSpace(input.AssetType) + if assetTypeValue == "" { + return Output{}, errors.New("assetType is required") + } + domainValue := strings.TrimSpace(input.Domain) + + assetType, err := resolveAssetType(ctx, collibraClient, assetTypeValue) + if err != nil { + return needsClarification(notResolvedMessage("Asset type", assetTypeValue, err)), nil + } + + var domain *clients.PrepareCreateDomain + if domainValue != "" { + domain, err = resolveDomain(ctx, collibraClient, domainValue) + if err != nil { + return needsClarification(notResolvedMessage("Domain", domainValue, err)), nil + } + } + + assignment, err := fetchAssignment(ctx, collibraClient, assetType, domain) + if err != nil { + return assignmentFailed(ctx, collibraClient, err, assetType, domain), nil + } + + out := Output{ + Status: StatusReady, + Message: readyMessage(assetType, domain, assignment), + Resolved: resolved(assetType, domain), + AttributeSchema: attributeEntries(assignment.Attributes), + RelationTypes: relationEntries(assignment.Relations), + } + + if err := hydrateAttributes(ctx, collibraClient, out.AttributeSchema); err != nil { + out.Message += " (attribute details could not be fully hydrated: " + err.Error() + ")" + } + if err := hydrateRelations(ctx, collibraClient, out.RelationTypes); err != nil { + out.Message += " (relation details could not be fully hydrated: " + err.Error() + ")" + } + + statuses, _ := clients.ListStatusesAll(ctx, collibraClient) + out.AvailableStatuses = statusOptions(statuses) + + return out, nil + } +} + +func needsClarification(message string) Output { + return Output{Status: StatusNeedsClarification, Message: message} +} + +func notResolvedMessage(label, value string, err error) string { + return fmt.Sprintf("%s %q could not be resolved: %v.", label, value, err) +} + +// fetchAssignment looks up the assignment for the asset type in the domain, or +// the asset type's default assignment when no domain was given. +func fetchAssignment(ctx context.Context, collibraClient *http.Client, assetType *clients.PrepareCreateAssetType, domain *clients.PrepareCreateDomain) (*clients.PrepareCreateScopedAssignment, error) { + if domain == nil { + return clients.GetDefaultAssignmentForAssetType(ctx, collibraClient, assetType.ID) + } + return clients.GetAssignmentForAssetTypeAndDomain(ctx, collibraClient, assetType.ID, domain.ID) +} + +func readyMessage(assetType *clients.PrepareCreateAssetType, domain *clients.PrepareCreateDomain, assignment *clients.PrepareCreateScopedAssignment) string { + counts := fmt.Sprintf("%d attribute slot(s), %d relation slot(s) in scope.", len(assignment.Attributes), len(assignment.Relations)) + if domain == nil { + return fmt.Sprintf("Resolved asset type %q without a domain: this is its default operating model, and a domain can change which attributes and relations apply. %s", assetType.Name, counts) + } + return fmt.Sprintf("Resolved asset type %q in domain %q. %s", assetType.Name, domain.Name, counts) +} + +// assignmentFailed explains why no assignment could be returned. A missing +// assignment means the asset type is not allowed in the domain; anything else is +// reported as a lookup failure so it isn't mistaken for a modelling problem. +func assignmentFailed(ctx context.Context, collibraClient *http.Client, err error, assetType *clients.PrepareCreateAssetType, domain *clients.PrepareCreateDomain) Output { + switch { + case errors.Is(err, clients.ErrNoDefaultAssignment): + return needsClarification(fmt.Sprintf("Asset type %q only has attributes and relations in specific domains, so a domain is required. Pass the domain the asset is or will be in.", assetType.Name)) + case !errors.Is(err, clients.ErrAssignmentNotFound): + return needsClarification(fmt.Sprintf("Failed to retrieve operating model: %s", err.Error())) + case domain == nil: + return needsClarification(fmt.Sprintf("Asset type %q has no attributes or relations assigned in this instance.", assetType.Name)) + } + domainTypeName := "" + if domain.Type != nil { + domainTypeName = domain.Type.Name + } + return needsClarification(clients.NotAllowedMessage(ctx, collibraClient, assetType.ID, assetType.Name, domain.Name, domainTypeName)) +} + +func resolved(assetType *clients.PrepareCreateAssetType, domain *clients.PrepareCreateDomain) *Resolved { + r := &Resolved{ + AssetTypeID: assetType.ID, + AssetTypeName: assetType.Name, + AssetTypePublicID: assetType.PublicID, + } + if domain == nil { + return r + } + r.DomainID = domain.ID + r.DomainName = domain.Name + if domain.Type != nil { + r.DomainTypeID = domain.Type.ID + r.DomainTypeName = domain.Type.Name + } + return r +} + +func attributeEntries(in []clients.PrepareCreateScopedAttribute) []AttributeSchemaEntry { + out := make([]AttributeSchemaEntry, len(in)) + for i, a := range in { + out[i] = AttributeSchemaEntry{ + AttributeTypeID: a.AttributeTypeID, + Name: a.AttributeTypeName, + PublicID: a.AttributeTypePublicID, + Kind: a.Kind, + Required: a.Required, + Min: a.Min, + Max: a.Max, + ReadOnly: a.ReadOnly, + } + } + return out +} + +func relationEntries(in []clients.PrepareCreateScopedRelation) []RelationSchemaEntry { + out := make([]RelationSchemaEntry, len(in)) + for i, r := range in { + entry := RelationSchemaEntry{ + RelationTypeID: r.RelationTypeID, + PublicID: r.RelationTypePublicID, + Kind: r.Kind, + Direction: r.Direction, + } + if r.TargetType != nil { + entry.TargetTypeID = r.TargetType.ID + entry.TargetTypeName = r.TargetType.Name + } + out[i] = entry + } + return out +} + +func statusOptions(in []clients.PrepareCreateStatus) []StatusOption { + out := make([]StatusOption, len(in)) + for i, s := range in { + out[i] = StatusOption{ID: s.ID, Name: s.Name} + } + return out +} + +// hydrateAttributes adds stringType, description and allowedValues, which the +// assignment payload omits. Failures on individual attributes are tolerated so +// one missing detail doesn't blank the whole schema; the first error is returned. +func hydrateAttributes(ctx context.Context, collibraClient *http.Client, schema []AttributeSchemaEntry) error { + var firstErr error + for i := range schema { + details, err := clients.GetAttributeTypeFull(ctx, collibraClient, schema[i].AttributeTypeID) + if err != nil { + firstErr = firstError(firstErr, err) + continue + } + schema[i].StringType = details.StringType + schema[i].Description = details.Description + if len(details.AllowedValues) > 0 { + schema[i].AllowedValues = details.AllowedValues + } + if schema[i].PublicID == "" { + schema[i].PublicID = details.PublicID + } + } + return firstErr +} + +// hydrateRelations adds the role names and leg types that the assignment payload +// omits. Failures on individual relations are tolerated; the first error is returned. +func hydrateRelations(ctx context.Context, collibraClient *http.Client, relations []RelationSchemaEntry) error { + var firstErr error + for i := range relations { + var err error + if relations[i].Kind == complexRelationKind { + err = hydrateComplexRelation(ctx, collibraClient, &relations[i]) + } else { + err = hydrateSimpleRelation(ctx, collibraClient, &relations[i]) + } + firstErr = firstError(firstErr, err) + } + return firstErr +} + +func hydrateSimpleRelation(ctx context.Context, collibraClient *http.Client, rel *RelationSchemaEntry) error { + details, err := clients.GetRelationTypeFull(ctx, collibraClient, rel.RelationTypeID) + if err != nil { + return err + } + rel.Role = details.Role + rel.CoRole = details.CoRole + if rel.PublicID == "" { + rel.PublicID = details.PublicID + } + if rel.TargetTypeID != "" { + return nil + } + other := details.TargetType + if rel.Direction == "TO_SOURCE" { + other = details.SourceType + } + if other != nil { + rel.TargetTypeID = other.ID + rel.TargetTypeName = other.Name + } + return nil +} + +func hydrateComplexRelation(ctx context.Context, collibraClient *http.Client, rel *RelationSchemaEntry) error { + details, err := clients.GetComplexRelationTypeFull(ctx, collibraClient, rel.RelationTypeID) + if err != nil { + return err + } + if rel.PublicID == "" { + rel.PublicID = details.PublicID + } + rel.Legs = make([]LegEntry, len(details.Legs)) + for i, leg := range details.Legs { + rel.Legs[i] = LegEntry{ + Role: leg.Role, + CoRole: leg.CoRole, + AssetTypeID: leg.AssetTypeID, + AssetTypeName: leg.AssetTypeName, + RelationTypePublicID: leg.RelationTypePublicID, + Min: leg.Min, + Max: leg.Max, + } + } + return nil +} + +func firstError(current, next error) error { + if current != nil { + return current + } + return next +} diff --git a/pkg/tools/get_asset_operating_model/tool_test.go b/pkg/tools/get_asset_operating_model/tool_test.go new file mode 100644 index 0000000..ddf2a87 --- /dev/null +++ b/pkg/tools/get_asset_operating_model/tool_test.go @@ -0,0 +1,340 @@ +package get_asset_operating_model_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + tools "github.com/collibra/chip/pkg/tools/get_asset_operating_model" + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + assetTypeID = "00000000-0000-0000-0000-000000000011" + domainID = "00000000-0000-0000-0000-000000000022" +) + +const ( + assetTypeJSON = `{"id":"` + assetTypeID + `","publicId":"BusinessAsset","name":"Business Asset"}` + domainJSON = `{"id":"` + domainID + `","name":"AI Use Cases","type":{"id":"dt-1","name":"Business Asset Domain"}}` +) + +const assignmentJSON = `{ + "id": "assignment-1", + "assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-1", "name": "Definition", "resourceDiscriminator": "StringAttributeType"}, + "assignedResourcePublicId": "Definition", "minimumOccurrences": 1, "maximumOccurrences": 1}, + {"assignedResourceReference": {"id": "rel-1", "resourceDiscriminator": "RelationType"}, + "relationTypeDirection": "TO_SOURCE"}, + {"assignedResourceReference": {"id": "rel-2", "resourceDiscriminator": "ComplexRelationType"}, + "assignedResourcePublicId": "ComplexOne"} + ] +}` + +// serverConfig describes which collaborators the fake Collibra serves. +type serverConfig struct { + assignmentStatus int + withDetails bool + // defaultAssignments is the body served for the no-domain assignments + // lookup; empty means the endpoint returns an empty list. + defaultAssignments string +} + +func newServer(t *testing.T, cfg serverConfig) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/rest/2.0/assetTypes/"+assetTypeID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(assetTypeJSON)) + }) + mux.HandleFunc("/rest/2.0/assetTypes/publicId/BusinessAsset", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(assetTypeJSON)) + }) + mux.HandleFunc("/rest/2.0/assetTypes", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("name") { + case "business asset", "Business Asset": + _, _ = w.Write([]byte(`{"results":[` + assetTypeJSON + `,{"id":"other","name":"Business Asset Group"}],"total":2}`)) + case "Twin": + _, _ = w.Write([]byte(`{"results":[{"id":"t1","name":"Twin"},{"id":"t2","name":"twin"}],"total":2}`)) + default: + _, _ = w.Write([]byte(`{"results":[],"total":0}`)) + } + }) + mux.HandleFunc("/rest/2.0/domains", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("name") { + case "ai use cases", "AI Use Cases": + _, _ = w.Write([]byte(`{"results":[` + domainJSON + `,{"id":"d2","name":"AI Use Cases Archive"}],"total":2}`)) + case "Twin": + _, _ = w.Write([]byte(`{"results":[{"id":"d3","name":"Twin"},{"id":"d4","name":"twin"}],"total":2}`)) + default: + _, _ = w.Write([]byte(`{"results":[],"total":0}`)) + } + }) + mux.HandleFunc("/rest/2.0/domains/"+domainID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(domainJSON)) + }) + mux.HandleFunc("/rest/2.0/assignments/assetType/"+assetTypeID+"/domain/"+domainID, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(cfg.assignmentStatus) + if cfg.assignmentStatus == http.StatusOK { + _, _ = w.Write([]byte(assignmentJSON)) + } + }) + mux.HandleFunc("/rest/2.0/assignments/assetType/"+assetTypeID, func(w http.ResponseWriter, _ *http.Request) { + if cfg.defaultAssignments == "" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(cfg.defaultAssignments)) + }) + if cfg.withDetails { + mux.HandleFunc("/rest/2.0/attributeTypes/attr-1", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"attr-1","stringType":"RICH_TEXT","description":"Shortest clear definition","allowedValues":["a","b"]}`)) + }) + mux.HandleFunc("/rest/2.0/relationTypes/rel-1", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"rel-1","publicId":"TermIsPartOf","role":"is part of","coRole":"contains", + "sourceType":{"id":"src-1","name":"Source"},"targetType":{"id":"tgt-1","name":"Target"}}`)) + }) + mux.HandleFunc("/rest/2.0/complexRelationTypes/rel-2", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"rel-2","publicId":"ComplexOne","legTypes":[ + {"role":"leg a","coRole":"leg b","relationTypePublicId":"LegRel","minimumOccurrences":1,"assetType":{"id":"at-1","name":"Type A"}}]}`)) + }) + mux.HandleFunc("/rest/2.0/statuses", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"results":[{"id":"st-1","name":"Candidate"},{"id":"st-2","name":"Approved"}],"total":2}`)) + }) + } + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func call(t *testing.T, server *httptest.Server, input tools.Input) tools.Output { + t.Helper() + output, err := tools.NewTool(testutil.NewClient(server)).Handler(t.Context(), input) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + return output +} + +func TestGetAssetOperatingModel_ShouldReturnResolvedSchemaStatusesAndHydratedDetails(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusOK, withDetails: true}) + + output := call(t, server, tools.Input{AssetType: assetTypeID, Domain: domainID}) + + if output.Status != tools.StatusReady { + t.Fatalf("expected status ready, got %q: %s", output.Status, output.Message) + } + if output.Resolved == nil || output.Resolved.AssetTypeName != "Business Asset" || output.Resolved.AssetTypePublicID != "BusinessAsset" || + output.Resolved.DomainName != "AI Use Cases" || output.Resolved.DomainTypeName != "Business Asset Domain" { + t.Fatalf("unexpected resolved: %+v", output.Resolved) + } + if len(output.AttributeSchema) != 1 { + t.Fatalf("expected 1 attribute, got %+v", output.AttributeSchema) + } + definition := output.AttributeSchema[0] + if definition.Name != "Definition" || !definition.Required || definition.StringType != "RICH_TEXT" || + definition.Description != "Shortest clear definition" || len(definition.AllowedValues) != 2 { + t.Errorf("attribute not hydrated: %+v", definition) + } + if len(output.RelationTypes) != 2 { + t.Fatalf("expected 2 relations, got %+v", output.RelationTypes) + } + simple := output.RelationTypes[0] + if simple.Role != "is part of" || simple.CoRole != "contains" || simple.PublicID != "TermIsPartOf" { + t.Errorf("simple relation not hydrated: %+v", simple) + } + if simple.TargetTypeID != "src-1" { + t.Errorf("expected TO_SOURCE relation to point at the source type, got %q", simple.TargetTypeID) + } + complexRel := output.RelationTypes[1] + if len(complexRel.Legs) != 1 || complexRel.Legs[0].Role != "leg a" || complexRel.Legs[0].AssetTypeName != "Type A" || + complexRel.Legs[0].RelationTypePublicID != "LegRel" { + t.Errorf("complex relation legs not hydrated: %+v", complexRel) + } + if len(output.AvailableStatuses) != 2 || output.AvailableStatuses[0].Name != "Candidate" { + t.Errorf("unexpected statuses: %+v", output.AvailableStatuses) + } +} + +func TestGetAssetOperatingModel_ShouldKeepSchemaWhenHydrationFails(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusOK}) + + output := call(t, server, tools.Input{AssetType: assetTypeID, Domain: domainID}) + + if output.Status != tools.StatusReady { + t.Fatalf("expected status ready, got %q: %s", output.Status, output.Message) + } + if len(output.AttributeSchema) != 1 || len(output.RelationTypes) != 2 { + t.Fatalf("expected schema to survive failed hydration, got %+v", output) + } + if output.AttributeSchema[0].StringType != "" || output.RelationTypes[0].Role != "" { + t.Errorf("expected no hydrated details, got %+v", output) + } + if !strings.Contains(output.Message, "could not be fully hydrated") { + t.Errorf("expected message to mention failed hydration, got %q", output.Message) + } + if len(output.AvailableStatuses) != 0 { + t.Errorf("expected no statuses, got %+v", output.AvailableStatuses) + } +} + +func TestGetAssetOperatingModel_ShouldNeedClarificationWhenAssetTypeNotAllowedInDomain(t *testing.T) { + // The asset type is allowed in another domain type, just not in this domain. + server := newServer(t, serverConfig{ + assignmentStatus: http.StatusNotFound, + defaultAssignments: `[{"id": "a", "domainTypes": [{"id": "other-dt", "name": "Other Domain Type"}]}]`, + }) + + output := call(t, server, tools.Input{AssetType: assetTypeID, Domain: domainID}) + + if output.Status != tools.StatusNeedsClarification { + t.Fatalf("expected needs_clarification, got %q", output.Status) + } + if !strings.Contains(output.Message, "isn't allowed in domain") || !strings.Contains(output.Message, "AI Use Cases") { + t.Errorf("expected message to name the asset type and domain, got %q", output.Message) + } + if output.Resolved != nil || len(output.AttributeSchema) != 0 { + t.Errorf("expected no schema, got %+v", output) + } +} + +func TestGetAssetOperatingModel_ShouldReportLookupFailureOnServerError(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusInternalServerError}) + + output := call(t, server, tools.Input{AssetType: assetTypeID, Domain: domainID}) + + if output.Status != tools.StatusNeedsClarification || !strings.Contains(output.Message, "Failed to retrieve operating model") { + t.Fatalf("expected a lookup failure message, got %q: %s", output.Status, output.Message) + } +} + +func TestGetAssetOperatingModel_ShouldResolveAssetTypeAndDomainByAnyIdentifier(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusOK}) + + tests := map[string]tools.Input{ + "uuids": {AssetType: assetTypeID, Domain: domainID}, + "publicId and domain name": {AssetType: "BusinessAsset", Domain: "AI Use Cases"}, + "names ignoring case": {AssetType: "business asset", Domain: "ai use cases"}, + "surrounding whitespace": {AssetType: " BusinessAsset ", Domain: " " + domainID + " "}, + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + output := call(t, server, input) + if output.Status != tools.StatusReady { + t.Fatalf("expected status ready, got %q: %s", output.Status, output.Message) + } + if output.Resolved == nil || output.Resolved.AssetTypeID != assetTypeID || output.Resolved.DomainID != domainID { + t.Errorf("expected the asset type and domain to resolve to the known IDs, got %+v", output.Resolved) + } + }) + } +} + +func TestGetAssetOperatingModel_ShouldNeedClarificationWhenAssetTypeOrDomainCannotBeResolved(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusOK}) + otherID := "00000000-0000-0000-0000-000000000099" + + tests := map[string]struct { + input tools.Input + message string + }{ + "unknown asset type uuid": {tools.Input{AssetType: otherID, Domain: domainID}, "Asset type"}, + "unknown asset type name": {tools.Input{AssetType: "Nope", Domain: domainID}, "no asset type matches"}, + "ambiguous asset type": {tools.Input{AssetType: "Twin", Domain: domainID}, "ambiguous"}, + "unknown domain uuid": {tools.Input{AssetType: assetTypeID, Domain: otherID}, "Domain"}, + "unknown domain name": {tools.Input{AssetType: assetTypeID, Domain: "Nope"}, "no domain matches"}, + "ambiguous domain": {tools.Input{AssetType: assetTypeID, Domain: "Twin"}, "ambiguous"}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + output := call(t, server, tt.input) + if output.Status != tools.StatusNeedsClarification || !strings.Contains(output.Message, tt.message) { + t.Fatalf("expected needs_clarification containing %q, got %q: %s", tt.message, output.Status, output.Message) + } + if output.Resolved != nil { + t.Errorf("expected nothing resolved, got %+v", output.Resolved) + } + }) + } +} + +func TestGetAssetOperatingModel_ShouldRejectMissingAssetType(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusOK}) + + tests := map[string]tools.Input{ + "missing asset type": {Domain: domainID}, + "blank asset type": {AssetType: " ", Domain: domainID}, + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + if _, err := tools.NewTool(testutil.NewClient(server)).Handler(t.Context(), input); err == nil { + t.Fatal("expected a validation error") + } + }) + } +} + +const defaultAssignmentsJSON = `[ + {"id": "scoped", "scope": {"id": "scope-1", "name": "Scope"}, + "assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-s", "name": "Scoped Only", "resourceDiscriminator": "StringAttributeType"}}]}, + {"id": "default", + "assignedCharacteristicTypeReferences": [ + {"assignedResourceReference": {"id": "attr-1", "name": "Definition", "resourceDiscriminator": "StringAttributeType"}, "minimumOccurrences": 1}]} +]` + +func TestGetAssetOperatingModel_ShouldReturnDefaultModelWhenDomainIsOmitted(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusNotFound, withDetails: true, defaultAssignments: defaultAssignmentsJSON}) + + for name, domain := range map[string]string{"omitted": "", "blank": " "} { + t.Run(name, func(t *testing.T) { + output := call(t, server, tools.Input{AssetType: "BusinessAsset", Domain: domain}) + + if output.Status != tools.StatusReady { + t.Fatalf("expected status ready, got %q: %s", output.Status, output.Message) + } + if output.Resolved == nil || output.Resolved.AssetTypeID != assetTypeID || output.Resolved.DomainID != "" || output.Resolved.DomainName != "" { + t.Errorf("expected the asset type only, got %+v", output.Resolved) + } + if len(output.AttributeSchema) != 1 || output.AttributeSchema[0].Name != "Definition" || output.AttributeSchema[0].StringType != "RICH_TEXT" { + t.Errorf("expected the default Definition attribute, hydrated, got %+v", output.AttributeSchema) + } + if !strings.Contains(output.Message, "without a domain") { + t.Errorf("expected message to say no domain was given, got %q", output.Message) + } + if len(output.AvailableStatuses) != 2 { + t.Errorf("expected statuses, got %+v", output.AvailableStatuses) + } + }) + } +} + +func TestGetAssetOperatingModel_ShouldAskForDomainWhenAssetTypeHasOnlyScopedAssignments(t *testing.T) { + server := newServer(t, serverConfig{defaultAssignments: `[{"id": "scoped", "scope": {"id": "scope-1", "name": "Scope"}}]`}) + + output := call(t, server, tools.Input{AssetType: assetTypeID}) + + if output.Status != tools.StatusNeedsClarification || !strings.Contains(output.Message, "a domain is required") { + t.Fatalf("expected a request for a domain, got %q: %s", output.Status, output.Message) + } +} + +func TestGetAssetOperatingModel_ShouldReportNoAssignmentWhenDomainIsOmittedAndNoneExist(t *testing.T) { + server := newServer(t, serverConfig{}) + + output := call(t, server, tools.Input{AssetType: assetTypeID}) + + if output.Status != tools.StatusNeedsClarification || !strings.Contains(output.Message, "no attributes or relations assigned") { + t.Fatalf("expected a no-assignment message, got %q: %s", output.Status, output.Message) + } +} + +func TestGetAssetOperatingModel_ShouldSayAssetTypeIsCreatableNowhereWhenItHasNoAssignments(t *testing.T) { + server := newServer(t, serverConfig{assignmentStatus: http.StatusNotFound}) + + output := call(t, server, tools.Input{AssetType: assetTypeID, Domain: domainID}) + + if output.Status != tools.StatusNeedsClarification || !strings.Contains(output.Message, "can't be created in any domain") { + t.Fatalf("expected the creatable-nowhere message, got %q: %s", output.Status, output.Message) + } +} diff --git a/pkg/tools/register.go b/pkg/tools/register.go index 02f680f..2fc7359 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -23,6 +23,7 @@ import ( "github.com/collibra/chip/pkg/tools/generate_dq_rule_sql" "github.com/collibra/chip/pkg/tools/get_assessment" "github.com/collibra/chip/pkg/tools/get_asset_details" + "github.com/collibra/chip/pkg/tools/get_asset_operating_model" "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_context_specification" @@ -76,6 +77,7 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv toolRegister(server, toolConfig, discover_data_assets.NewTool(client)) toolRegister(server, toolConfig, discover_business_glossary.NewTool(client)) toolRegister(server, toolConfig, get_asset_details.NewTool(client, toolConfig.IsExperimentalEnabled(ContextSpecificationsFeature))) + toolRegister(server, toolConfig, get_asset_operating_model.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))