From 79e35e5455f6a60124aa9c0afb93ac0989bc01e1 Mon Sep 17 00:00:00 2001 From: connor-savage Date: Fri, 20 Feb 2026 23:03:17 -0500 Subject: [PATCH 01/11] refactor: restructure tools into per-tool packages Move each of the 12 MCP tools from a flat pkg/tools/ package into self-contained sub-packages (pkg/tools//). This enables the factory pipeline to generate isolated tool packages without needing access to the full Chip codebase. Changes: - Each tool now lives in its own package with Input/Output types and a NewTool constructor (namespace replaces prefixes) - Shared test helpers extracted to pkg/tools/testutil/ - Registration file updated to import all sub-packages - All tests pass, build clean, vet clean --- .../tool.go} | 30 +++++++------- .../tool_test.go} | 33 +++++++-------- pkg/tools/{ask_dad.go => ask_dad/tool.go} | 20 +++++----- .../{ask_dad_test.go => ask_dad/tool_test.go} | 11 ++--- .../{ask_glossary.go => ask_glossary/tool.go} | 20 +++++----- .../tool_test.go} | 11 ++--- .../tool.go} | 28 ++++++------- .../tool_test.go} | 11 ++--- .../tool.go} | 24 +++++------ .../tool_test.go} | 11 ++--- .../tool.go} | 34 ++++++++-------- .../tool_test.go} | 11 ++--- .../tool.go} | 20 +++++----- .../tool_test.go} | 11 ++--- .../tool.go} | 34 ++++++++-------- .../tool_test.go} | 17 ++++---- .../tool.go} | 22 +++++----- .../tool_test.go} | 19 ++++----- .../tool.go} | 22 +++++----- .../tool_test.go} | 21 +++++----- pkg/tools/register.go | 40 +++++++++++++++++++ .../tool.go} | 28 ++++++------- .../tool_test.go} | 29 +++++++------- .../tool.go} | 26 ++++++------ .../tool_test.go} | 11 ++--- .../{tools_test.go => testutil/testutil.go} | 4 +- pkg/tools/tools_register.go | 28 ------------- 27 files changed, 300 insertions(+), 276 deletions(-) rename pkg/tools/{add_data_classification_match.go => add_data_classification_match/tool.go} (62%) rename pkg/tools/{add_data_classification_match_test.go => add_data_classification_match/tool_test.go} (78%) rename pkg/tools/{ask_dad.go => ask_dad/tool.go} (54%) rename pkg/tools/{ask_dad_test.go => ask_dad/tool_test.go} (66%) rename pkg/tools/{ask_glossary.go => ask_glossary/tool.go} (50%) rename pkg/tools/{ask_glossary_test.go => ask_glossary/tool_test.go} (64%) rename pkg/tools/{find_data_classification_matches.go => find_data_classification_matches/tool.go} (68%) rename pkg/tools/{find_data_classification_matches_test.go => find_data_classification_matches/tool_test.go} (76%) rename pkg/tools/{get_asset_details.go => get_asset_details/tool.go} (78%) rename pkg/tools/{get_asset_details_test.go => get_asset_details/tool_test.go} (66%) rename pkg/tools/{keyword_search.go => keyword_search/tool.go} (81%) rename pkg/tools/{keyword_search_test.go => keyword_search/tool_test.go} (68%) rename pkg/tools/{list_asset_types.go => list_asset_types/tool.go} (81%) rename pkg/tools/{list_asset_types_test.go => list_asset_types/tool_test.go} (78%) rename pkg/tools/{list_data_contracts.go => list_data_contracts/tool.go} (56%) rename pkg/tools/{list_data_contracts_test.go => list_data_contracts/tool_test.go} (75%) rename pkg/tools/{pull_data_contract_manifest.go => pull_data_contract_manifest/tool.go} (63%) rename pkg/tools/{pull_data_contract_manifest_test.go => pull_data_contract_manifest/tool_test.go} (72%) rename pkg/tools/{push_data_contract_manifest.go => push_data_contract_manifest/tool.go} (79%) rename pkg/tools/{push_data_contract_manifest_test.go => push_data_contract_manifest/tool_test.go} (86%) create mode 100644 pkg/tools/register.go rename pkg/tools/{remove_data_classification_match.go => remove_data_classification_match/tool.go} (53%) rename pkg/tools/{remove_data_classification_match_test.go => remove_data_classification_match/tool_test.go} (69%) rename pkg/tools/{search_data_classes.go => search_data_classes/tool.go} (69%) rename pkg/tools/{search_data_classes_test.go => search_data_classes/tool_test.go} (64%) rename pkg/tools/{tools_test.go => testutil/testutil.go} (97%) delete mode 100644 pkg/tools/tools_register.go 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 9a860eb..c213060 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: "data_classification_match_add", 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 78% rename from pkg/tools/add_data_classification_match_test.go rename to pkg/tools/add_data_classification_match/tool_test.go index ca35e47..fc10d49 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" + "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 := add_data_classification_match.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := add_data_classification_match.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 := add_data_classification_match.Input{ ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := add_data_classification_match.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 := add_data_classification_match.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := add_data_classification_match.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 := add_data_classification_match.Input{ AssetID: "00000000-0000-0000-0000-000000000000", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := add_data_classification_match.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 := add_data_classification_match.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := tools.NewAddDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := add_data_classification_match.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) diff --git a/pkg/tools/ask_dad.go b/pkg/tools/ask_dad/tool.go similarity index 54% rename from pkg/tools/ask_dad.go rename to pkg/tools/ask_dad/tool.go index 6564ca6..a55798f 100644 --- a/pkg/tools/ask_dad.go +++ b/pkg/tools/ask_dad/tool.go @@ -1,4 +1,4 @@ -package tools +package ask_dad 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: "data_assets_discover", Description: "Ask the data asset discovery agent questions about available data assets in Collibra.", - 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/ask_dad_test.go b/pkg/tools/ask_dad/tool_test.go similarity index 66% rename from pkg/tools/ask_dad_test.go rename to pkg/tools/ask_dad/tool_test.go index 5e1fd46..59240a7 100644 --- a/pkg/tools/ask_dad_test.go +++ b/pkg/tools/ask_dad/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package ask_dad_test import ( "fmt" @@ -7,12 +7,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + "github.com/collibra/chip/pkg/tools/ask_dad" + "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 := ask_dad.NewTool(client).Handler(t.Context(), ask_dad.Input{ Question: "Column names with PII in table users?", }) if err != nil { diff --git a/pkg/tools/ask_glossary.go b/pkg/tools/ask_glossary/tool.go similarity index 50% rename from pkg/tools/ask_glossary.go rename to pkg/tools/ask_glossary/tool.go index efd7dac..65cb873 100644 --- a/pkg/tools/ask_glossary.go +++ b/pkg/tools/ask_glossary/tool.go @@ -1,4 +1,4 @@ -package tools +package ask_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: "business_glossary_discover", Description: "Ask the business glossary agent questions about terms and definitions in Collibra.", - 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/ask_glossary_test.go b/pkg/tools/ask_glossary/tool_test.go similarity index 64% rename from pkg/tools/ask_glossary_test.go rename to pkg/tools/ask_glossary/tool_test.go index 8de4775..27d5e36 100644 --- a/pkg/tools/ask_glossary_test.go +++ b/pkg/tools/ask_glossary/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package ask_glossary_test import ( "fmt" @@ -7,12 +7,13 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + "github.com/collibra/chip/pkg/tools/ask_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 := ask_glossary.NewTool(client).Handler(t.Context(), ask_glossary.Input{ Question: "What is the definition of ARR?", }) if err != nil { diff --git a/pkg/tools/find_data_classification_matches.go b/pkg/tools/find_data_classification_matches/tool.go similarity index 68% rename from pkg/tools/find_data_classification_matches.go rename to pkg/tools/find_data_classification_matches/tool.go index 2c6429b..4920da0 100644 --- a/pkg/tools/find_data_classification_matches.go +++ b/pkg/tools/find_data_classification_matches/tool.go @@ -1,4 +1,4 @@ -package tools +package find_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: "data_classification_match_search", 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/find_data_classification_matches_test.go b/pkg/tools/find_data_classification_matches/tool_test.go similarity index 76% rename from pkg/tools/find_data_classification_matches_test.go rename to pkg/tools/find_data_classification_matches/tool_test.go index e799617..34059cc 100644 --- a/pkg/tools/find_data_classification_matches_test.go +++ b/pkg/tools/find_data_classification_matches/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package find_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" + "github.com/collibra/chip/pkg/tools/find_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 := find_data_classification_matches.NewTool(client).Handler(t.Context(), find_data_classification_matches.Input{ Statuses: []string{"ACCEPTED"}, Limit: 50, }) diff --git a/pkg/tools/get_asset_details.go b/pkg/tools/get_asset_details/tool.go similarity index 78% rename from pkg/tools/get_asset_details.go rename to pkg/tools/get_asset_details/tool.go index 65985fb..1daea50 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" @@ -12,33 +12,33 @@ 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"` Link string `json:"link,omitempty" jsonschema:"the link you can navigate to in Collibra to view the asset"` Error string `json:"error,omitempty" jsonschema:"error message if asset not found or other error occurred"` Found bool `json:"found" jsonschema:"whether the asset was found"` } -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: "asset_details_get", Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, 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{ + return Output{ Error: fmt.Sprintf("Invalid asset ID format: %s", err.Error()), Found: false, }, nil @@ -52,14 +52,14 @@ func handleAssetDetails(collibraClient *http.Client) chip.ToolHandlerFunc[AssetD input.IncomingRelationsCursor, ) if err != nil { - return AssetDetailsOutput{ + return Output{ Error: fmt.Sprintf("Failed to retrieve asset details: %s", err.Error()), Found: false, }, nil } if len(assets) == 0 { - return AssetDetailsOutput{ + return Output{ Error: "Asset not found", Found: false, }, nil @@ -70,7 +70,7 @@ func handleAssetDetails(collibraClient *http.Client) chip.ToolHandlerFunc[AssetD slog.WarnContext(ctx, "Collibra instance URL unknown, links will be rendered without host") } - return AssetDetailsOutput{ + return Output{ Asset: &assets[0], Found: true, Link: fmt.Sprintf("%s/asset/%s", strings.TrimSuffix(collibraHost, "/"), assetUUID), diff --git a/pkg/tools/get_asset_details_test.go b/pkg/tools/get_asset_details/tool_test.go similarity index 66% rename from pkg/tools/get_asset_details_test.go rename to pkg/tools/get_asset_details/tool_test.go index cadc930..c4d121f 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" @@ -6,14 +6,15 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/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{ @@ -28,9 +29,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 := get_asset_details.NewTool(client).Handler(t.Context(), get_asset_details.Input{ AssetID: assetId.String(), }) if err != nil { diff --git a/pkg/tools/keyword_search.go b/pkg/tools/keyword_search/tool.go similarity index 81% rename from pkg/tools/keyword_search.go rename to pkg/tools/keyword_search/tool.go index 1a158b9..74f684d 100644 --- a/pkg/tools/keyword_search.go +++ b/pkg/tools/keyword_search/tool.go @@ -1,4 +1,4 @@ -package tools +package keyword_search 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: "asset_keyword_search", 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/keyword_search_test.go b/pkg/tools/keyword_search/tool_test.go similarity index 68% rename from pkg/tools/keyword_search_test.go rename to pkg/tools/keyword_search/tool_test.go index 7d0898d..311363e 100644 --- a/pkg/tools/keyword_search_test.go +++ b/pkg/tools/keyword_search/tool_test.go @@ -1,4 +1,4 @@ -package tools_test +package keyword_search_test import ( "net/http" @@ -6,14 +6,15 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools" + "github.com/collibra/chip/pkg/tools/keyword_search" + "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 := keyword_search.NewTool(client).Handler(t.Context(), keyword_search.Input{ Query: "revenue", }) if err != 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 be8576b..380177e 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: "asset_types_list", 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 78% rename from pkg/tools/list_asset_types_test.go rename to pkg/tools/list_asset_types/tool_test.go index c7c4c1f..9867c1d 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" + "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 := list_asset_types.NewTool(client).Handler(t.Context(), list_asset_types.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 28d82a3..71e693d 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: "data_contract_list", 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 75% rename from pkg/tools/list_data_contracts_test.go rename to pkg/tools/list_data_contracts/tool_test.go index ae00f70..c91e549 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" + "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 := list_data_contracts.NewTool(client).Handler(t.Context(), list_data_contracts.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 := list_data_contracts.NewTool(client).Handler(t.Context(), list_data_contracts.Input{ Limit: 100, }) if err != nil { 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 2a64a11..a973bef 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: "data_contract_manifest_pull", 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 72% rename from pkg/tools/pull_data_contract_manifest_test.go rename to pkg/tools/pull_data_contract_manifest/tool_test.go index bcbbc06..20c7688 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" + "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 := pull_data_contract_manifest.NewTool(client).Handler(t.Context(), pull_data_contract_manifest.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 := pull_data_contract_manifest.NewTool(client).Handler(t.Context(), pull_data_contract_manifest.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 := pull_data_contract_manifest.NewTool(client).Handler(t.Context(), pull_data_contract_manifest.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 b17442c..326f423 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: "data_contract_manifest_push", 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 86% rename from pkg/tools/push_data_contract_manifest_test.go rename to pkg/tools/push_data_contract_manifest/tool_test.go index d24e8ca..677437c 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" + "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 := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.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 := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.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 := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.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 := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.Input{ Manifest: manifestContent, }) if err != nil { diff --git a/pkg/tools/register.go b/pkg/tools/register.go new file mode 100644 index 0000000..7b620b2 --- /dev/null +++ b/pkg/tools/register.go @@ -0,0 +1,40 @@ +package tools + +import ( + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/tools/add_data_classification_match" + "github.com/collibra/chip/pkg/tools/ask_dad" + "github.com/collibra/chip/pkg/tools/ask_glossary" + "github.com/collibra/chip/pkg/tools/find_data_classification_matches" + "github.com/collibra/chip/pkg/tools/get_asset_details" + "github.com/collibra/chip/pkg/tools/keyword_search" + "github.com/collibra/chip/pkg/tools/list_asset_types" + "github.com/collibra/chip/pkg/tools/list_data_contracts" + "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_data_classes" +) + +func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.ServerToolConfig) { + toolRegister(server, toolConfig, ask_dad.NewTool(client)) + toolRegister(server, toolConfig, ask_glossary.NewTool(client)) + toolRegister(server, toolConfig, get_asset_details.NewTool(client)) + toolRegister(server, toolConfig, keyword_search.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, find_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)) +} + +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 b39a91f..0b4c561 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: "data_classification_match_remove", 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 69% rename from pkg/tools/remove_data_classification_match_test.go rename to pkg/tools/remove_data_classification_match/tool_test.go index bae5f84..71e63ac 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" + "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 := remove_data_classification_match.Input{ ClassificationMatchID: "12345678-1234-1234-1234-123456789abc", } - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := remove_data_classification_match.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 := remove_data_classification_match.Input{} - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := remove_data_classification_match.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 := remove_data_classification_match.Input{ ClassificationMatchID: "00000000-0000-0000-0000-000000000000", } - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := remove_data_classification_match.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 := remove_data_classification_match.Input{ ClassificationMatchID: "12345678-1234-1234-1234-123456789abc", } - output, err := tools.NewRemoveDataClassificationMatchTool(client).Handler(t.Context(), input) + output, err := remove_data_classification_match.NewTool(client).Handler(t.Context(), input) if err != nil { t.Fatalf("Expected no error, got %v", err) 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 74f1869..dccb8b5 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: "data_class_search", 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 64% rename from pkg/tools/search_data_classes_test.go rename to pkg/tools/search_data_classes/tool_test.go index b6a865f..873489a 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" + "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 := search_data_classes.NewTool(client).Handler(t.Context(), search_data_classes.Input{ Name: "Question", }) if err != nil { diff --git a/pkg/tools/tools_test.go b/pkg/tools/testutil/testutil.go similarity index 97% rename from pkg/tools/tools_test.go rename to pkg/tools/testutil/testutil.go index e1f7678..669d55a 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}} } diff --git a/pkg/tools/tools_register.go b/pkg/tools/tools_register.go deleted file mode 100644 index 93910fc..0000000 --- a/pkg/tools/tools_register.go +++ /dev/null @@ -1,28 +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)) -} - -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) - } -} From 9030e1d97ce621dcd600b55fb76a3c653f5f767a Mon Sep 17 00:00:00 2001 From: connor-savage Date: Sun, 22 Feb 2026 22:24:24 -0500 Subject: [PATCH 02/11] ignore mise.toml --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 7021db72c451ae637348f8bc8e186c858fa646fd Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:44:51 -0400 Subject: [PATCH 03/11] Bot/main work (#42) - Rename sub-packages to match main's tool names: ask_dad -> discover_data_assets ask_glossary -> discover_business_glossary find_data_classification_matches -> search_data_classification_matches keyword_search -> search_asset_keyword - Port get_asset_details responsibilities feature to sub-package style - Convert 10 new tools from main to sub-packages: get_business_term_data, get_column_semantics, get_lineage_downstream, get_lineage_entity, get_lineage_transformation, get_lineage_upstream, get_measure_data, get_table_semantics, search_lineage_entities, search_lineage_transformations - Add new clients: lineage, dgc_relation, dgc_responsibility - Update register.go with all 22 tools --- .github/runs-on.yml | 2 + .github/workflows/release.yaml | 126 +++++-- .goreleaser.yaml | 67 ++++ README.md | 36 +- SKILLS.md | 126 +++++++ docs/CONFIG.md | 2 +- pkg/chip/version.go | 2 +- pkg/clients/dgc_relation_client.go | 213 +++++++++++ pkg/clients/dgc_responsibility_client.go | 143 +++++++ pkg/clients/lineage_client.go | 340 +++++++++++++++++ pkg/clients/lineage_client_test.go | 352 ++++++++++++++++++ .../add_data_classification_match/tool.go | 2 +- .../tool_test.go | 22 +- .../tool.go | 6 +- .../tool_test.go | 6 +- .../{ask_dad => discover_data_assets}/tool.go | 6 +- .../tool_test.go | 6 +- pkg/tools/get_asset_details/tool.go | 136 +++++-- pkg/tools/get_asset_details/tool_test.go | 164 +++++++- pkg/tools/get_business_term_data/tool.go | 113 ++++++ pkg/tools/get_column_semantics/tool.go | 103 +++++ pkg/tools/get_lineage_downstream/tool.go | 49 +++ pkg/tools/get_lineage_downstream/tool_test.go | 104 ++++++ pkg/tools/get_lineage_entity/tool.go | 37 ++ pkg/tools/get_lineage_entity/tool_test.go | 94 +++++ pkg/tools/get_lineage_transformation/tool.go | 37 ++ .../get_lineage_transformation/tool_test.go | 94 +++++ pkg/tools/get_lineage_upstream/tool.go | 50 +++ pkg/tools/get_lineage_upstream/tool_test.go | 104 ++++++ pkg/tools/get_measure_data/tool.go | 106 ++++++ pkg/tools/get_table_semantics/tool.go | 115 ++++++ pkg/tools/list_asset_types/tool.go | 2 +- pkg/tools/list_asset_types/tool_test.go | 4 +- pkg/tools/list_data_contracts/tool.go | 2 +- pkg/tools/list_data_contracts/tool_test.go | 6 +- pkg/tools/pull_data_contract_manifest/tool.go | 2 +- .../pull_data_contract_manifest/tool_test.go | 8 +- pkg/tools/push_data_contract_manifest/tool.go | 2 +- .../push_data_contract_manifest/tool_test.go | 10 +- pkg/tools/register.go | 36 +- .../remove_data_classification_match/tool.go | 2 +- .../tool_test.go | 18 +- .../tool.go | 4 +- .../tool_test.go | 6 +- pkg/tools/search_data_classes/tool.go | 2 +- pkg/tools/search_data_classes/tool_test.go | 4 +- .../tool.go | 4 +- .../tool_test.go | 6 +- pkg/tools/search_lineage_entities/tool.go | 37 ++ .../search_lineage_entities/tool_test.go | 82 ++++ .../search_lineage_transformations/tool.go | 35 ++ .../tool_test.go | 78 ++++ pkg/tools/testutil/testutil.go | 2 + 53 files changed, 2963 insertions(+), 152 deletions(-) create mode 100644 .github/runs-on.yml create mode 100644 .goreleaser.yaml create mode 100644 SKILLS.md create mode 100644 pkg/clients/dgc_relation_client.go create mode 100644 pkg/clients/dgc_responsibility_client.go create mode 100644 pkg/clients/lineage_client.go create mode 100644 pkg/clients/lineage_client_test.go rename pkg/tools/{ask_glossary => discover_business_glossary}/tool.go (75%) rename pkg/tools/{ask_glossary => discover_business_glossary}/tool_test.go (84%) rename pkg/tools/{ask_dad => discover_data_assets}/tool.go (77%) rename pkg/tools/{ask_dad => discover_data_assets}/tool_test.go (85%) create mode 100644 pkg/tools/get_business_term_data/tool.go create mode 100644 pkg/tools/get_column_semantics/tool.go create mode 100644 pkg/tools/get_lineage_downstream/tool.go create mode 100644 pkg/tools/get_lineage_downstream/tool_test.go create mode 100644 pkg/tools/get_lineage_entity/tool.go create mode 100644 pkg/tools/get_lineage_entity/tool_test.go create mode 100644 pkg/tools/get_lineage_transformation/tool.go create mode 100644 pkg/tools/get_lineage_transformation/tool_test.go create mode 100644 pkg/tools/get_lineage_upstream/tool.go create mode 100644 pkg/tools/get_lineage_upstream/tool_test.go create mode 100644 pkg/tools/get_measure_data/tool.go create mode 100644 pkg/tools/get_table_semantics/tool.go rename pkg/tools/{keyword_search => search_asset_keyword}/tool.go (98%) rename pkg/tools/{keyword_search => search_asset_keyword}/tool_test.go (86%) rename pkg/tools/{find_data_classification_matches => search_data_classification_matches}/tool.go (97%) rename pkg/tools/{find_data_classification_matches => search_data_classification_matches}/tool_test.go (87%) create mode 100644 pkg/tools/search_lineage_entities/tool.go create mode 100644 pkg/tools/search_lineage_entities/tool_test.go create mode 100644 pkg/tools/search_lineage_transformations/tool.go create mode 100644 pkg/tools/search_lineage_transformations/tool_test.go diff --git a/.github/runs-on.yml b/.github/runs-on.yml new file mode 100644 index 0000000..01cb22e --- /dev/null +++ b/.github/runs-on.yml @@ -0,0 +1,2 @@ +_extends: .github-private + diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 420abac..2ee8b02 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,22 +11,21 @@ jobs: uses: ./.github/workflows/build.yaml release: needs: build - runs-on: ubuntu-latest - env: - BUILD_DIR: 'build' + runs-on: + - runs-on + - run-id=${{ github.run_id }} + - runner=md + - env=production-eu + - tag=build-${{ github.event.repository.name }} + environment: Release permissions: contents: write steps: - - uses: actions/checkout@v5 + - name: Checkout + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Set version - run: | - VERSION=${{ github.ref_name }} - VERSION=${VERSION#v} - echo "VERSION=$VERSION" >> $GITHUB_ENV - - name: Setup Go uses: actions/setup-go@v6 with: @@ -35,29 +34,94 @@ jobs: - name: Install dependencies run: go mod download + - name: Set version + run: | + VERSION=${{ github.ref_name }} + VERSION=${VERSION#v} + echo "VERSION=$VERSION" >> $GITHUB_ENV + - name: Test + if: ${{ !contains(env.VERSION, '-') }} # Skip tests for pre-release versions (e.g., 1.0.0-beta) to avoid issues with version parsing in tests run: go test --tags release -run TestReleaseVersionCheck -v ./... - - name: Build + - name: Setup Java 17 + run: | + mkdir -p /tmp/chip-signing + pushd /tmp/chip-signing + wget -q https://corretto.aws/downloads/latest/amazon-corretto-17-x64-linux-jdk.tar.gz + tar -xzf amazon-corretto-17-x64-linux-jdk.tar.gz + JAVA_DIR=$(find . -maxdepth 1 -type d -name "amazon-corretto-*" -print -quit | sed 's|^\./||') + echo "$PWD/$JAVA_DIR/bin" >> $GITHUB_PATH + echo "Java 17 installed: $JAVA_DIR" + popd + + - name: Download JSign run: | - GOFIPS140=v1.0.0 GOOS=linux GOARCH=amd64 go build -ldflags="-X 'github.com/collibra/chip/pkg/chip.Version=${{ env.VERSION }}'" -o ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-linux-amd64 ./cmd/chip - GOFIPS140=v1.0.0 GOOS=linux GOARCH=arm64 go build -ldflags="-X 'github.com/collibra/chip/pkg/chip.Version=${{ env.VERSION }}'" -o ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-linux-arm64 ./cmd/chip - GOFIPS140=v1.0.0 GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'github.com/collibra/chip/pkg/chip.Version=${{ env.VERSION }}'" -o ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-mac-amd64 ./cmd/chip - GOFIPS140=v1.0.0 GOOS=darwin GOARCH=arm64 go build -ldflags="-X 'github.com/collibra/chip/pkg/chip.Version=${{ env.VERSION }}'" -o ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-mac-arm64 ./cmd/chip - GOFIPS140=v1.0.0 GOOS=windows GOARCH=amd64 go build -ldflags="-X 'github.com/collibra/chip/pkg/chip.Version=${{ env.VERSION }}'" -o ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-windows-amd64.exe ./cmd/chip - GOFIPS140=v1.0.0 GOOS=windows GOARCH=arm64 go build -ldflags="-X 'github.com/collibra/chip/pkg/chip.Version=${{ env.VERSION }}'" -o ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-windows-arm64.exe ./cmd/chip - - - name: Release - uses: softprops/action-gh-release@v2 + mkdir -p /tmp/chip-signing + wget -q https://github.com/ebourg/jsign/releases/download/7.4/jsign-7.4.jar -O /tmp/chip-signing/jsign.jar + echo "JSIGN_JAR_PATH=/tmp/chip-signing/jsign.jar" >> $GITHUB_ENV + echo "JSign downloaded successfully" + + - name: Create certificate chain file + run: | + mkdir -p /tmp/chip-signing + echo "${{ secrets.CODE_SIGNING_CERTIFICATE_CHAIN }}" > /tmp/chip-signing/signing_chain.pem + if [ ! -s /tmp/chip-signing/signing_chain.pem ]; then + echo "ERROR: CODE_SIGNING_CERTIFICATE_CHAIN secret is empty or not set" + exit 1 + fi + echo "CODE_SIGNING_CERT_CHAIN_FILE=/tmp/chip-signing/signing_chain.pem" >> $GITHUB_ENV + echo "Certificate chain file created" + + # RunsOn workers have the CodeSigningPolicy attached, which grants + # access to the KMS signing key via EC2 instance metadata (IMDSv2). + - name: Configure AWS credentials + run: | + TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") + ROLE_NAME=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/meta-data/iam/security-credentials/) + CREDENTIALS=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE_NAME) + + ACCESS_KEY=$(echo $CREDENTIALS | jq -r .AccessKeyId) + SECRET_KEY=$(echo $CREDENTIALS | jq -r .SecretAccessKey) + SESSION_TOKEN=$(echo $CREDENTIALS | jq -r .Token) + + mkdir -p ~/.aws + echo "[default]" > ~/.aws/credentials + echo "aws_access_key_id = ${ACCESS_KEY}" >> ~/.aws/credentials + echo "aws_secret_access_key = ${SECRET_KEY}" >> ~/.aws/credentials + echo "aws_session_token = ${SESSION_TOKEN}" >> ~/.aws/credentials + + echo "[default]" > ~/.aws/config + echo "region = ${{ vars.CODE_SIGNING_AWS_REGION || 'eu-west-1' }}" >> ~/.aws/config + + echo "AWS credentials configured successfully" + + - name: Set signing environment variables + run: | + echo "CODE_SIGNING_AWS_REGION=${{ vars.CODE_SIGNING_AWS_REGION || 'eu-west-1' }}" >> $GITHUB_ENV + if [ -z "${{ secrets.KMS_SIGNING_KEY_ARN }}" ]; then + echo "ERROR: KMS_SIGNING_KEY_ARN secret is not set" + exit 1 + fi + echo "KMS_SIGNING_KEY_ARN=${{ secrets.KMS_SIGNING_KEY_ARN }}" >> $GITHUB_ENV + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 with: - files: | - ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-linux-amd64 - ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-linux-arm64 - ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-mac-amd64 - ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-mac-arm64 - ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-windows-amd64.exe - ${{ env.BUILD_DIR }}/chip-${{ env.VERSION }}-windows-arm64.exe - generate_release_notes: true - make_latest: true - draft: false - prerelease: false + distribution: goreleaser + version: latest + args: release --clean --verbose + env: + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JSIGN_JAR_PATH: ${{ env.JSIGN_JAR_PATH }} + CODE_SIGNING_CERT_CHAIN_FILE: ${{ env.CODE_SIGNING_CERT_CHAIN_FILE }} + CODE_SIGNING_AWS_REGION: ${{ env.CODE_SIGNING_AWS_REGION }} + KMS_SIGNING_KEY_ARN: ${{ env.KMS_SIGNING_KEY_ARN }} + + - name: Cleanup + if: always() + run: | + rm -rf /tmp/chip-signing ~/.aws + echo "Cleanup completed" + diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..cb8ed72 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,67 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: chip +dist: ./build/dist + +builds: + - id: default + main: ./cmd/chip + env: + - CGO_ENABLED=0 + - GOFIPS140=v1.0.0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + binary: chip + ldflags: + - -X github.com/collibra/chip/pkg/chip.Version={{.Version}} + # Sign Windows binaries using AWS KMS and JSign (the signature is embedded in the binary) + hooks: + post: + - > + bash -c ' + if [ -n "${SKIP_SIGNING}" ]; then + echo "Skipping signing Windows binaries (SKIP_SIGNING is set)"; + exit 0; + fi; + if [ "{{ .Os }}" = "windows" ]; then + echo "Signing Windows binary {{ .Path }}"; + if [ ! -f "{{ .Path }}" ]; then + echo "ERROR Binary file does not exist: {{ .Path }}"; + exit 1; + fi; + java -jar "${JSIGN_JAR_PATH}" --storetype AWS --keystore "${CODE_SIGNING_AWS_REGION}" --alias "${KMS_SIGNING_KEY_ARN}" --certfile "${CODE_SIGNING_CERT_CHAIN_FILE}" --tsaurl http://timestamp.digicert.com "{{ .Path }}" || { + echo "ERROR Failed to sign {{ .Path }}"; + exit 1; + }; + if [ ! -f "{{ .Path }}" ]; then + echo "ERROR Binary file disappeared after signing {{ .Path }}"; + exit 1; + fi; + echo "✓ Signed {{ .Path }}"; + else + echo "Skipping non-Windows binary ({{ .Os }}) {{ .Path }}"; + fi + ' + +archives: + - id: default + formats: ["binary"] + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ if eq .Os "darwin" }}mac{{ else }}{{ .Os }}{{ end }}-{{ .Arch }}' + +checksum: + name_template: 'checksums.txt' + +release: + draft: false + prerelease: auto + make_latest: legacy + +changelog: + use: github-native + diff --git a/README.md b/README.md index 0f4e66f..3ed745e 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,28 @@ A Model Context Protocol (MCP) server that provides AI agents with access to Col This Go-based MCP server acts as a bridge between AI applications and Collibra, enabling intelligent data discovery and governance operations through the following tools: -- [`asset_details_get`](pkg/tools/get_asset_details.go) - Retrieve detailed information about specific assets by UUID -- [`asset_keyword_search`](pkg/tools/keyword_search.go) - Wildcard keyword search for assets -- [`asset_types_list`](pkg/tools/list_asset_types.go) - List available asset types -- [`business_glossary_discover`](pkg/tools/ask_glossary.go) - Ask questions about terms and definitions -- [`data_classification_match_add`](pkg/tools/add_data_classification_match.go) - Associate a data class with an asset -- [`data_classification_match_remove`](pkg/tools/remove_data_classification_match.go) - Remove a classification match -- [`data_classification_match_search`](pkg/tools/find_data_classification_matches.go) - Find associations between data classes and assets -- [`data_assets_discover`](pkg/tools/ask_dad.go) - Query available data assets using natural language -- [`data_class_search`](pkg/tools/search_data_classes.go) - Search for data classes with filters -- [`data_contract_list`](pkg/tools/list_data_contracts.go) - List data contracts with pagination -- [`data_contract_manifest_pull`](pkg/tools/pull_data_contract_manifest.go) - Download manifest for a data contract -- [`data_contract_manifest_push`](pkg/tools/push_data_contract_manifest.go) - Upload manifest for a data contract +- [`add_data_classification_match`](pkg/tools/add_data_classification_match.go) - Associate a data class with an asset +- [`discover_business_glossary`](pkg/tools/discover_business_glossary.go) - Ask questions about terms and definitions +- [`discover_data_assets`](pkg/tools/discover_data_assets.go) - Query available data assets using natural language +- [`get_asset_details`](pkg/tools/get_asset_details.go) - Retrieve detailed information about specific assets by UUID +- [`get_business_term_data`](pkg/tools/get_business_term_data.go) - Trace a business term back to its connected physical data assets +- [`get_column_semantics`](pkg/tools/get_column_semantics.go) - Retrieve data attributes, measures, and business assets connected to a column +- [`get_measure_data`](pkg/tools/get_measure_data.go) - Trace a measure back to its underlying physical columns and tables +- [`get_table_semantics`](pkg/tools/get_table_semantics.go) - Retrieve the semantic layer for a table: columns, data attributes, and connected measures +- [`list_asset_types`](pkg/tools/list_asset_types.go) - List available asset types +- [`list_data_contract`](pkg/tools/list_data_contracts.go) - List data contracts with pagination +- [`pull_data_contract_manifest`](pkg/tools/pull_data_contract_manifest.go) - Download manifest for a data contract +- [`push_data_contract_manifest`](pkg/tools/push_data_contract_manifest.go) - Upload manifest for a data contract +- [`removedata_classification_match`](pkg/tools/remove_data_classification_match.go) - Remove a classification match +- [`search_asset_keyword`](pkg/tools/search_asset_keyword.go) - Wildcard keyword search for assets +- [`search_data_class`](pkg/tools/search_data_classes.go) - Search for data classes with filters +- [`search_data_classification_match`](pkg/tools/search_data_classification_matches.go) - Search for associations between data classes and assets +- [`get_lineage_entity`](pkg/tools/get_lineage_entity.go) - Get metadata about a specific entity in the technical lineage graph +- [`get_lineage_upstream`](pkg/tools/get_lineage_upstream.go) - Get upstream technical lineage (sources) for a data entity +- [`get_lineage_downstream`](pkg/tools/get_lineage_downstream.go) - Get downstream technical lineage (consumers) for a data entity +- [`search_lineage_entities`](pkg/tools/search_lineage_entities.go) - Search for entities in the technical lineage graph +- [`get_lineage_transformation`](pkg/tools/get_lineage_transformation.go) - Get details and logic of a specific data transformation +- [`search_lineage_transformations`](pkg/tools/search_lineage_transformations.go) - Search for transformations in the technical lineage graph ## Quick Start @@ -162,7 +172,7 @@ Here's how to integrate with some popular clients assuming you have a configurat ## Enabling or disabling specific tools You can enable or disable specific tools by passing command line parameters, setting environment variables, or customizing the `mcp.yaml` configuration file. -You can specify tools to enable or disable by using the tool names listed above (e.g. `asset_details_get`). For more information, see the [CONFIG.md](docs/CONFIG.md) documentation. +You can specify tools to enable or disable by using the tool names listed above (e.g. `get_asset_details`). For more information, see the [CONFIG.md](docs/CONFIG.md) documentation. By default, all tools are enabled. Specifying tools to be enabled will enable *only* those tools. Disabling tools will disable *only* those tools and leave all others enabled. At present, enabling and disabling at the same time is not supported. diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..e4cc2c4 --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,126 @@ +# SKILLS.md + +This file describes the MCP tools available in this server and how Claude agents should use them effectively. + +## What is Collibra? + +Collibra is a data governance platform — a central catalog where an organization documents, classifies, and governs its data assets. It is the authoritative source for: + +- **What data exists**: tables, columns, datasets, reports, APIs, and other data assets across the organization +- **What data means**: a rich business glossary of terms, acronyms, KPIs, and definitions that captures how the business interprets and communicates about data — the authoritative place to resolve ambiguity around business language +- **How data relates**: lineage between physical columns, semantic data attributes, business terms, and measures +- **Who owns and trusts it**: stewards, data contracts, classifications, and quality rules + +Reach for Collibra tools when the user's question is about **understanding, discovering, or governing data in the organization** — e.g. "what customer data do we have?", "what does this metric measure?", "which columns contain PII?", or "where does this KPI come from?". These tools are not appropriate for querying the actual data values in a database; they operate on the metadata and governance layer above the data. + +## Tool Inventory + +### Discovery & Search + +**`discover_data_assets`** — Natural language semantic search over data assets (tables, columns, datasets). Use when the user asks open-ended questions like "what data do we have about customers?". Requires `dgc.ai-copilot` permission. + +**`discover_business_glossary`** — Natural language semantic search over the business glossary (terms, acronyms, KPIs, definitions). Use when the user asks about the meaning of a business concept. Requires `dgc.ai-copilot` permission. + +**`search_asset_keyword`** — Wildcard keyword search. Returns names, IDs, and metadata but not full asset details. Use this to find an asset's UUID when you only know its name. Supports filtering by resource type, community, domain, asset type, status, and creator. Paginated via `limit`/`offset`. + +**`list_asset_types`** — List all asset type names and UUIDs. Use this when you need a type UUID to filter `search_asset_keyword` results. + +### Asset Details + +**`get_asset_details`** — Retrieve full details for a single asset by UUID: attributes, relations, and metadata. Returns a direct link to the asset in the Collibra UI. Relations are paginated (50 per page); use `outgoingRelationsCursor` and `incomingRelationsCursor` from the previous response to page through them. + +### Semantic Graph Traversal + +These tools walk the Collibra asset relation graph to answer lineage and semantic questions. All require asset UUIDs as input. + +**`get_column_semantics`** — Given a column UUID, returns all connected Data Attributes with their descriptions, linked Measures, and generic business assets. Use to answer "what does this column mean semantically?". + +**`get_table_semantics`** — Given a table UUID, returns all columns with their Data Attributes and connected Measures. Use to answer "what metrics use data from this table?" or "what is the semantic context of this table?". + +**`get_measure_data`** — Given a measure UUID, traces backward through Data Attributes to the underlying Columns and their parent Tables. Use to answer "what physical data feeds this metric?". + +**`get_business_term_data`** — Given a business term UUID, traces through Data Attributes to connected Columns and Tables. Use to answer "what physical data is associated with this business term?". + +### Data Classification + +**`search_data_class`** — Search for data classes by name or description. Use this to find a classification UUID before applying it to an asset. Requires `dgc.data-classes-read` permission. + +**`search_data_classification_match`** — Search existing classification matches (associations between data classes and assets). Filter by asset IDs, classification IDs, or status (`ACCEPTED`, `REJECTED`, `SUGGESTED`). Requires `dgc.classify` + `dgc.catalog`. + +**`add_data_classification_match`** — Apply a data class to an asset. Requires both the asset UUID and classification UUID. Requires `dgc.classify` + `dgc.catalog`. + +**`remove_data_classification_match`** — Remove a classification match. Requires `dgc.classify` + `dgc.catalog`. + +### Technical Lineage + +These tools query the technical lineage graph — a map of all data objects and transformations across external systems, including unregistered assets, temporary tables, and source code. Unlike business lineage (which only covers assets in the Collibra Data Catalog), technical lineage covers the full physical data flow. + +**`search_lineage_entities`** — Search for data entities in the technical lineage graph by name, type, or DGC UUID. Use this as a starting point when you don't have an entity ID. Supports partial name matching and type filtering (e.g. `table`, `column`, `report`). Paginated. + +**`get_lineage_entity`** — Get full metadata for a specific lineage entity by ID: name, type, source systems, parent entity, and linked DGC identifier. Use after obtaining an entity ID from a search or lineage traversal. + +**`get_lineage_upstream`** — Get all upstream entities (sources) for a data entity, along with the transformations connecting them. Use to answer "where does this data come from?". Paginated. + +**`get_lineage_downstream`** — Get all downstream entities (consumers) for a data entity, along with the transformations connecting them. Use to answer "what depends on this data?" or "what is impacted if this changes?". Paginated. + +**`search_lineage_transformations`** — Search for transformations by name. Returns lightweight summaries. Use to discover ETL jobs or SQL queries by name. + +**`get_lineage_transformation`** — Get the full details of a transformation, including its SQL or script logic. Use after finding a transformation ID in an upstream/downstream result or search. + +### Data Contracts + +**`list_data_contract`** — List data contracts with cursor-based pagination. Filter by `manifestId`. Use this to find a contract's UUID. + +**`pull_data_contract_manifest`** — Download the manifest for a data contract by UUID. + +**`push_data_contract_manifest`** — Upload/update a manifest for a data contract by UUID. + +--- + +## Common Workflows + +### Find an asset and get its details +1. `search_asset_keyword` with the asset name → get UUID from results +2. `get_asset_details` with the UUID → get full attributes and relations + +### Classify a column +1. `search_asset_keyword` to find the column UUID +2. `search_data_class` to find the data class UUID +3. `add_data_classification_match` with both UUIDs + +### Understand what a table means +1. `search_asset_keyword` to find the table UUID +2. `get_table_semantics` → columns → data attributes → measures + +### Trace a metric to its source data +1. `search_asset_keyword` to find the measure UUID +2. `get_measure_data` → data attributes → columns → tables + +### Trace a business term to physical data +1. `search_asset_keyword` to find the business term UUID +2. `get_business_term_data` → data attributes → columns → tables + +### Trace upstream lineage for a data asset +1. `search_lineage_entities` with the asset name → get entity ID +2. `get_lineage_upstream` → relations with source entity IDs and transformation IDs +3. `get_lineage_entity` for any source entity to get its details +4. `get_lineage_transformation` for any transformation ID to see the logic + +### Perform impact analysis (downstream) +1. `search_lineage_entities` with the asset name → get entity ID +2. `get_lineage_downstream` → relations with consumer entity IDs +3. Follow up with `get_lineage_entity` for specific consumers as needed + +### Manage a data contract +1. `list_data_contract` to find the contract UUID +2. `pull_data_contract_manifest` to download, edit, then `push_data_contract_manifest` to update + +--- + +## Tips + +- **UUIDs are required for most tools.** When you only have a name, start with `search_asset_keyword` or the natural language discovery tools to get the UUID first. +- **`discover_data_assets` vs `search_asset_keyword`**: Prefer `discover_data_assets` for open-ended semantic questions; prefer `search_asset_keyword` when you know the exact name or need to filter by type/community/domain. +- **Permissions**: `discover_data_assets` and `discover_business_glossary` require the `dgc.ai-copilot` permission. Classification tools require `dgc.classify` + `dgc.catalog`. If a tool fails with a permission error, let the user know which permission is needed. +- **Pagination**: `search_asset_keyword`, `list_asset_types`, `search_data_class`, and `search_data_classification_match` use `limit`/`offset`. `list_data_contract` and `get_asset_details` (for relations) use cursor-based pagination — carry the cursor from the previous response. Lineage tools (`search_lineage_entities`, `get_lineage_upstream`, `get_lineage_downstream`, `search_lineage_transformations`) also use cursor-based pagination. +- **Error handling**: Validation errors are returned in the output `error` field (not as Go errors), so always check `error` and `success`/`found` fields in the response before using the data. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 44ba25c..c8a5c7d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -134,7 +134,7 @@ export COLLIBRA_MCP_API_PROXY="http://proxy.example.com:8080" # Or use HTTP_PROX export COLLIBRA_MCP_API_URL="https://your-instance.collibra.com" export COLLIBRA_MCP_API_USR="your-username" export COLLIBRA_MCP_API_PWD="your-password" -export COLLIBRA_MCP_ENABLED_TOOLS="asset_keyword_search,asset_details_get,asset_types_list" +export COLLIBRA_MCP_ENABLED_TOOLS="search_asset_keyword,get_asset_details,list_asset_types" ./mcp-server ``` diff --git a/pkg/chip/version.go b/pkg/chip/version.go index 863ef47..0087ed9 100644 --- a/pkg/chip/version.go +++ b/pkg/chip/version.go @@ -1,3 +1,3 @@ package chip -var Version = "0.0.24-SNAPSHOT" +var Version = "0.0.28-SNAPSHOT" diff --git a/pkg/clients/dgc_relation_client.go b/pkg/clients/dgc_relation_client.go new file mode 100644 index 0000000..747cd21 --- /dev/null +++ b/pkg/clients/dgc_relation_client.go @@ -0,0 +1,213 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" +) + +// 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" +) + +type RelationsQueryParams struct { + SourceID string `url:"sourceId,omitempty"` + TargetID string `url:"targetId,omitempty"` + RelationTypeID string `url:"relationTypeId,omitempty"` + Limit int `url:"limit"` +} + +type RelationsResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Results []Relation `json:"results"` +} + +type Relation struct { + ID string `json:"id"` + Source RelationAsset `json:"source"` + Target RelationAsset `json:"target"` +} + +type RelationAsset struct { + ID string `json:"id"` + Name string `json:"name"` + TypeName string `json:"typeName"` +} + +type AttributesQueryParams struct { + AssetID string `url:"assetId,omitempty"` + AttributeTypeID string `url:"attributeTypeId,omitempty"` +} + +type AttributesResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Results []AttributeResult `json:"results"` +} + +type AttributeResult struct { + ID string `json:"id"` + Value string `json:"value"` +} + +type ConnectedAsset struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` +} + +// GetRelations queries the Collibra relations API. +func GetRelations(ctx context.Context, client *http.Client, params RelationsQueryParams) (*RelationsResponse, error) { + endpoint, err := buildUrl("/rest/2.0/relations", params) + if err != nil { + return nil, fmt.Errorf("failed to build relations endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create relations request: %w", err) + } + + body, err := executeRequest(client, req) + if err != nil { + return nil, err + } + + var response RelationsResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse relations response: %w", err) + } + return &response, nil +} + +// GetAssetAttributes queries the Collibra attributes API for a specific asset and attribute type. +func GetAssetAttributes(ctx context.Context, client *http.Client, assetID string, attrTypeID string) (*AttributesResponse, error) { + params := AttributesQueryParams{ + AssetID: assetID, + AttributeTypeID: attrTypeID, + } + + endpoint, err := buildUrl("/rest/2.0/attributes", params) + if err != nil { + return nil, fmt.Errorf("failed to build attributes endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create attributes request: %w", err) + } + + body, err := executeRequest(client, req) + if err != nil { + return nil, err + } + + var response AttributesResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse attributes response: %w", err) + } + return &response, nil +} + +// FindConnectedAssets finds assets connected to assetID via relationTypeID, querying both directions. +func FindConnectedAssets(ctx context.Context, client *http.Client, assetID string, relationTypeID string) ([]ConnectedAsset, error) { + sourceResp, err := GetRelations(ctx, client, RelationsQueryParams{ + SourceID: assetID, + RelationTypeID: relationTypeID, + Limit: 0, + }) + if err != nil { + return nil, fmt.Errorf("failed to get relations as source: %w", err) + } + + targetResp, err := GetRelations(ctx, client, RelationsQueryParams{ + TargetID: assetID, + RelationTypeID: relationTypeID, + Limit: 0, + }) + if err != nil { + return nil, fmt.Errorf("failed to get relations as target: %w", err) + } + + allRelations := append(sourceResp.Results, targetResp.Results...) + seen := make(map[string]struct{}) + result := make([]ConnectedAsset, 0) + + for _, rel := range allRelations { + opposite := oppositeAsset(rel, assetID) + if opposite.ID == "" { + continue + } + if _, exists := seen[opposite.ID]; exists { + continue + } + seen[opposite.ID] = struct{}{} + result = append(result, opposite) + } + + return result, nil +} + +// FindColumnsForDataAttribute finds assets connected via both data attribute relation types. +func FindColumnsForDataAttribute(ctx context.Context, client *http.Client, dataAttributeID string) ([]ConnectedAsset, error) { + seen := make(map[string]struct{}) + result := make([]ConnectedAsset, 0) + + for _, relID := range []string{DataAttributeRelID1, DataAttributeRelID2} { + assets, err := FindConnectedAssets(ctx, client, dataAttributeID, relID) + if err != nil { + return nil, err + } + for _, asset := range assets { + if _, exists := seen[asset.ID]; exists { + continue + } + seen[asset.ID] = struct{}{} + result = append(result, asset) + } + } + + return result, nil +} + +// FetchDescription retrieves the definition/description attribute for an asset. +func FetchDescription(ctx context.Context, client *http.Client, assetID string) string { + resp, err := GetAssetAttributes(ctx, client, assetID, DefinitionAttributeTypeID) + if err != nil { + slog.InfoContext(ctx, fmt.Sprintf("Failed to fetch description for asset %s: %v", assetID, err)) + return "No description available." + } + if len(resp.Results) > 0 && resp.Results[0].Value != "" { + return resp.Results[0].Value + } + return "No description available." +} + +func oppositeAsset(rel Relation, assetID string) ConnectedAsset { + if rel.Source.ID == assetID { + return ConnectedAsset{ + ID: rel.Target.ID, + Name: rel.Target.Name, + AssetType: rel.Target.TypeName, + } + } + if rel.Target.ID == assetID { + return ConnectedAsset{ + ID: rel.Source.ID, + Name: rel.Source.Name, + AssetType: rel.Source.TypeName, + } + } + return ConnectedAsset{} +} diff --git a/pkg/clients/dgc_responsibility_client.go b/pkg/clients/dgc_responsibility_client.go new file mode 100644 index 0000000..a35a174 --- /dev/null +++ b/pkg/clients/dgc_responsibility_client.go @@ -0,0 +1,143 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" +) + +const errFailedToCreateRequest = "failed to create request: %w" + +// Responsibility represents a single responsibility assignment for an asset. +type Responsibility struct { + ID string `json:"id"` + Role *ResourceRole `json:"role,omitempty"` + Owner *ResourceRef `json:"owner,omitempty"` + BaseResource *ResourceRef `json:"baseResource,omitempty"` + System bool `json:"system"` +} + +// ResourceRole represents the role in a responsibility (e.g., Owner, Steward). +type ResourceRole struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ResourceRef represents a reference to a resource (user, group, community, etc.) in the API. +type ResourceRef struct { + ID string `json:"id"` + ResourceDiscriminator string `json:"resourceDiscriminator"` +} + +// ResponsibilityPagedResponse represents the paginated response from the responsibilities API. +type ResponsibilityPagedResponse struct { + Total int64 `json:"total"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` + Results []Responsibility `json:"results"` +} + +// ResponsibilityQueryParams defines the query parameters for the responsibilities API. +type ResponsibilityQueryParams struct { + ResourceIDs string `url:"resourceIds,omitempty"` + IncludeInherited bool `url:"includeInherited,omitempty"` + Limit int `url:"limit,omitempty"` + Offset int `url:"offset,omitempty"` +} + +// UserResponse represents the response from the /rest/2.0/users/{userId} endpoint. +type UserResponse struct { + ID string `json:"id"` + UserName string `json:"userName"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` +} + +// UserGroupResponse represents the response from the /rest/2.0/userGroups/{groupId} endpoint. +type UserGroupResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// GetResponsibilities fetches all responsibilities for the given asset ID, including inherited ones. +func GetResponsibilities(ctx context.Context, collibraHttpClient *http.Client, assetID string) ([]Responsibility, error) { + slog.InfoContext(ctx, fmt.Sprintf("Fetching responsibilities for asset: %s", assetID)) + + params := ResponsibilityQueryParams{ + ResourceIDs: assetID, + IncludeInherited: true, + Limit: 100, + Offset: 0, + } + + endpoint, err := buildUrl("/rest/2.0/responsibilities", params) + if err != nil { + return nil, fmt.Errorf("failed to build endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf(errFailedToCreateRequest, err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return nil, err + } + + var response ResponsibilityPagedResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse responsibilities response: %w", err) + } + + return response.Results, nil +} + +// GetUserName fetches the display name for a user by ID. +func GetUserName(ctx context.Context, collibraHttpClient *http.Client, userID string) (string, error) { + endpoint := fmt.Sprintf("/rest/2.0/users/%s", userID) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return "", fmt.Errorf(errFailedToCreateRequest, err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return "", err + } + + var user UserResponse + if err := json.Unmarshal(body, &user); err != nil { + return "", fmt.Errorf("failed to parse user response: %w", err) + } + + if user.FirstName != "" || user.LastName != "" { + return fmt.Sprintf("%s %s (%s)", user.FirstName, user.LastName, user.UserName), nil + } + return user.UserName, nil +} + +// GetUserGroupName fetches the name for a user group by ID. +func GetUserGroupName(ctx context.Context, collibraHttpClient *http.Client, groupID string) (string, error) { + endpoint := fmt.Sprintf("/rest/2.0/userGroups/%s", groupID) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return "", fmt.Errorf(errFailedToCreateRequest, err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return "", err + } + + var group UserGroupResponse + if err := json.Unmarshal(body, &group); err != nil { + return "", fmt.Errorf("failed to parse user group response: %w", err) + } + + return group.Name, nil +} diff --git a/pkg/clients/lineage_client.go b/pkg/clients/lineage_client.go new file mode 100644 index 0000000..c7f7d9c --- /dev/null +++ b/pkg/clients/lineage_client.go @@ -0,0 +1,340 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type LineageEntity struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + SourceIds []string `json:"sourceIds,omitempty"` + DgcId string `json:"dgcId,omitempty"` + ParentId string `json:"parentId,omitempty"` +} + +// UnmarshalJSON handles both plain string values and JsonNullable-wrapped objects +// for the DgcId and ParentId fields. The server may serialize JsonNullable as +// {"present": false, "undefined": true} when JsonNullableModule is not on the classpath. +func (e *LineageEntity) UnmarshalJSON(data []byte) error { + type lineageEntityAlias LineageEntity + var raw struct { + lineageEntityAlias + DgcId json.RawMessage `json:"dgcId"` + ParentId json.RawMessage `json:"parentId"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *e = LineageEntity(raw.lineageEntityAlias) + e.DgcId = extractJsonNullableString(raw.DgcId) + e.ParentId = extractJsonNullableString(raw.ParentId) + return nil +} + +// extractJsonNullableString extracts a string from either a plain JSON string +// or a JsonNullable object. Returns empty string for null, undefined, or objects +// where the value is not recoverable. +func extractJsonNullableString(data json.RawMessage) string { + if len(data) == 0 || string(data) == "null" { + return "" + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + return s + } + // JsonNullable object format — actual value is not serialized without the module + return "" +} + +type LineageRelation struct { + SourceEntityId string `json:"sourceEntityId"` + TargetEntityId string `json:"targetEntityId"` + TransformationIds []string `json:"transformationIds"` +} + +type LineagePagination struct { + NextCursor string `json:"nextCursor,omitempty"` +} + +type LineageResponseWarning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type LineageTransformation struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TransformationLogic string `json:"transformationLogic,omitempty"` +} + +type TransformationSummary struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// --- API response types --- + +type lineageUpstreamDownstreamResponse struct { + Relations []LineageRelation `json:"relations"` + NextCursor string `json:"nextCursor,omitempty"` + Warnings []LineageResponseWarning `json:"warnings,omitempty"` +} + +type lineageEntitiesResponse struct { + Results []LineageEntity `json:"results"` + NextCursor string `json:"nextCursor,omitempty"` + Warnings []LineageResponseWarning `json:"warnings,omitempty"` +} + +type lineageTransformationsResponse struct { + Results []TransformationSummary `json:"results"` + NextCursor string `json:"nextCursor,omitempty"` + Warnings []LineageResponseWarning `json:"warnings,omitempty"` +} + +// --- Output types --- + +type GetLineageEntityOutput struct { + Entity *LineageEntity `json:"entity,omitempty"` + Error string `json:"error,omitempty"` + Found bool `json:"found"` +} + +type GetLineageDirectionalOutput struct { + EntityId string `json:"entityId"` + Direction LineageDirection `json:"direction"` + Relations []LineageRelation `json:"relations"` + Pagination *LineagePagination `json:"pagination,omitempty"` + Warnings []LineageResponseWarning `json:"warnings,omitempty"` + Error string `json:"error,omitempty"` +} + +type SearchLineageEntitiesOutput struct { + Results []LineageEntity `json:"results"` + Pagination *LineagePagination `json:"pagination,omitempty"` + Warnings []LineageResponseWarning `json:"warnings,omitempty"` +} + +type GetLineageTransformationOutput struct { + Transformation *LineageTransformation `json:"transformation,omitempty"` + Error string `json:"error,omitempty"` + Found bool `json:"found"` +} + +type SearchLineageTransformationsOutput struct { + Results []TransformationSummary `json:"results"` + Pagination *LineagePagination `json:"pagination,omitempty"` + Warnings []LineageResponseWarning `json:"warnings,omitempty"` +} + +type LineageDirection string + +const ( + LineageDirectionUpstream LineageDirection = "upstream" + LineageDirectionDownstream LineageDirection = "downstream" + + // lineageDGCProxyPath is the path prefix targeting the lineage proxy on DGC + lineageDGCProxyPath = "/technical_lineage_resource" + + // lineageReadAPIPath is the API prefix for the lineage read API (LineageRead.yaml). + lineageReadAPIPath = "/rest/lineageGraphRead/v1" + + lineageAPIBasePath = lineageDGCProxyPath + lineageReadAPIPath +) + +// --- Query param structs --- + +type lineageDirectionalParams struct { + EntityType string `url:"entityType,omitempty"` + Limit int `url:"limit,omitempty"` + Cursor string `url:"cursor,omitempty"` +} + +type lineageSearchEntitiesParams struct { + NameContains string `url:"nameContains,omitempty"` + Type string `url:"type,omitempty"` + DgcId string `url:"dgcId,omitempty"` + Limit int `url:"limit,omitempty"` + Cursor string `url:"cursor,omitempty"` +} + +type lineageSearchTransformationsParams struct { + NameContains string `url:"nameContains,omitempty"` + Limit int `url:"limit,omitempty"` + Cursor string `url:"cursor,omitempty"` +} + +// --- Client functions --- + +func GetLineageEntity(ctx context.Context, collibraHttpClient *http.Client, entityId string) (*GetLineageEntityOutput, error) { + endpoint := fmt.Sprintf("%s/entities/%s", lineageAPIBasePath, entityId) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return &GetLineageEntityOutput{Found: false, Error: err.Error()}, nil + } + + var entity LineageEntity + if err := json.Unmarshal(body, &entity); err != nil { + return nil, fmt.Errorf("failed to parse entity response: %w", err) + } + + return &GetLineageEntityOutput{Entity: &entity, Found: true}, nil +} + +func GetLineageUpstream(ctx context.Context, collibraHttpClient *http.Client, entityId string, entityType string, limit int, cursor string) (*GetLineageDirectionalOutput, error) { + return getLineageDirectional(ctx, collibraHttpClient, entityId, LineageDirectionUpstream, entityType, limit, cursor) +} + +func GetLineageDownstream(ctx context.Context, collibraHttpClient *http.Client, entityId string, entityType string, limit int, cursor string) (*GetLineageDirectionalOutput, error) { + return getLineageDirectional(ctx, collibraHttpClient, entityId, LineageDirectionDownstream, entityType, limit, cursor) +} + +func getLineageDirectional(ctx context.Context, collibraHttpClient *http.Client, entityId string, direction LineageDirection, entityType string, limit int, cursor string) (*GetLineageDirectionalOutput, error) { + basePath := fmt.Sprintf("%s/entities/%s/%s", lineageAPIBasePath, entityId, direction) + + params := lineageDirectionalParams{ + EntityType: entityType, + Limit: limit, + Cursor: cursor, + } + + endpoint, err := buildUrl(basePath, params) + if err != nil { + return nil, fmt.Errorf("failed to build endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return &GetLineageDirectionalOutput{EntityId: entityId, Direction: direction, Relations: []LineageRelation{}, Error: err.Error()}, nil + } + + var resp lineageUpstreamDownstreamResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse %s response: %w", direction, err) + } + + out := &GetLineageDirectionalOutput{ + EntityId: entityId, + Direction: direction, + Relations: resp.Relations, + Warnings: resp.Warnings, + } + if resp.NextCursor != "" { + out.Pagination = &LineagePagination{NextCursor: resp.NextCursor} + } + return out, nil +} + +func SearchLineageEntities(ctx context.Context, collibraHttpClient *http.Client, nameContains string, entityType string, dgcId string, limit int, cursor string) (*SearchLineageEntitiesOutput, error) { + params := lineageSearchEntitiesParams{ + NameContains: nameContains, + Type: entityType, + DgcId: dgcId, + Limit: limit, + Cursor: cursor, + } + + endpoint, err := buildUrl(lineageAPIBasePath+"/entities", params) + if err != nil { + return nil, fmt.Errorf("failed to build endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return nil, err + } + + var resp lineageEntitiesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse entities response: %w", err) + } + + out := &SearchLineageEntitiesOutput{ + Results: resp.Results, + Warnings: resp.Warnings, + } + if resp.NextCursor != "" { + out.Pagination = &LineagePagination{NextCursor: resp.NextCursor} + } + return out, nil +} + +func GetLineageTransformation(ctx context.Context, collibraHttpClient *http.Client, transformationId string) (*GetLineageTransformationOutput, error) { + endpoint := fmt.Sprintf("%s/transformations/%s", lineageAPIBasePath, transformationId) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return &GetLineageTransformationOutput{Found: false, Error: err.Error()}, nil + } + + var t LineageTransformation + if err := json.Unmarshal(body, &t); err != nil { + return nil, fmt.Errorf("failed to parse transformation response: %w", err) + } + + return &GetLineageTransformationOutput{Transformation: &t, Found: true}, nil +} + +func SearchLineageTransformations(ctx context.Context, collibraHttpClient *http.Client, nameContains string, limit int, cursor string) (*SearchLineageTransformationsOutput, error) { + params := lineageSearchTransformationsParams{ + NameContains: nameContains, + Limit: limit, + Cursor: cursor, + } + + endpoint, err := buildUrl(lineageAPIBasePath+"/transformations", params) + if err != nil { + return nil, fmt.Errorf("failed to build endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + body, err := executeRequest(collibraHttpClient, req) + if err != nil { + return nil, err + } + + var resp lineageTransformationsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse transformations response: %w", err) + } + + out := &SearchLineageTransformationsOutput{ + Results: resp.Results, + Warnings: resp.Warnings, + } + if resp.NextCursor != "" { + out.Pagination = &LineagePagination{NextCursor: resp.NextCursor} + } + return out, nil +} diff --git a/pkg/clients/lineage_client_test.go b/pkg/clients/lineage_client_test.go new file mode 100644 index 0000000..5f8c20b --- /dev/null +++ b/pkg/clients/lineage_client_test.go @@ -0,0 +1,352 @@ +package clients + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "path" + "testing" +) + +// redirectClient rewrites requests to hit the test server instead of relative paths. +type redirectClient struct { + baseURL string + next http.RoundTripper +} + +func (c *redirectClient) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + base, _ := url.Parse(c.baseURL) + clone.URL.Scheme = base.Scheme + clone.URL.Host = base.Host + clone.URL.Path = path.Join(base.Path, req.URL.Path) + clone.URL.RawQuery = req.URL.RawQuery + return c.next.RoundTrip(clone) +} + +func newTestClient(server *httptest.Server) *http.Client { + return &http.Client{Transport: &redirectClient{baseURL: server.URL, next: http.DefaultTransport}} +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// --- LineageEntity.UnmarshalJSON --- + +func TestLineageEntityUnmarshalJSON_plainStrings(t *testing.T) { + data := []byte(`{"id":"1","name":"col","type":"column","dgcId":"550e8400-e29b-41d4-a716-446655440000","parentId":"42"}`) + var e LineageEntity + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.DgcId != "550e8400-e29b-41d4-a716-446655440000" { + t.Errorf("expected dgcId string, got %q", e.DgcId) + } + if e.ParentId != "42" { + t.Errorf("expected parentId string, got %q", e.ParentId) + } +} + +func TestLineageEntityUnmarshalJSON_jsonNullableObjects(t *testing.T) { + // Simulates response from server without JsonNullableModule registered. + data := []byte(`{"id":"32","name":"SALESFACT","type":"table","sourceIds":[],"dgcId":{"undefined":true,"present":false},"parentId":{"undefined":false,"present":true}}`) + var e LineageEntity + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.Id != "32" { + t.Errorf("expected id 32, got %q", e.Id) + } + if e.DgcId != "" { + t.Errorf("expected empty dgcId, got %q", e.DgcId) + } + if e.ParentId != "" { + t.Errorf("expected empty parentId, got %q", e.ParentId) + } +} + +func TestLineageEntityUnmarshalJSON_nullFields(t *testing.T) { + data := []byte(`{"id":"5","name":"t","type":"table","dgcId":null,"parentId":null}`) + var e LineageEntity + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.DgcId != "" { + t.Errorf("expected empty dgcId, got %q", e.DgcId) + } + if e.ParentId != "" { + t.Errorf("expected empty parentId, got %q", e.ParentId) + } +} + +func TestLineageEntityUnmarshalJSON_missingFields(t *testing.T) { + data := []byte(`{"id":"7","name":"t","type":"table"}`) + var e LineageEntity + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.DgcId != "" || e.ParentId != "" { + t.Errorf("expected empty optional fields, got dgcId=%q parentId=%q", e.DgcId, e.ParentId) + } +} + +// --- GetLineageEntity --- + +func TestGetLineageEntity_RoutesCorrectly(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + writeJSON(w, http.StatusOK, map[string]any{"id": "entity-1", "name": "col1", "type": "Column"}) + })) + defer server.Close() + + _, err := GetLineageEntity(context.Background(), newTestClient(server), "entity-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1" + if capturedPath != expected { + t.Errorf("expected path %q, got %q", expected, capturedPath) + } +} + +// --- GetLineageUpstream --- + +func TestGetLineageUpstream_RoutesCorrectly(t *testing.T) { + var capturedPath string + var capturedQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + writeJSON(w, http.StatusOK, map[string]any{"relations": []any{}}) + })) + defer server.Close() + + _, err := GetLineageUpstream(context.Background(), newTestClient(server), "entity-1", "Column", 10, "cursor-abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/upstream" + if capturedPath != expected { + t.Errorf("expected path %q, got %q", expected, capturedPath) + } + if capturedQuery.Get("entityType") != "Column" { + t.Errorf("expected entityType=Column, got %q", capturedQuery.Get("entityType")) + } + if capturedQuery.Get("limit") != "10" { + t.Errorf("expected limit=10, got %q", capturedQuery.Get("limit")) + } + if capturedQuery.Get("cursor") != "cursor-abc" { + t.Errorf("expected cursor=cursor-abc, got %q", capturedQuery.Get("cursor")) + } +} + +// --- GetLineageDownstream --- + +func TestGetLineageDownstream_RoutesCorrectly(t *testing.T) { + var capturedPath string + var capturedQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + writeJSON(w, http.StatusOK, map[string]any{"relations": []any{}}) + })) + defer server.Close() + + _, err := GetLineageDownstream(context.Background(), newTestClient(server), "entity-2", "Table", 5, "cursor-xyz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-2/downstream" + if capturedPath != expected { + t.Errorf("expected path %q, got %q", expected, capturedPath) + } + if capturedQuery.Get("entityType") != "Table" { + t.Errorf("expected entityType=Table, got %q", capturedQuery.Get("entityType")) + } + if capturedQuery.Get("limit") != "5" { + t.Errorf("expected limit=5, got %q", capturedQuery.Get("limit")) + } + if capturedQuery.Get("cursor") != "cursor-xyz" { + t.Errorf("expected cursor=cursor-xyz, got %q", capturedQuery.Get("cursor")) + } +} + +func TestGetLineageDownstream_ErrorReturnsEmptyRelations(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + out, err := GetLineageDownstream(context.Background(), newTestClient(server), "entity-x", "", 0, "") + if err != nil { + t.Fatalf("unexpected hard error: %v", err) + } + if out.Error == "" { + t.Errorf("expected error message in output") + } + if out.Relations == nil { + t.Errorf("expected non-nil Relations slice on error, got nil") + } +} + +func TestGetLineageUpstream_ErrorReturnsEmptyRelations(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + out, err := GetLineageUpstream(context.Background(), newTestClient(server), "entity-x", "", 0, "") + if err != nil { + t.Fatalf("unexpected hard error: %v", err) + } + if out.Error == "" { + t.Errorf("expected error message in output") + } + if out.Relations == nil { + t.Errorf("expected non-nil Relations slice on error, got nil") + } +} + +func TestGetLineageDirectional_NoCursorInResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "relations": []map[string]any{ + {"sourceEntityId": "a", "targetEntityId": "b", "transformationIds": []string{"t1"}}, + }, + }) + })) + defer server.Close() + + out, err := GetLineageDownstream(context.Background(), newTestClient(server), "a", "", 0, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Pagination != nil { + t.Errorf("expected nil Pagination when server omits nextCursor, got %+v", out.Pagination) + } +} + +// --- SearchLineageEntities --- + +func TestSearchLineageEntities_RoutesCorrectly(t *testing.T) { + var capturedPath string + var capturedQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + writeJSON(w, http.StatusOK, map[string]any{"results": []any{}}) + })) + defer server.Close() + + _, err := SearchLineageEntities(context.Background(), newTestClient(server), "orders", "Table", "dgc-id-1", 5, "cur-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "/technical_lineage_resource/rest/lineageGraphRead/v1/entities" + if capturedPath != expected { + t.Errorf("expected path %q, got %q", expected, capturedPath) + } + if capturedQuery.Get("nameContains") != "orders" { + t.Errorf("expected nameContains=orders, got %q", capturedQuery.Get("nameContains")) + } + if capturedQuery.Get("type") != "Table" { + t.Errorf("expected type=Table, got %q", capturedQuery.Get("type")) + } + if capturedQuery.Get("dgcId") != "dgc-id-1" { + t.Errorf("expected dgcId=dgc-id-1, got %q", capturedQuery.Get("dgcId")) + } + if capturedQuery.Get("limit") != "5" { + t.Errorf("expected limit=5, got %q", capturedQuery.Get("limit")) + } + if capturedQuery.Get("cursor") != "cur-1" { + t.Errorf("expected cursor=cur-1, got %q", capturedQuery.Get("cursor")) + } +} + +func TestSearchLineageEntities_JsonNullableObjects(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate server without JsonNullableModule + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[{"id":"32","name":"SALESFACT","type":"table","sourceIds":[],"dgcId":{"undefined":true,"present":false},"parentId":{"undefined":false,"present":true}}],"nextCursor":null}`)) + })) + defer server.Close() + + out, err := SearchLineageEntities(context.Background(), newTestClient(server), "SALES", "", "", 5, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out.Results) != 1 { + t.Fatalf("expected 1 result, got %d", len(out.Results)) + } + e := out.Results[0] + if e.Id != "32" { + t.Errorf("expected id 32, got %q", e.Id) + } + if e.DgcId != "" { + t.Errorf("expected empty dgcId, got %q", e.DgcId) + } +} + +// --- GetLineageTransformation --- + +func TestGetLineageTransformation_RoutesCorrectly(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + writeJSON(w, http.StatusOK, map[string]any{"id": "transform-1", "name": "t1"}) + })) + defer server.Close() + + _, err := GetLineageTransformation(context.Background(), newTestClient(server), "transform-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "/technical_lineage_resource/rest/lineageGraphRead/v1/transformations/transform-1" + if capturedPath != expected { + t.Errorf("expected path %q, got %q", expected, capturedPath) + } +} + +// --- SearchLineageTransformations --- + +func TestSearchLineageTransformations_RoutesCorrectly(t *testing.T) { + var capturedPath string + var capturedQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + writeJSON(w, http.StatusOK, map[string]any{"results": []any{}}) + })) + defer server.Close() + + _, err := SearchLineageTransformations(context.Background(), newTestClient(server), "etl", 20, "next-cursor") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "/technical_lineage_resource/rest/lineageGraphRead/v1/transformations" + if capturedPath != expected { + t.Errorf("expected path %q, got %q", expected, capturedPath) + } + if capturedQuery.Get("nameContains") != "etl" { + t.Errorf("expected nameContains=etl, got %q", capturedQuery.Get("nameContains")) + } + if capturedQuery.Get("limit") != "20" { + t.Errorf("expected limit=20, got %q", capturedQuery.Get("limit")) + } + if capturedQuery.Get("cursor") != "next-cursor" { + t.Errorf("expected cursor=next-cursor, got %q", capturedQuery.Get("cursor")) + } +} diff --git a/pkg/tools/add_data_classification_match/tool.go b/pkg/tools/add_data_classification_match/tool.go index c213060..dacd03c 100644 --- a/pkg/tools/add_data_classification_match/tool.go +++ b/pkg/tools/add_data_classification_match/tool.go @@ -23,7 +23,7 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_classification_match_add", + 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: handler(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog"}, diff --git a/pkg/tools/add_data_classification_match/tool_test.go b/pkg/tools/add_data_classification_match/tool_test.go index fc10d49..67ae6aa 100644 --- a/pkg/tools/add_data_classification_match/tool_test.go +++ b/pkg/tools/add_data_classification_match/tool_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools/add_data_classification_match" + tools "github.com/collibra/chip/pkg/tools/add_data_classification_match" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -42,12 +42,12 @@ func TestAddClassificationMatch_Success(t *testing.T) { client := testutil.NewClient(server) - input := add_data_classification_match.Input{ + input := tools.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := add_data_classification_match.NewTool(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) @@ -77,11 +77,11 @@ func TestAddClassificationMatch_Success(t *testing.T) { func TestAddClassificationMatch_MissingAssetID(t *testing.T) { client := &http.Client{} - input := add_data_classification_match.Input{ + input := tools.Input{ ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := add_data_classification_match.NewTool(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) @@ -99,11 +99,11 @@ func TestAddClassificationMatch_MissingAssetID(t *testing.T) { func TestAddClassificationMatch_MissingClassificationID(t *testing.T) { client := &http.Client{} - input := add_data_classification_match.Input{ + input := tools.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", } - output, err := add_data_classification_match.NewTool(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) @@ -130,12 +130,12 @@ func TestAddClassificationMatch_AssetNotFound(t *testing.T) { client := testutil.NewClient(server) - input := add_data_classification_match.Input{ + input := tools.Input{ AssetID: "00000000-0000-0000-0000-000000000000", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := add_data_classification_match.NewTool(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) @@ -162,12 +162,12 @@ func TestAddClassificationMatch_AlreadyExists(t *testing.T) { client := testutil.NewClient(server) - input := add_data_classification_match.Input{ + input := tools.Input{ AssetID: "9179b887-04ef-4ce5-ab3a-b5bbd39ea3c8", ClassificationID: "be45c001-b173-48ff-ac91-3f6e45868c8b", } - output, err := add_data_classification_match.NewTool(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/ask_glossary/tool.go b/pkg/tools/discover_business_glossary/tool.go similarity index 75% rename from pkg/tools/ask_glossary/tool.go rename to pkg/tools/discover_business_glossary/tool.go index 65cb873..d29c7f4 100644 --- a/pkg/tools/ask_glossary/tool.go +++ b/pkg/tools/discover_business_glossary/tool.go @@ -1,4 +1,4 @@ -package ask_glossary +package discover_business_glossary import ( "context" @@ -18,8 +18,8 @@ type Output struct { func NewTool(collibraHttpClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "business_glossary_discover", - Description: "Ask the business glossary agent questions about terms and definitions in Collibra.", + 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: handler(collibraHttpClient), Permissions: []string{"dgc.ai-copilot"}, } diff --git a/pkg/tools/ask_glossary/tool_test.go b/pkg/tools/discover_business_glossary/tool_test.go similarity index 84% rename from pkg/tools/ask_glossary/tool_test.go rename to pkg/tools/discover_business_glossary/tool_test.go index 27d5e36..2fddb5f 100644 --- a/pkg/tools/ask_glossary/tool_test.go +++ b/pkg/tools/discover_business_glossary/tool_test.go @@ -1,4 +1,4 @@ -package ask_glossary_test +package discover_business_glossary_test import ( "fmt" @@ -7,7 +7,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/ask_glossary" + tools "github.com/collibra/chip/pkg/tools/discover_business_glossary" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -25,7 +25,7 @@ func TestAskGlossary(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := ask_glossary.NewTool(client).Handler(t.Context(), ask_glossary.Input{ + 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/ask_dad/tool.go b/pkg/tools/discover_data_assets/tool.go similarity index 77% rename from pkg/tools/ask_dad/tool.go rename to pkg/tools/discover_data_assets/tool.go index a55798f..7ce2d31 100644 --- a/pkg/tools/ask_dad/tool.go +++ b/pkg/tools/discover_data_assets/tool.go @@ -1,4 +1,4 @@ -package ask_dad +package discover_data_assets import ( "context" @@ -18,8 +18,8 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_assets_discover", - Description: "Ask the data asset discovery agent questions about available data assets in Collibra.", + 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: handler(collibraClient), Permissions: []string{"dgc.ai-copilot"}, } diff --git a/pkg/tools/ask_dad/tool_test.go b/pkg/tools/discover_data_assets/tool_test.go similarity index 85% rename from pkg/tools/ask_dad/tool_test.go rename to pkg/tools/discover_data_assets/tool_test.go index 59240a7..63e66b7 100644 --- a/pkg/tools/ask_dad/tool_test.go +++ b/pkg/tools/discover_data_assets/tool_test.go @@ -1,4 +1,4 @@ -package ask_dad_test +package discover_data_assets_test import ( "fmt" @@ -7,7 +7,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/ask_dad" + tools "github.com/collibra/chip/pkg/tools/discover_data_assets" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -25,7 +25,7 @@ func TestAskDad(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := ask_dad.NewTool(client).Handler(t.Context(), ask_dad.Input{ + 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/tool.go b/pkg/tools/get_asset_details/tool.go index 1daea50..21054df 100644 --- a/pkg/tools/get_asset_details/tool.go +++ b/pkg/tools/get_asset_details/tool.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "github.com/collibra/chip/pkg/chip" "github.com/collibra/chip/pkg/clients" @@ -19,16 +20,26 @@ type Input struct { } type Output struct { - Asset *clients.Asset `json:"asset,omitempty" jsonschema:"the detailed asset information if found"` - Link string `json:"link,omitempty" jsonschema:"the link you can navigate to in Collibra to view the asset"` - Error string `json:"error,omitempty" jsonschema:"error message if asset not found or other error occurred"` - Found bool `json:"found" jsonschema:"whether the asset was found"` + 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"` + Link string `json:"link,omitempty" jsonschema:"the link you can navigate to in Collibra to view the asset"` + Error string `json:"error,omitempty" jsonschema:"error message if asset not found or other error occurred"` + Found bool `json:"found" jsonschema:"whether the asset was found"` +} + +// AssetResponsibility represents a role assignment (e.g., Owner, Steward) for an asset. +type AssetResponsibility struct { + RoleName string `json:"roleName" jsonschema:"the name of the resource role (e.g., Owner, Business Steward)"` + UserName string `json:"userName,omitempty" jsonschema:"the username of the assigned user, if the owner is a user"` + GroupName string `json:"groupName,omitempty" jsonschema:"the name of the assigned group, if the owner is a user group"` + 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 NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "asset_details_get", - Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, and metadata. Returns up to 100 attributes per type and supports cursor-based pagination for relations (50 per page).", + 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: handler(collibraClient), Permissions: []string{}, } @@ -38,31 +49,16 @@ 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 Output{ - 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 Output{ - 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 Output{ - Error: "Asset not found", - Found: false, - }, nil + return Output{Error: "Asset not found", Found: false}, nil } collibraHost, ok := chip.GetCollibraHost(ctx) @@ -70,10 +66,94 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { slog.WarnContext(ctx, "Collibra instance URL unknown, links will be rendered without host") } + responsibilities, err := clients.GetResponsibilities(ctx, collibraClient, assetUUID.String()) + if err != nil { + slog.WarnContext(ctx, fmt.Sprintf("Failed to retrieve responsibilities: %s", err.Error())) + } + + mappedResponsibilities := resolveResponsibilities(ctx, collibraClient, responsibilities, assetUUID.String()) + responsibilitiesStatus := "" + if len(mappedResponsibilities) == 0 { + responsibilitiesStatus = "No responsibilities assigned" + } + return Output{ - Asset: &assets[0], - Found: true, - Link: fmt.Sprintf("%s/asset/%s", strings.TrimSuffix(collibraHost, "/"), assetUUID), + Asset: &assets[0], + Responsibilities: mappedResponsibilities, + ResponsibilitiesStatus: responsibilitiesStatus, + Found: true, + Link: fmt.Sprintf("%s/asset/%s", strings.TrimSuffix(collibraHost, "/"), assetUUID), }, nil } } + +func resolveResponsibilities(ctx context.Context, collibraClient *http.Client, responsibilities []clients.Responsibility, assetID string) []AssetResponsibility { + if len(responsibilities) == 0 { + return nil + } + ownerNames := resolveOwnerNames(ctx, collibraClient, responsibilities) + result := make([]AssetResponsibility, 0, len(responsibilities)) + for _, r := range responsibilities { + entry := AssetResponsibility{} + if r.Role != nil { + entry.RoleName = r.Role.Name + } + if r.Owner != nil { + resolved := ownerNames[r.Owner.ID] + if r.Owner.ResourceDiscriminator == "UserGroup" { + entry.GroupName = resolved + } else { + entry.UserName = resolved + } + } + entry.Inherited = r.BaseResource != nil && r.BaseResource.ID != assetID + result = append(result, entry) + } + return result +} + +func resolveOwnerNames(ctx context.Context, collibraClient *http.Client, responsibilities []clients.Responsibility) map[string]string { + 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) { + defer wg.Done() + name := fetchOwnerName(ctx, collibraClient, o) + mu.Lock() + names[o.ID] = name + mu.Unlock() + }(owner) + } + wg.Wait() + return names +} + +func fetchOwnerName(ctx context.Context, collibraClient *http.Client, owner *clients.ResourceRef) string { + switch owner.ResourceDiscriminator { + case "User": + name, err := clients.GetUserName(ctx, collibraClient, owner.ID) + if err != nil { + slog.WarnContext(ctx, fmt.Sprintf("Failed to resolve user name for %s: %s", owner.ID, err.Error())) + return owner.ID + } + return name + case "UserGroup": + name, err := clients.GetUserGroupName(ctx, collibraClient, owner.ID) + if err != nil { + slog.WarnContext(ctx, fmt.Sprintf("Failed to resolve group name for %s: %s", owner.ID, err.Error())) + return owner.ID + } + return name + default: + slog.WarnContext(ctx, fmt.Sprintf("Unknown owner type '%s' for %s", owner.ResourceDiscriminator, owner.ID)) + return owner.ID + } +} diff --git a/pkg/tools/get_asset_details/tool_test.go b/pkg/tools/get_asset_details/tool_test.go index c4d121f..14a4de3 100644 --- a/pkg/tools/get_asset_details/tool_test.go +++ b/pkg/tools/get_asset_details/tool_test.go @@ -3,10 +3,11 @@ package get_asset_details_test import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/get_asset_details" + tools "github.com/collibra/chip/pkg/tools/get_asset_details" "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -26,12 +27,128 @@ func TestGetAssetDetails(t *testing.T) { }, } })) + handler.Handle("/rest/2.0/responsibilities", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.ResponsibilityPagedResponse) { + return http.StatusOK, clients.ResponsibilityPagedResponse{ + Total: 0, + Offset: 0, + Limit: 100, + } + })) + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + AssetID: assetId.String(), + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if !output.Found { + t.Fatalf("Asset not found") + } + if output.Asset.DisplayName != "My Asset Name" { + t.Fatalf("Expected answer 'My Asset Name', got: '%s'", output.Asset.DisplayName) + } + if len(output.Responsibilities) != 0 { + t.Fatalf("Expected no responsibilities, got: %d", len(output.Responsibilities)) + } + if output.ResponsibilitiesStatus != "No responsibilities assigned" { + t.Fatalf("Expected 'No responsibilities assigned', got: '%s'", output.ResponsibilitiesStatus) + } +} + +func TestGetAssetDetailsWithResponsibilities(t *testing.T) { + assetId, _ := uuid.NewUUID() + domainId := "domain-123" + handler := http.NewServeMux() + 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{ + { + ID: assetId.String(), + DisplayName: "My Asset Name", + }, + }, + }, + } + })) + handler.Handle("/rest/2.0/responsibilities", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.ResponsibilityPagedResponse) { + return http.StatusOK, clients.ResponsibilityPagedResponse{ + Total: 3, + Offset: 0, + Limit: 100, + Results: []clients.Responsibility{ + { + ID: "resp-1", + Role: &clients.ResourceRole{ID: "role-1", Name: "Owner"}, + Owner: &clients.ResourceRef{ + ID: "user-1", + ResourceDiscriminator: "User", + }, + BaseResource: &clients.ResourceRef{ + ID: assetId.String(), + ResourceDiscriminator: "Asset", + }, + }, + { + ID: "resp-2", + Role: &clients.ResourceRole{ID: "role-2", Name: "Business Steward"}, + Owner: &clients.ResourceRef{ + ID: "group-1", + ResourceDiscriminator: "UserGroup", + }, + BaseResource: &clients.ResourceRef{ + ID: assetId.String(), + ResourceDiscriminator: "Asset", + }, + }, + { + ID: "resp-3", + Role: &clients.ResourceRole{ID: "role-3", Name: "Technical Steward"}, + Owner: &clients.ResourceRef{ + ID: "user-2", + ResourceDiscriminator: "User", + }, + BaseResource: &clients.ResourceRef{ + ID: domainId, + ResourceDiscriminator: "Domain", + }, + }, + }, + } + })) + 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", + FirstName: "John", + LastName: "Doe", + } + })) + 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", + FirstName: "Jane", + LastName: "Smith", + } + })) + 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", + } + })) server := httptest.NewServer(handler) defer server.Close() client := testutil.NewClient(server) - output, err := get_asset_details.NewTool(client).Handler(t.Context(), get_asset_details.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ AssetID: assetId.String(), }) if err != nil { @@ -41,8 +158,45 @@ func TestGetAssetDetails(t *testing.T) { if !output.Found { t.Fatalf("Asset not found") } - expectedAnswer := "My Asset Name" - if output.Asset.DisplayName != expectedAnswer { - t.Fatalf("Expected answer '%s', got: '%s'", expectedAnswer, output.Asset.DisplayName) + + if len(output.Responsibilities) != 3 { + t.Fatalf("Expected 3 responsibilities, got: %d", len(output.Responsibilities)) + } + + // Direct user assignment + if output.Responsibilities[0].RoleName != "Owner" { + t.Fatalf("Expected role 'Owner', got: '%s'", output.Responsibilities[0].RoleName) + } + if !strings.Contains(output.Responsibilities[0].UserName, "john.doe") { + t.Fatalf("Expected user name to contain 'john.doe', got: '%s'", output.Responsibilities[0].UserName) + } + if output.Responsibilities[0].Inherited { + t.Fatalf("Expected direct assignment (inherited=false), got inherited=true") + } + + // Direct group assignment + if output.Responsibilities[1].RoleName != "Business Steward" { + t.Fatalf("Expected role 'Business Steward', got: '%s'", output.Responsibilities[1].RoleName) + } + if output.Responsibilities[1].GroupName != "Data Governance Team" { + t.Fatalf("Expected group 'Data Governance Team', got: '%s'", output.Responsibilities[1].GroupName) + } + if output.Responsibilities[1].Inherited { + t.Fatalf("Expected direct assignment (inherited=false), got inherited=true") + } + + // Inherited assignment (baseResource ID differs from asset ID) + if output.Responsibilities[2].RoleName != "Technical Steward" { + t.Fatalf("Expected role 'Technical Steward', got: '%s'", output.Responsibilities[2].RoleName) + } + if !strings.Contains(output.Responsibilities[2].UserName, "jane.smith") { + t.Fatalf("Expected user name to contain 'jane.smith', got: '%s'", output.Responsibilities[2].UserName) + } + if !output.Responsibilities[2].Inherited { + t.Fatalf("Expected inherited assignment (inherited=true), got inherited=false") + } + + if output.ResponsibilitiesStatus != "" { + t.Fatalf("Expected empty responsibilitiesStatus when responsibilities exist, got: '%s'", output.ResponsibilitiesStatus) } } diff --git a/pkg/tools/get_business_term_data/tool.go b/pkg/tools/get_business_term_data/tool.go new file mode 100644 index 0000000..2d7a1cb --- /dev/null +++ b/pkg/tools/get_business_term_data/tool.go @@ -0,0 +1,113 @@ +package get_business_term_data + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +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 Output struct { + BusinessTermID string `json:"businessTermId" jsonschema:"The Business Term asset ID."` + 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 Attribute struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedColumns []ColumnWithTable `json:"connectedColumns"` +} + +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: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if input.BusinessTermID == "" { + return Output{Error: "businessTermId is required"}, nil + } + + dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.BusinessTermID, clients.GenericConnectedAssetRelID) + if err != nil { + return Output{}, err + } + + 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 Output{}, err + } + + columnsWithDetails := make([]ColumnWithTable, 0, len(columns)) + for _, col := range columns { + colDetail := ColumnWithTable{ + ID: col.ID, + Name: col.Name, + AssetType: col.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, col.ID), + } + + tables, err := clients.FindConnectedAssets(ctx, collibraClient, col.ID, clients.ColumnToTableRelID) + if err != nil { + return Output{}, err + } + if len(tables) > 0 { + t := tables[0] + colDetail.ConnectedTable = &AssetWithDescription{ + ID: t.ID, + Name: t.Name, + AssetType: t.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, t.ID), + } + } + + columnsWithDetails = append(columnsWithDetails, colDetail) + } + + physicalData = append(physicalData, Attribute{ + ID: da.ID, + Name: da.Name, + AssetType: da.AssetType, + Description: daDescription, + ConnectedColumns: columnsWithDetails, + }) + } + + return Output{ + BusinessTermID: input.BusinessTermID, + ConnectedPhysicalData: physicalData, + }, nil + } +} diff --git a/pkg/tools/get_column_semantics/tool.go b/pkg/tools/get_column_semantics/tool.go new file mode 100644 index 0000000..3246431 --- /dev/null +++ b/pkg/tools/get_column_semantics/tool.go @@ -0,0 +1,103 @@ +package get_column_semantics + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +// AssetWithDescription represents an enriched asset used in traversal tool outputs. +type AssetWithDescription struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` +} + +type Input struct { + ColumnID string `json:"columnId" jsonschema:"Required. The UUID of the column asset to retrieve semantics for."` +} + +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."` +} + +type DataAttributeSemantics struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedMeasures []AssetWithDescription `json:"connectedMeasures"` + ConnectedBusinessAssets []AssetWithDescription `json:"connectedBusinessAssets"` +} + +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: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if input.ColumnID == "" { + return Output{Error: "columnId is required"}, nil + } + + dataAttributes, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, input.ColumnID) + if err != nil { + 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) + if err != nil { + return Output{}, err + } + + measures := make([]AssetWithDescription, 0, len(rawMeasures)) + for _, m := range rawMeasures { + measures = append(measures, AssetWithDescription{ + ID: m.ID, + Name: m.Name, + AssetType: m.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, m.ID), + }) + } + + rawGenericAssets, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.GenericConnectedAssetRelID) + if err != nil { + return Output{}, err + } + + genericAssets := make([]AssetWithDescription, 0, len(rawGenericAssets)) + for _, g := range rawGenericAssets { + genericAssets = append(genericAssets, AssetWithDescription{ + ID: g.ID, + Name: g.Name, + AssetType: g.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, g.ID), + }) + } + + semantics = append(semantics, DataAttributeSemantics{ + ID: da.ID, + Name: da.Name, + AssetType: da.AssetType, + Description: description, + ConnectedMeasures: measures, + ConnectedBusinessAssets: genericAssets, + }) + } + + return Output{Semantics: semantics}, nil + } +} diff --git a/pkg/tools/get_lineage_downstream/tool.go b/pkg/tools/get_lineage_downstream/tool.go new file mode 100644 index 0000000..0ee1caa --- /dev/null +++ b/pkg/tools/get_lineage_downstream/tool.go @@ -0,0 +1,49 @@ +package get_lineage_downstream + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +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 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: handler(collibraClient), + Permissions: []string{}, + } +} + +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/tool_test.go b/pkg/tools/get_lineage_downstream/tool_test.go new file mode 100644 index 0000000..048bfa5 --- /dev/null +++ b/pkg/tools/get_lineage_downstream/tool_test.go @@ -0,0 +1,104 @@ +package get_lineage_downstream_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + 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", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + return http.StatusOK, map[string]any{ + "relations": []map[string]any{ + { + "sourceEntityId": "entity-1", + "targetEntityId": "target-1", + "transformationIds": []string{"transform-2"}, + }, + }, + "nextCursor": "cursor-xyz", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + EntityId: "entity-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Error != "" { + t.Fatalf("Expected no error in output, got: %s", output.Error) + } + + if output.EntityId != "entity-1" { + t.Fatalf("Expected entityId 'entity-1', got: '%s'", output.EntityId) + } + + if output.Direction != clients.LineageDirectionDownstream { + t.Fatalf("Expected direction 'downstream', got: '%s'", output.Direction) + } + + if len(output.Relations) != 1 { + t.Fatalf("Expected 1 relation, got: %d", len(output.Relations)) + } + + relation := output.Relations[0] + if relation.TargetEntityId != "target-1" { + t.Fatalf("Expected targetEntityId 'target-1', got: '%s'", relation.TargetEntityId) + } + + if output.Pagination == nil || output.Pagination.NextCursor != "cursor-xyz" { + t.Fatalf("Expected nextCursor 'cursor-xyz'") + } +} + +func TestGetLineageDownstreamNotFound(t *testing.T) { + handler := http.NewServeMux() + 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 := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + EntityId: "entity-unknown", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } + + if output.Relations == nil { + t.Fatalf("Expected Relations to be a non-nil slice, got nil") + } +} + +func TestGetLineageDownstreamMissingId(t *testing.T) { + server := httptest.NewServer(http.NewServeMux()) + defer server.Close() + + 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) + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } +} diff --git a/pkg/tools/get_lineage_entity/tool.go b/pkg/tools/get_lineage_entity/tool.go new file mode 100644 index 0000000..d61d6dd --- /dev/null +++ b/pkg/tools/get_lineage_entity/tool.go @@ -0,0 +1,37 @@ +package get_lineage_entity + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +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 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: handler(collibraClient), + Permissions: []string{}, + } +} + +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 + } + + result, err := clients.GetLineageEntity(ctx, collibraClient, input.EntityId) + if err != nil { + return clients.GetLineageEntityOutput{}, err + } + + return *result, nil + } +} diff --git a/pkg/tools/get_lineage_entity/tool_test.go b/pkg/tools/get_lineage_entity/tool_test.go new file mode 100644 index 0000000..8071b5f --- /dev/null +++ b/pkg/tools/get_lineage_entity/tool_test.go @@ -0,0 +1,94 @@ +package get_lineage_entity_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + 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", testutil.JsonHandlerOut(func(r *http.Request) (int, clients.LineageEntity) { + return http.StatusOK, clients.LineageEntity{ + Id: "entity-1", + Name: "my_table", + Type: "table", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + EntityId: "entity-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if !output.Found { + t.Fatalf("Expected entity to be found") + } + + if output.Entity.Id != "entity-1" { + t.Fatalf("Expected entity ID 'entity-1', got: '%s'", output.Entity.Id) + } + + if output.Entity.Name != "my_table" { + t.Fatalf("Expected entity name 'my_table', got: '%s'", output.Entity.Name) + } + + if output.Entity.Type != "table" { + t.Fatalf("Expected entity type 'table', got: '%s'", output.Entity.Type) + } +} + +func TestGetLineageEntityNotFound(t *testing.T) { + handler := http.NewServeMux() + 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 := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + EntityId: "entity-unknown", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Found { + t.Fatalf("Expected entity not to be found") + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } +} + +func TestGetLineageEntityMissingId(t *testing.T) { + server := httptest.NewServer(http.NewServeMux()) + defer server.Close() + + 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) + } + + if output.Found { + t.Fatalf("Expected entity not to be found") + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } +} diff --git a/pkg/tools/get_lineage_transformation/tool.go b/pkg/tools/get_lineage_transformation/tool.go new file mode 100644 index 0000000..6ecfb6d --- /dev/null +++ b/pkg/tools/get_lineage_transformation/tool.go @@ -0,0 +1,37 @@ +package get_lineage_transformation + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type Input struct { + TransformationId string `json:"transformationId" jsonschema:"Required. ID of the transformation to be fetched (e.g. '67890')."` +} + +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: handler(collibraClient), + Permissions: []string{}, + } +} + +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 + } + + result, err := clients.GetLineageTransformation(ctx, collibraClient, input.TransformationId) + if err != nil { + return clients.GetLineageTransformationOutput{}, err + } + + return *result, nil + } +} diff --git a/pkg/tools/get_lineage_transformation/tool_test.go b/pkg/tools/get_lineage_transformation/tool_test.go new file mode 100644 index 0000000..a0a99bf --- /dev/null +++ b/pkg/tools/get_lineage_transformation/tool_test.go @@ -0,0 +1,94 @@ +package get_lineage_transformation_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + 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", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + return http.StatusOK, map[string]any{ + "id": "transform-1", + "name": "etl_sales_daily", + "description": "Daily ETL for sales data", + "transformationLogic": "SELECT * FROM raw_sales WHERE date = CURRENT_DATE", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + TransformationId: "transform-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if !output.Found { + t.Fatalf("Expected transformation to be found") + } + + if output.Transformation.Id != "transform-1" { + t.Fatalf("Expected transformation ID 'transform-1', got: '%s'", output.Transformation.Id) + } + + if output.Transformation.Name != "etl_sales_daily" { + t.Fatalf("Expected transformation name 'etl_sales_daily', got: '%s'", output.Transformation.Name) + } + + if output.Transformation.TransformationLogic == "" { + t.Fatalf("Expected transformation logic to be present") + } +} + +func TestGetLineageTransformationNotFound(t *testing.T) { + handler := http.NewServeMux() + 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 := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + TransformationId: "transform-unknown", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Found { + t.Fatalf("Expected transformation not to be found") + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } +} + +func TestGetLineageTransformationMissingId(t *testing.T) { + server := httptest.NewServer(http.NewServeMux()) + defer server.Close() + + 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) + } + + if output.Found { + t.Fatalf("Expected transformation not to be found") + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } +} diff --git a/pkg/tools/get_lineage_upstream/tool.go b/pkg/tools/get_lineage_upstream/tool.go new file mode 100644 index 0000000..4dd591a --- /dev/null +++ b/pkg/tools/get_lineage_upstream/tool.go @@ -0,0 +1,50 @@ +package get_lineage_upstream + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +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 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: handler(collibraClient), + Permissions: []string{}, + } +} + +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) + } +} + +// handleLineageDirectional is a shared helper for the upstream and downstream tool handlers. +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_upstream/tool_test.go b/pkg/tools/get_lineage_upstream/tool_test.go new file mode 100644 index 0000000..015d4df --- /dev/null +++ b/pkg/tools/get_lineage_upstream/tool_test.go @@ -0,0 +1,104 @@ +package get_lineage_upstream_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + 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", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + return http.StatusOK, map[string]any{ + "relations": []map[string]any{ + { + "sourceEntityId": "source-1", + "targetEntityId": "entity-1", + "transformationIds": []string{"transform-1"}, + }, + }, + "nextCursor": "cursor-abc", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + EntityId: "entity-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Error != "" { + t.Fatalf("Expected no error in output, got: %s", output.Error) + } + + if output.EntityId != "entity-1" { + t.Fatalf("Expected entityId 'entity-1', got: '%s'", output.EntityId) + } + + if output.Direction != clients.LineageDirectionUpstream { + t.Fatalf("Expected direction 'upstream', got: '%s'", output.Direction) + } + + if len(output.Relations) != 1 { + t.Fatalf("Expected 1 relation, got: %d", len(output.Relations)) + } + + relation := output.Relations[0] + if relation.SourceEntityId != "source-1" { + t.Fatalf("Expected sourceEntityId 'source-1', got: '%s'", relation.SourceEntityId) + } + + if output.Pagination == nil || output.Pagination.NextCursor != "cursor-abc" { + t.Fatalf("Expected nextCursor 'cursor-abc'") + } +} + +func TestGetLineageUpstreamNotFound(t *testing.T) { + handler := http.NewServeMux() + 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 := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + EntityId: "entity-unknown", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } + + if output.Relations == nil { + t.Fatalf("Expected Relations to be a non-nil slice, got nil") + } +} + +func TestGetLineageUpstreamMissingId(t *testing.T) { + server := httptest.NewServer(http.NewServeMux()) + defer server.Close() + + 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) + } + + if output.Error == "" { + t.Fatalf("Expected an error message") + } +} diff --git a/pkg/tools/get_measure_data/tool.go b/pkg/tools/get_measure_data/tool.go new file mode 100644 index 0000000..9101286 --- /dev/null +++ b/pkg/tools/get_measure_data/tool.go @@ -0,0 +1,106 @@ +package get_measure_data + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "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"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedTable *AssetWithDescription `json:"connectedTable"` +} + +type Input struct { + MeasureID string `json:"measureId" jsonschema:"Required. The UUID of the measure asset to trace back to its underlying physical columns."` +} + +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 Attribute struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + ConnectedColumns []ColumnWithTable `json:"connectedColumns"` +} + +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: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if input.MeasureID == "" { + return Output{Error: "measureId is required"}, nil + } + + dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.MeasureID, clients.DataAttributeRepresentsMeasureRelID) + if err != nil { + return Output{}, err + } + + hierarchy := make([]Attribute, 0, len(dataAttributes)) + for _, da := range dataAttributes { + columns, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, da.ID) + if err != nil { + return Output{}, err + } + + columnsWithDetails := make([]ColumnWithTable, 0, len(columns)) + for _, col := range columns { + colDetail := ColumnWithTable{ + ID: col.ID, + Name: col.Name, + AssetType: col.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, col.ID), + } + + tables, err := clients.FindConnectedAssets(ctx, collibraClient, col.ID, clients.ColumnToTableRelID) + if err != nil { + return Output{}, err + } + if len(tables) > 0 { + t := tables[0] + colDetail.ConnectedTable = &AssetWithDescription{ + ID: t.ID, + Name: t.Name, + AssetType: t.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, t.ID), + } + } + + columnsWithDetails = append(columnsWithDetails, colDetail) + } + + hierarchy = append(hierarchy, Attribute{ + ID: da.ID, + Name: da.Name, + AssetType: da.AssetType, + ConnectedColumns: columnsWithDetails, + }) + } + + return Output{DataHierarchy: hierarchy}, nil + } +} diff --git a/pkg/tools/get_table_semantics/tool.go b/pkg/tools/get_table_semantics/tool.go new file mode 100644 index 0000000..1f29139 --- /dev/null +++ b/pkg/tools/get_table_semantics/tool.go @@ -0,0 +1,115 @@ +package get_table_semantics + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "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"` +} + +type Input struct { + TableID string `json:"tableId" jsonschema:"Required. The UUID of the Table asset to retrieve semantics for."` +} + +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."` +} + +type ColumnWithSemantics struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedDataAttributes []DataAttributeWithMeasures `json:"connectedDataAttributes"` +} + +type DataAttributeWithMeasures struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedMeasures []AssetWithDescription `json:"connectedMeasures"` +} + +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: handler(collibraClient), + Permissions: []string{}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if input.TableID == "" { + return Output{Error: "tableId is required"}, nil + } + + rawColumns, err := clients.FindConnectedAssets(ctx, collibraClient, input.TableID, clients.ColumnToTableRelID) + if err != nil { + return Output{}, err + } + + columns := make([]ColumnWithSemantics, 0, len(rawColumns)) + for _, col := range rawColumns { + colDescription := clients.FetchDescription(ctx, collibraClient, col.ID) + + dataAttributes, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, col.ID) + if err != nil { + 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) + if err != nil { + return Output{}, err + } + + measures := make([]AssetWithDescription, 0, len(rawMeasures)) + for _, m := range rawMeasures { + measures = append(measures, AssetWithDescription{ + ID: m.ID, + Name: m.Name, + AssetType: m.AssetType, + Description: clients.FetchDescription(ctx, collibraClient, m.ID), + }) + } + + das = append(das, DataAttributeWithMeasures{ + ID: da.ID, + Name: da.Name, + AssetType: da.AssetType, + Description: daDescription, + ConnectedMeasures: measures, + }) + } + + columns = append(columns, ColumnWithSemantics{ + ID: col.ID, + Name: col.Name, + AssetType: col.AssetType, + Description: colDescription, + ConnectedDataAttributes: das, + }) + } + + return Output{ + TableID: input.TableID, + SemanticHierarchy: columns, + }, nil + } +} diff --git a/pkg/tools/list_asset_types/tool.go b/pkg/tools/list_asset_types/tool.go index 380177e..1a683e0 100644 --- a/pkg/tools/list_asset_types/tool.go +++ b/pkg/tools/list_asset_types/tool.go @@ -34,7 +34,7 @@ type AssetType struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "asset_types_list", + Name: "list_asset_types", Description: "List asset types available in Collibra with their properties and metadata.", Handler: handler(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/list_asset_types/tool_test.go b/pkg/tools/list_asset_types/tool_test.go index 9867c1d..ab41603 100644 --- a/pkg/tools/list_asset_types/tool_test.go +++ b/pkg/tools/list_asset_types/tool_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/list_asset_types" + tools "github.com/collibra/chip/pkg/tools/list_asset_types" "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -39,7 +39,7 @@ func TestListAssetTypes(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := list_asset_types.NewTool(client).Handler(t.Context(), list_asset_types.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Limit: 100, }) if err != nil { diff --git a/pkg/tools/list_data_contracts/tool.go b/pkg/tools/list_data_contracts/tool.go index 71e693d..733651c 100644 --- a/pkg/tools/list_data_contracts/tool.go +++ b/pkg/tools/list_data_contracts/tool.go @@ -29,7 +29,7 @@ type Contract struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_contract_list", + 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: handler(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/list_data_contracts/tool_test.go b/pkg/tools/list_data_contracts/tool_test.go index c91e549..c27e421 100644 --- a/pkg/tools/list_data_contracts/tool_test.go +++ b/pkg/tools/list_data_contracts/tool_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/list_data_contracts" + tools "github.com/collibra/chip/pkg/tools/list_data_contracts" "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -35,7 +35,7 @@ func TestListDataContracts(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := list_data_contracts.NewTool(client).Handler(t.Context(), list_data_contracts.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Limit: 100, }) if err != nil { @@ -90,7 +90,7 @@ func TestListDataContractsWithTotal(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := list_data_contracts.NewTool(client).Handler(t.Context(), list_data_contracts.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Limit: 100, }) if err != nil { diff --git a/pkg/tools/pull_data_contract_manifest/tool.go b/pkg/tools/pull_data_contract_manifest/tool.go index a973bef..a6301c6 100644 --- a/pkg/tools/pull_data_contract_manifest/tool.go +++ b/pkg/tools/pull_data_contract_manifest/tool.go @@ -22,7 +22,7 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_contract_manifest_pull", + 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: handler(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/pull_data_contract_manifest/tool_test.go b/pkg/tools/pull_data_contract_manifest/tool_test.go index 20c7688..15e4b96 100644 --- a/pkg/tools/pull_data_contract_manifest/tool_test.go +++ b/pkg/tools/pull_data_contract_manifest/tool_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools/pull_data_contract_manifest" + tools "github.com/collibra/chip/pkg/tools/pull_data_contract_manifest" "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -29,7 +29,7 @@ func TestPullDataContractManifest(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := pull_data_contract_manifest.NewTool(client).Handler(t.Context(), pull_data_contract_manifest.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ DataContractID: contractId.String(), }) if err != nil { @@ -54,7 +54,7 @@ func TestPullDataContractManifestInvalidUUID(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := pull_data_contract_manifest.NewTool(client).Handler(t.Context(), pull_data_contract_manifest.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ DataContractID: "invalid-uuid", }) if err != nil { @@ -80,7 +80,7 @@ func TestPullDataContractManifestNotFound(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := pull_data_contract_manifest.NewTool(client).Handler(t.Context(), pull_data_contract_manifest.Input{ + 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/tool.go b/pkg/tools/push_data_contract_manifest/tool.go index 326f423..864a73a 100644 --- a/pkg/tools/push_data_contract_manifest/tool.go +++ b/pkg/tools/push_data_contract_manifest/tool.go @@ -27,7 +27,7 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_contract_manifest_push", + 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: handler(collibraClient), Permissions: []string{"dgc.data-contract"}, diff --git a/pkg/tools/push_data_contract_manifest/tool_test.go b/pkg/tools/push_data_contract_manifest/tool_test.go index 677437c..78a844d 100644 --- a/pkg/tools/push_data_contract_manifest/tool_test.go +++ b/pkg/tools/push_data_contract_manifest/tool_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/collibra/chip/pkg/tools/push_data_contract_manifest" + tools "github.com/collibra/chip/pkg/tools/push_data_contract_manifest" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -71,7 +71,7 @@ description: This is a sample data contract manifest` defer server.Close() client := testutil.NewClient(server) - output, err := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: manifestContent, }) if err != nil { @@ -146,7 +146,7 @@ func TestPushDataContractManifestWithOptionalParams(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: manifestContent, ManifestID: "test-manifest-456", Version: "1.0.0", @@ -171,7 +171,7 @@ func TestPushDataContractManifestEmptyManifest(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Manifest: "", }) if err != nil { @@ -200,7 +200,7 @@ kind: DataContract` defer server.Close() client := testutil.NewClient(server) - output, err := push_data_contract_manifest.NewTool(client).Handler(t.Context(), push_data_contract_manifest.Input{ + 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 index 7b620b2..e1fdd31 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -5,32 +5,52 @@ import ( "github.com/collibra/chip/pkg/chip" "github.com/collibra/chip/pkg/tools/add_data_classification_match" - "github.com/collibra/chip/pkg/tools/ask_dad" - "github.com/collibra/chip/pkg/tools/ask_glossary" - "github.com/collibra/chip/pkg/tools/find_data_classification_matches" + "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/keyword_search" + "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/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" ) func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.ServerToolConfig) { - toolRegister(server, toolConfig, ask_dad.NewTool(client)) - toolRegister(server, toolConfig, ask_glossary.NewTool(client)) + 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, keyword_search.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, find_data_classification_matches.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, 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)) } func toolRegister[In, Out any](server *chip.Server, toolConfig *chip.ServerToolConfig, tool *chip.Tool[In, Out]) { diff --git a/pkg/tools/remove_data_classification_match/tool.go b/pkg/tools/remove_data_classification_match/tool.go index 0b4c561..deb8007 100644 --- a/pkg/tools/remove_data_classification_match/tool.go +++ b/pkg/tools/remove_data_classification_match/tool.go @@ -21,7 +21,7 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_classification_match_remove", + 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: handler(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog", "dgc.data-classes-edit"}, diff --git a/pkg/tools/remove_data_classification_match/tool_test.go b/pkg/tools/remove_data_classification_match/tool_test.go index 71e63ac..1411d1a 100644 --- a/pkg/tools/remove_data_classification_match/tool_test.go +++ b/pkg/tools/remove_data_classification_match/tool_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/collibra/chip/pkg/tools/remove_data_classification_match" + tools "github.com/collibra/chip/pkg/tools/remove_data_classification_match" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -23,11 +23,11 @@ func TestRemoveClassificationMatch_Success(t *testing.T) { client := testutil.NewClient(server) - input := remove_data_classification_match.Input{ + input := tools.Input{ ClassificationMatchID: "12345678-1234-1234-1234-123456789abc", } - output, err := remove_data_classification_match.NewTool(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) @@ -41,9 +41,9 @@ func TestRemoveClassificationMatch_Success(t *testing.T) { func TestRemoveClassificationMatch_MissingClassificationMatchID(t *testing.T) { client := &http.Client{} - input := remove_data_classification_match.Input{} + input := tools.Input{} - output, err := remove_data_classification_match.NewTool(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) @@ -66,11 +66,11 @@ func TestRemoveClassificationMatch_NotFound(t *testing.T) { client := testutil.NewClient(server) - input := remove_data_classification_match.Input{ + input := tools.Input{ ClassificationMatchID: "00000000-0000-0000-0000-000000000000", } - output, err := remove_data_classification_match.NewTool(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) @@ -97,11 +97,11 @@ func TestRemoveClassificationMatch_ServerError(t *testing.T) { client := testutil.NewClient(server) - input := remove_data_classification_match.Input{ + input := tools.Input{ ClassificationMatchID: "12345678-1234-1234-1234-123456789abc", } - output, err := remove_data_classification_match.NewTool(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/keyword_search/tool.go b/pkg/tools/search_asset_keyword/tool.go similarity index 98% rename from pkg/tools/keyword_search/tool.go rename to pkg/tools/search_asset_keyword/tool.go index 74f684d..9937827 100644 --- a/pkg/tools/keyword_search/tool.go +++ b/pkg/tools/search_asset_keyword/tool.go @@ -1,4 +1,4 @@ -package keyword_search +package search_asset_keyword import ( "context" @@ -38,7 +38,7 @@ type Resource struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "asset_keyword_search", + 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: handler(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/keyword_search/tool_test.go b/pkg/tools/search_asset_keyword/tool_test.go similarity index 86% rename from pkg/tools/keyword_search/tool_test.go rename to pkg/tools/search_asset_keyword/tool_test.go index 311363e..180b58d 100644 --- a/pkg/tools/keyword_search/tool_test.go +++ b/pkg/tools/search_asset_keyword/tool_test.go @@ -1,4 +1,4 @@ -package keyword_search_test +package search_asset_keyword_test import ( "net/http" @@ -6,7 +6,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/keyword_search" + tools "github.com/collibra/chip/pkg/tools/search_asset_keyword" "github.com/collibra/chip/pkg/tools/testutil" "github.com/google/uuid" ) @@ -33,7 +33,7 @@ func TestKeywordSearch(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := keyword_search.NewTool(client).Handler(t.Context(), keyword_search.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Query: "revenue", }) if err != nil { diff --git a/pkg/tools/search_data_classes/tool.go b/pkg/tools/search_data_classes/tool.go index dccb8b5..32ce780 100644 --- a/pkg/tools/search_data_classes/tool.go +++ b/pkg/tools/search_data_classes/tool.go @@ -26,7 +26,7 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_class_search", + 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: handler(collibraClient), Permissions: []string{"dgc.data-classes-read"}, diff --git a/pkg/tools/search_data_classes/tool_test.go b/pkg/tools/search_data_classes/tool_test.go index 873489a..41f1be9 100644 --- a/pkg/tools/search_data_classes/tool_test.go +++ b/pkg/tools/search_data_classes/tool_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/search_data_classes" + tools "github.com/collibra/chip/pkg/tools/search_data_classes" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -22,7 +22,7 @@ func TestFindDataClasses(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := search_data_classes.NewTool(client).Handler(t.Context(), search_data_classes.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Name: "Question", }) if err != nil { diff --git a/pkg/tools/find_data_classification_matches/tool.go b/pkg/tools/search_data_classification_matches/tool.go similarity index 97% rename from pkg/tools/find_data_classification_matches/tool.go rename to pkg/tools/search_data_classification_matches/tool.go index 4920da0..7c38feb 100644 --- a/pkg/tools/find_data_classification_matches/tool.go +++ b/pkg/tools/search_data_classification_matches/tool.go @@ -1,4 +1,4 @@ -package find_data_classification_matches +package search_data_classification_matches import ( "context" @@ -27,7 +27,7 @@ type Output struct { func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ - Name: "data_classification_match_search", + 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: handler(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog"}, diff --git a/pkg/tools/find_data_classification_matches/tool_test.go b/pkg/tools/search_data_classification_matches/tool_test.go similarity index 87% rename from pkg/tools/find_data_classification_matches/tool_test.go rename to pkg/tools/search_data_classification_matches/tool_test.go index 34059cc..e5f87df 100644 --- a/pkg/tools/find_data_classification_matches/tool_test.go +++ b/pkg/tools/search_data_classification_matches/tool_test.go @@ -1,4 +1,4 @@ -package find_data_classification_matches_test +package search_data_classification_matches_test import ( "net/http" @@ -6,7 +6,7 @@ import ( "testing" "github.com/collibra/chip/pkg/clients" - "github.com/collibra/chip/pkg/tools/find_data_classification_matches" + tools "github.com/collibra/chip/pkg/tools/search_data_classification_matches" "github.com/collibra/chip/pkg/tools/testutil" ) @@ -38,7 +38,7 @@ func TestFindClassificationMatches(t *testing.T) { defer server.Close() client := testutil.NewClient(server) - output, err := find_data_classification_matches.NewTool(client).Handler(t.Context(), find_data_classification_matches.Input{ + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ Statuses: []string{"ACCEPTED"}, Limit: 50, }) diff --git a/pkg/tools/search_lineage_entities/tool.go b/pkg/tools/search_lineage_entities/tool.go new file mode 100644 index 0000000..e66970f --- /dev/null +++ b/pkg/tools/search_lineage_entities/tool.go @@ -0,0 +1,37 @@ +package search_lineage_entities + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +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."` + 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 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: handler(collibraClient), + Permissions: []string{}, + } +} + +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 + } + + return *result, nil + } +} diff --git a/pkg/tools/search_lineage_entities/tool_test.go b/pkg/tools/search_lineage_entities/tool_test.go new file mode 100644 index 0000000..012b112 --- /dev/null +++ b/pkg/tools/search_lineage_entities/tool_test.go @@ -0,0 +1,82 @@ +package search_lineage_entities_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + 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", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + return http.StatusOK, map[string]any{ + "results": []map[string]any{ + { + "id": "entity-1", + "name": "sales_table", + "type": "table", + }, + }, + "nextCursor": "cursor-abc", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + NameContains: "sales", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(output.Results) != 1 { + t.Fatalf("Expected 1 result, got: %d", len(output.Results)) + } + + entity := output.Results[0] + if entity.Id != "entity-1" { + t.Fatalf("Expected entity ID 'entity-1', got: '%s'", entity.Id) + } + + if entity.Name != "sales_table" { + t.Fatalf("Expected entity name 'sales_table', got: '%s'", entity.Name) + } + + if entity.Type != "table" { + t.Fatalf("Expected entity type 'table', got: '%s'", entity.Type) + } + + if output.Pagination == nil || output.Pagination.NextCursor != "cursor-abc" { + t.Fatalf("Expected nextCursor 'cursor-abc'") + } +} + +func TestSearchLineageEntitiesNotFound(t *testing.T) { + handler := http.NewServeMux() + 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{}, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + NameContains: "nonexistent_table", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(output.Results) != 0 { + t.Fatalf("Expected 0 results, got: %d", len(output.Results)) + } +} diff --git a/pkg/tools/search_lineage_transformations/tool.go b/pkg/tools/search_lineage_transformations/tool.go new file mode 100644 index 0000000..4edcbcf --- /dev/null +++ b/pkg/tools/search_lineage_transformations/tool.go @@ -0,0 +1,35 @@ +package search_lineage_transformations + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +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 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: handler(collibraClient), + Permissions: []string{}, + } +} + +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 + } + + return *result, nil + } +} diff --git a/pkg/tools/search_lineage_transformations/tool_test.go b/pkg/tools/search_lineage_transformations/tool_test.go new file mode 100644 index 0000000..45f4b4c --- /dev/null +++ b/pkg/tools/search_lineage_transformations/tool_test.go @@ -0,0 +1,78 @@ +package search_lineage_transformations_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + 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", testutil.JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + return http.StatusOK, map[string]any{ + "results": []map[string]any{ + { + "id": "transform-1", + "name": "etl_sales_daily", + "description": "Daily ETL for sales data", + }, + }, + "nextCursor": "cursor-abc", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + NameContains: "etl", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(output.Results) != 1 { + t.Fatalf("Expected 1 result, got: %d", len(output.Results)) + } + + transformation := output.Results[0] + if transformation.Id != "transform-1" { + t.Fatalf("Expected transformation ID 'transform-1', got: '%s'", transformation.Id) + } + + if transformation.Name != "etl_sales_daily" { + t.Fatalf("Expected transformation name 'etl_sales_daily', got: '%s'", transformation.Name) + } + + if output.Pagination == nil || output.Pagination.NextCursor != "cursor-abc" { + t.Fatalf("Expected nextCursor 'cursor-abc'") + } +} + +func TestSearchLineageTransformationsNotFound(t *testing.T) { + handler := http.NewServeMux() + 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{}, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := testutil.NewClient(server) + output, err := tools.NewTool(client).Handler(t.Context(), tools.Input{ + NameContains: "nonexistent_etl", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(output.Results) != 0 { + t.Fatalf("Expected 0 results, got: %d", len(output.Results)) + } +} diff --git a/pkg/tools/testutil/testutil.go b/pkg/tools/testutil/testutil.go index 669d55a..c7d084a 100644 --- a/pkg/tools/testutil/testutil.go +++ b/pkg/tools/testutil/testutil.go @@ -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 { From 8f9ddbd1b57732d5d3c90bad921392126ea4ba00 Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:04:26 -0400 Subject: [PATCH 04/11] feat: add prepare_add_business_term MCP tool (#41) --- .../prepare_add_business_term_client.go | 347 ++++++++++++++ pkg/tools/prepare_add_business_term/tool.go | 234 ++++++++++ .../prepare_add_business_term/tool_test.go | 434 ++++++++++++++++++ pkg/tools/register.go | 2 + 4 files changed, 1017 insertions(+) create mode 100644 pkg/clients/prepare_add_business_term_client.go create mode 100644 pkg/tools/prepare_add_business_term/tool.go create mode 100644 pkg/tools/prepare_add_business_term/tool_test.go 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/tools/prepare_add_business_term/tool.go b/pkg/tools/prepare_add_business_term/tool.go new file mode 100644 index 0000000..20c03ef --- /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{"dgc.ai-copilot"}, + } +} + +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/register.go b/pkg/tools/register.go index e1fdd31..72d7fbc 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -18,6 +18,7 @@ import ( "github.com/collibra/chip/pkg/tools/get_table_semantics" "github.com/collibra/chip/pkg/tools/list_asset_types" "github.com/collibra/chip/pkg/tools/list_data_contracts" + "github.com/collibra/chip/pkg/tools/prepare_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" @@ -41,6 +42,7 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv 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)) From dd244b2afc17af45adabdc4c138edd79908eee6c Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:06:17 -0400 Subject: [PATCH 05/11] feat: add create_asset MCP tool (#43) --- pkg/clients/create_asset_client.go | 160 ++++++++++++++ pkg/tools/create_asset/tool.go | 67 ++++++ pkg/tools/create_asset/tool_test.go | 322 ++++++++++++++++++++++++++++ pkg/tools/register.go | 2 + 4 files changed, 551 insertions(+) create mode 100644 pkg/clients/create_asset_client.go create mode 100644 pkg/tools/create_asset/tool.go create mode 100644 pkg/tools/create_asset/tool_test.go 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/tools/create_asset/tool.go b/pkg/tools/create_asset/tool.go new file mode 100644 index 0000000..1f57374 --- /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:"asset_type_id" jsonschema:"The UUID of the asset type"` + DomainID string `json:"domain_id" jsonschema:"The UUID of the domain to create the asset in"` + DisplayName string `json:"display_name,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:"asset_id" 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{"dgc.ai-copilot"}, + } +} + +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/register.go b/pkg/tools/register.go index 72d7fbc..b772f30 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -5,6 +5,7 @@ import ( "github.com/collibra/chip/pkg/chip" "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" @@ -53,6 +54,7 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv 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, create_asset.NewTool(client)) } func toolRegister[In, Out any](server *chip.Server, toolConfig *chip.ServerToolConfig, tool *chip.Tool[In, Out]) { From 6e1443c21f38316d44f539da0bffad4e32a4e1a8 Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:10:24 -0400 Subject: [PATCH 06/11] feat: add add_business_term MCP tool (#44) --- pkg/clients/add_business_term_client.go | 100 +++++++++++++ pkg/tools/add_business_term/tool.go | 87 +++++++++++ pkg/tools/add_business_term/tool_test.go | 178 +++++++++++++++++++++++ pkg/tools/register.go | 2 + 4 files changed, 367 insertions(+) create mode 100644 pkg/clients/add_business_term_client.go create mode 100644 pkg/tools/add_business_term/tool.go create mode 100644 pkg/tools/add_business_term/tool_test.go 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/tools/add_business_term/tool.go b/pkg/tools/add_business_term/tool.go new file mode 100644 index 0000000..61ba5ac --- /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-000000003114" +) + +// 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{"dgc.ai-copilot"}, + } +} + +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..69f8623 --- /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-000000003114" { + t.Errorf("expected definition typeId '00000000-0000-0000-0000-000000003114', 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/register.go b/pkg/tools/register.go index b772f30..abaabdf 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -4,6 +4,7 @@ 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" @@ -54,6 +55,7 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv 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, add_business_term.NewTool(client)) toolRegister(server, toolConfig, create_asset.NewTool(client)) } From 543c930c64e0de5004566ce7c97a12a9d76b88ac Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:12:12 -0400 Subject: [PATCH 07/11] feat: add prepare_create_asset MCP tool (#45) --- pkg/clients/prepare_create_asset_client.go | 279 ++++++++++++ pkg/tools/prepare_create_asset/tool.go | 218 ++++++++++ pkg/tools/prepare_create_asset/tool_test.go | 456 ++++++++++++++++++++ pkg/tools/register.go | 2 + 4 files changed, 955 insertions(+) create mode 100644 pkg/clients/prepare_create_asset_client.go create mode 100644 pkg/tools/prepare_create_asset/tool.go create mode 100644 pkg/tools/prepare_create_asset/tool_test.go 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/prepare_create_asset/tool.go b/pkg/tools/prepare_create_asset/tool.go new file mode 100644 index 0000000..b47dc9a --- /dev/null +++ b/pkg/tools/prepare_create_asset/tool.go @@ -0,0 +1,218 @@ +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:"asset_name" jsonschema:"The name of the asset to create"` + AssetTypeID string `json:"asset_type_id,omitempty" jsonschema:"Optional. The publicId of the asset type"` + DomainID string `json:"domain_id,omitempty" jsonschema:"Optional. The ID of the target domain"` + AttributeTypeIDs []string `json:"attribute_type_ids,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:"public_id" jsonschema:"The public ID of the asset type"` + Name string `json:"name" jsonschema:"The name of the asset type"` +} + +// 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:"allowed_values,omitempty" jsonschema:"Optional. List of permitted values if restricted"` + Direction string `json:"direction,omitempty" jsonschema:"Optional. Direction for relation attributes"` + TargetAssetType *AssetTypeOption `json:"target_asset_type,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"` + AssetTypeOptions []AssetTypeOption `json:"asset_type_options,omitempty" jsonschema:"Optional. Available asset types when asset type is missing"` + DomainOptions []DomainOption `json:"domain_options,omitempty" jsonschema:"Optional. Available domains when domain is missing"` + OptionsTruncated bool `json:"options_truncated" jsonschema:"Whether options were truncated to the maximum limit of 20"` + AttributeSchema []AttributeSchema `json:"attribute_schema,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{"dgc.ai-copilot"}, + } +} + +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 + "\".", + 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/register.go b/pkg/tools/register.go index abaabdf..8f9e3cc 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -20,6 +20,7 @@ import ( "github.com/collibra/chip/pkg/tools/get_table_semantics" "github.com/collibra/chip/pkg/tools/list_asset_types" "github.com/collibra/chip/pkg/tools/list_data_contracts" + "github.com/collibra/chip/pkg/tools/prepare_create_asset" "github.com/collibra/chip/pkg/tools/prepare_add_business_term" "github.com/collibra/chip/pkg/tools/pull_data_contract_manifest" "github.com/collibra/chip/pkg/tools/push_data_contract_manifest" @@ -55,6 +56,7 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv 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)) } From 3fdda30a70f4ba665e65ef898cd88f665b5184fc Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:39:35 -0400 Subject: [PATCH 08/11] fix: add resolved UUIDs to prepare_create_asset response, use camelCase JSON tags (#46) --- pkg/tools/create_asset/tool.go | 8 ++--- pkg/tools/prepare_create_asset/tool.go | 41 ++++++++++++++++++-------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/pkg/tools/create_asset/tool.go b/pkg/tools/create_asset/tool.go index 1f57374..092c47b 100644 --- a/pkg/tools/create_asset/tool.go +++ b/pkg/tools/create_asset/tool.go @@ -12,15 +12,15 @@ import ( // 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:"asset_type_id" jsonschema:"The UUID of the asset type"` - DomainID string `json:"domain_id" jsonschema:"The UUID of the domain to create the asset in"` - DisplayName string `json:"display_name,omitempty" jsonschema:"Optional. The display name of the asset"` + 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:"asset_id" jsonschema:"The UUID of the newly created asset"` + AssetID string `json:"assetId" jsonschema:"The UUID of the newly created asset"` } // NewTool creates a new create_asset tool instance. diff --git a/pkg/tools/prepare_create_asset/tool.go b/pkg/tools/prepare_create_asset/tool.go index b47dc9a..2e1ca45 100644 --- a/pkg/tools/prepare_create_asset/tool.go +++ b/pkg/tools/prepare_create_asset/tool.go @@ -12,19 +12,27 @@ const maxOptions = 20 // Input defines the input parameters for the prepare_create_asset tool. type Input struct { - AssetName string `json:"asset_name" jsonschema:"The name of the asset to create"` - AssetTypeID string `json:"asset_type_id,omitempty" jsonschema:"Optional. The publicId of the asset type"` - DomainID string `json:"domain_id,omitempty" jsonschema:"Optional. The ID of the target domain"` - AttributeTypeIDs []string `json:"attribute_type_ids,omitempty" jsonschema:"Optional. List of attribute type IDs to hydrate schema for"` + 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:"public_id" jsonschema:"The public 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"` @@ -38,9 +46,9 @@ type AttributeSchema struct { 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:"allowed_values,omitempty" jsonschema:"Optional. List of permitted values if restricted"` + 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:"target_asset_type,omitempty" jsonschema:"Optional. Target asset type 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. @@ -53,10 +61,11 @@ type DuplicateAsset struct { 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"` - AssetTypeOptions []AssetTypeOption `json:"asset_type_options,omitempty" jsonschema:"Optional. Available asset types when asset type is missing"` - DomainOptions []DomainOption `json:"domain_options,omitempty" jsonschema:"Optional. Available domains when domain is missing"` - OptionsTruncated bool `json:"options_truncated" jsonschema:"Whether options were truncated to the maximum limit of 20"` - AttributeSchema []AttributeSchema `json:"attribute_schema,omitempty" jsonschema:"Optional. Full attribute schemas for the asset type"` + 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"` } @@ -210,8 +219,14 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { } return Output{ - Status: string(clients.StatusReady), - Message: "All validations passed. Ready to create asset \"" + input.AssetName + "\" of type \"" + assetType.Name + "\" in domain \"" + domain.Name + "\".", + 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 } From 9e7148bebd52085f03f49c55c0b1c1d008392967 Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:31:02 -0400 Subject: [PATCH 09/11] feat: expose CopilotToolNames for chip-service routing (#48) --- pkg/tools/register.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/tools/register.go b/pkg/tools/register.go index 8f9e3cc..6845198 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -32,6 +32,14 @@ import ( "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)) From 3fc7e784615086a08e7852375aad09cb39a85399 Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:29:05 -0400 Subject: [PATCH 10/11] remove unnecessary permissions (#49) --- pkg/tools/add_business_term/tool.go | 2 +- pkg/tools/create_asset/tool.go | 2 +- pkg/tools/prepare_add_business_term/tool.go | 14 +++++++------- pkg/tools/prepare_create_asset/tool.go | 18 +++++++++--------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pkg/tools/add_business_term/tool.go b/pkg/tools/add_business_term/tool.go index 61ba5ac..4bc9102 100644 --- a/pkg/tools/add_business_term/tool.go +++ b/pkg/tools/add_business_term/tool.go @@ -40,7 +40,7 @@ func NewTool(collibraClient *http.Client) *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{"dgc.ai-copilot"}, + Permissions: []string{}, } } diff --git a/pkg/tools/create_asset/tool.go b/pkg/tools/create_asset/tool.go index 092c47b..00825c9 100644 --- a/pkg/tools/create_asset/tool.go +++ b/pkg/tools/create_asset/tool.go @@ -29,7 +29,7 @@ func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { Name: "create_asset", Description: "Create a new data asset with optional attributes in Collibra.", Handler: handler(collibraClient), - Permissions: []string{"dgc.ai-copilot"}, + Permissions: []string{}, } } diff --git a/pkg/tools/prepare_add_business_term/tool.go b/pkg/tools/prepare_add_business_term/tool.go index 20c03ef..0a90dd8 100644 --- a/pkg/tools/prepare_add_business_term/tool.go +++ b/pkg/tools/prepare_add_business_term/tool.go @@ -44,13 +44,13 @@ type DuplicateAssetInfo struct { // 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"` + 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"` + 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. @@ -80,7 +80,7 @@ func NewTool(collibraClient *http.Client) *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{"dgc.ai-copilot"}, + Permissions: []string{}, } } diff --git a/pkg/tools/prepare_create_asset/tool.go b/pkg/tools/prepare_create_asset/tool.go index 2e1ca45..0480f68 100644 --- a/pkg/tools/prepare_create_asset/tool.go +++ b/pkg/tools/prepare_create_asset/tool.go @@ -41,14 +41,14 @@ type DomainOption struct { // 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"` + 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"` + 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. @@ -64,7 +64,7 @@ type Output struct { 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"` + 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"` } @@ -75,7 +75,7 @@ func NewTool(collibraClient *http.Client) *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{"dgc.ai-copilot"}, + Permissions: []string{}, } } From efb4e5ac64c90190d5b8e9a47634a2f0b5006db6 Mon Sep 17 00:00:00 2001 From: Bobby Smedley <105080650+bobby-smedley@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:42:00 -0400 Subject: [PATCH 11/11] fix: correct Collibra relation/attribute type UUIDs and constant names (#51) * fix: correct DefinitionAttributeTypeID * fix: rename relation/attribute type constants to match DGC and fix broken UUIDs * fix: update hardcoded DefinitionAttributeTypeID in add_business_term test --- pkg/clients/dgc_relation_client.go | 14 +++++++------- pkg/tools/add_business_term/tool.go | 2 +- pkg/tools/add_business_term/tool_test.go | 4 ++-- pkg/tools/get_business_term_data/tool.go | 4 ++-- pkg/tools/get_column_semantics/tool.go | 4 ++-- pkg/tools/get_measure_data/tool.go | 4 ++-- pkg/tools/get_table_semantics/tool.go | 4 ++-- 7 files changed, 18 insertions(+), 18 deletions(-) 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/tools/add_business_term/tool.go b/pkg/tools/add_business_term/tool.go index 4bc9102..d34b9b7 100644 --- a/pkg/tools/add_business_term/tool.go +++ b/pkg/tools/add_business_term/tool.go @@ -12,7 +12,7 @@ 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-000000003114" + DefinitionAttributeTypeID = "00000000-0000-0000-0000-000000000202" ) // InputAttribute represents an additional attribute to add to the business term. diff --git a/pkg/tools/add_business_term/tool_test.go b/pkg/tools/add_business_term/tool_test.go index 69f8623..90a6339 100644 --- a/pkg/tools/add_business_term/tool_test.go +++ b/pkg/tools/add_business_term/tool_test.go @@ -30,8 +30,8 @@ func TestAddBusinessTermSuccess(t *testing.T) { 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-000000003114" { - t.Errorf("expected definition typeId '00000000-0000-0000-0000-000000003114', got '%s'", req.TypeId) + 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) diff --git a/pkg/tools/get_business_term_data/tool.go b/pkg/tools/get_business_term_data/tool.go index 2d7a1cb..e83617a 100644 --- a/pkg/tools/get_business_term_data/tool.go +++ b/pkg/tools/get_business_term_data/tool.go @@ -56,7 +56,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err } @@ -79,7 +79,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err } diff --git a/pkg/tools/get_column_semantics/tool.go b/pkg/tools/get_column_semantics/tool.go index 3246431..beff244 100644 --- a/pkg/tools/get_column_semantics/tool.go +++ b/pkg/tools/get_column_semantics/tool.go @@ -58,7 +58,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err } @@ -73,7 +73,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { }) } - rawGenericAssets, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.GenericConnectedAssetRelID) + rawGenericAssets, err := clients.FindConnectedAssets(ctx, collibraClient, da.ID, clients.BusinessAssetRepresentsDataAssetRelID) if err != nil { return Output{}, err } diff --git a/pkg/tools/get_measure_data/tool.go b/pkg/tools/get_measure_data/tool.go index 9101286..6a0056c 100644 --- a/pkg/tools/get_measure_data/tool.go +++ b/pkg/tools/get_measure_data/tool.go @@ -55,7 +55,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err } @@ -76,7 +76,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err } diff --git a/pkg/tools/get_table_semantics/tool.go b/pkg/tools/get_table_semantics/tool.go index 1f29139..efa8e8c 100644 --- a/pkg/tools/get_table_semantics/tool.go +++ b/pkg/tools/get_table_semantics/tool.go @@ -56,7 +56,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err } @@ -74,7 +74,7 @@ func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { 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 Output{}, err }