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/clients/prepare_add_business_term_client.go b/pkg/clients/prepare_add_business_term_client.go new file mode 100644 index 0000000..63137be --- /dev/null +++ b/pkg/clients/prepare_add_business_term_client.go @@ -0,0 +1,320 @@ +package clients + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// BusinessTermAssetTypePublicID is the well-known public ID for the Business Term asset type. +const BusinessTermAssetTypePublicID = "BusinessTerm" + +// --- Response types for prepare_add_business_term --- + +// PrepareAddBusinessTermResourceRef represents a named resource reference (NamedResourceReferenceImpl). +type PrepareAddBusinessTermResourceRef struct { + ID string `json:"id"` + Name string `json:"name"` + ResourceDiscriminator string `json:"resourceDiscriminator,omitempty"` +} + +// PrepareAddBusinessTermDomainResponse represents a domain from GET /rest/2.0/domains/{domainId}. +type PrepareAddBusinessTermDomainResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// PrepareAddBusinessTermDomainPagedResponse represents the paginated response from GET /rest/2.0/domains. +type PrepareAddBusinessTermDomainPagedResponse struct { + Results []PrepareAddBusinessTermDomainResponse `json:"results"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// PrepareAddBusinessTermAssetTypeResponse represents an asset type from GET /rest/2.0/assetTypes/publicId/{publicId}. +type PrepareAddBusinessTermAssetTypeResponse struct { + ID string `json:"id"` + PublicID string `json:"publicId"` + Name string `json:"name"` + Description string `json:"description"` +} + +// PrepareAddBusinessTermCharacteristicTypeRef represents an assigned characteristic type reference. +type PrepareAddBusinessTermCharacteristicTypeRef struct { + ID string `json:"id"` + AssignedResourceReference PrepareAddBusinessTermResourceRef `json:"assignedResourceReference"` + MinimumOccurrences int `json:"minimumOccurrences"` + MaximumOccurrences *int `json:"maximumOccurrences,omitempty"` +} + +// PrepareAddBusinessTermAssignmentResponse represents an assignment from GET /rest/2.0/assignments/assetType/{assetTypeId}. +type PrepareAddBusinessTermAssignmentResponse struct { + ID string `json:"id"` + AssignedCharacteristicTypeReferences []PrepareAddBusinessTermCharacteristicTypeRef `json:"assignedCharacteristicTypeReferences"` +} + +// PrepareAddBusinessTermConstraintsResponse represents attribute constraints. +type PrepareAddBusinessTermConstraintsResponse struct { + MinLength int `json:"minLength,omitempty"` + MaxLength int `json:"maxLength,omitempty"` + Pattern string `json:"pattern,omitempty"` + AllowedValues []string `json:"allowedValues,omitempty"` +} + +// PrepareAddBusinessTermRelationTypeResponse represents a relation type within an attribute type. +type PrepareAddBusinessTermRelationTypeResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Direction string `json:"direction"` + TargetAssetTypeID string `json:"targetAssetTypeId"` +} + +// PrepareAddBusinessTermAttributeTypeResponse represents an attribute type from GET /rest/2.0/attributeTypes/{id}. +type PrepareAddBusinessTermAttributeTypeResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Description string `json:"description"` + Constraints *PrepareAddBusinessTermConstraintsResponse `json:"constraints,omitempty"` + RelationTypes []PrepareAddBusinessTermRelationTypeResponse `json:"relationTypes,omitempty"` +} + +// PrepareAddBusinessTermAssetResponse represents an individual asset in search results. +type PrepareAddBusinessTermAssetResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Domain PrepareAddBusinessTermResourceRef `json:"domain"` + Type PrepareAddBusinessTermResourceRef `json:"type"` + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + +// PrepareAddBusinessTermSearchAssetsResponse represents the response from GET /rest/2.0/assets. +type PrepareAddBusinessTermSearchAssetsResponse struct { + Results []PrepareAddBusinessTermAssetResponse `json:"results"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// PrepareAddBusinessTermExtractedAssignment is the simplified assignment info extracted from the API response. +type PrepareAddBusinessTermExtractedAssignment struct { + AttributeTypeID string + Required bool +} + +// --- Client functions --- + +// PrepareAddBusinessTermListDomains lists all available domains. +func PrepareAddBusinessTermListDomains(ctx context.Context, client *http.Client) ([]PrepareAddBusinessTermDomainResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/rest/2.0/domains?limit=100&excludeMeta=true", 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 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 paged PrepareAddBusinessTermDomainPagedResponse + if err := json.NewDecoder(resp.Body).Decode(&paged); err != nil { + return nil, fmt.Errorf("decoding domains response: %w", err) + } + return paged.Results, nil +} + +// PrepareAddBusinessTermGetDomain gets a specific domain by ID. +func PrepareAddBusinessTermGetDomain(ctx context.Context, client *http.Client, domainID string) (*PrepareAddBusinessTermDomainResponse, 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 %s: %w", domainID, err) + } + defer 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 PrepareAddBusinessTermDomainResponse + 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) (*PrepareAddBusinessTermAssetTypeResponse, 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 %s: %w", publicID, err) + } + defer 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 PrepareAddBusinessTermAssetTypeResponse + 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 and extracts +// the attribute type IDs and required status from the characteristic type references. +func PrepareAddBusinessTermGetAssignments(ctx context.Context, client *http.Client, assetTypeID string) ([]PrepareAddBusinessTermExtractedAssignment, error) { + reqURL := fmt.Sprintf("/rest/2.0/assignments/assetType/%s", url.PathEscape(assetTypeID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, 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 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 assignments []PrepareAddBusinessTermAssignmentResponse + if err := json.NewDecoder(resp.Body).Decode(&assignments); err != nil { + return nil, fmt.Errorf("decoding assignments response: %w", err) + } + + // Deduplicate attribute type IDs across all assignments using a map. + // Only include characteristic types that are attribute types (not relation types). + attributeTypeDiscriminators := map[string]bool{ + "AttributeType": true, + "StringAttributeType": true, + "ScriptAttributeType": true, + "BooleanAttributeType": true, + "DateAttributeType": true, + "NumericAttributeType": true, + "SingleValueListAttributeType": true, + "MultiValueListAttributeType": true, + } + + seen := make(map[string]bool) + var extracted []PrepareAddBusinessTermExtractedAssignment + for _, assignment := range assignments { + for _, ref := range assignment.AssignedCharacteristicTypeReferences { + disc := ref.AssignedResourceReference.ResourceDiscriminator + if !attributeTypeDiscriminators[disc] { + continue + } + attrTypeID := ref.AssignedResourceReference.ID + if attrTypeID == "" || seen[attrTypeID] { + continue + } + seen[attrTypeID] = true + extracted = append(extracted, PrepareAddBusinessTermExtractedAssignment{ + AttributeTypeID: attrTypeID, + Required: ref.MinimumOccurrences > 0, + }) + } + } + return extracted, nil +} + +// PrepareAddBusinessTermGetAttributeType gets the full attribute type schema by ID. +func PrepareAddBusinessTermGetAttributeType(ctx context.Context, client *http.Client, attributeTypeID string) (*PrepareAddBusinessTermAttributeTypeResponse, error) { + reqURL := fmt.Sprintf("/rest/2.0/attributeTypes/%s", url.PathEscape(attributeTypeID)) + 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 %s: %w", attributeTypeID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("getting attribute type %s: status %d: %s", attributeTypeID, resp.StatusCode, string(body)) + } + + var attrType PrepareAddBusinessTermAttributeTypeResponse + if err := json.NewDecoder(resp.Body).Decode(&attrType); err != nil { + return nil, fmt.Errorf("decoding attribute type response: %w", err) + } + return &attrType, nil +} + +// PrepareAddBusinessTermSearchAssets searches for assets matching the given criteria. +func PrepareAddBusinessTermSearchAssets(ctx context.Context, client *http.Client, name string, assetTypeID string, domainID string) (*PrepareAddBusinessTermSearchAssetsResponse, error) { + params := url.Values{} + if name != "" { + params.Set("name", name) + params.Set("nameMatchMode", "EXACT") + } + if assetTypeID != "" { + params.Set("typeIds", assetTypeID) + } + if domainID != "" { + params.Set("domainId", domainID) + } + params.Set("limit", "10") + params.Set("offset", "0") + + reqURL := "/rest/2.0/assets?" + 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 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 PrepareAddBusinessTermSearchAssetsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding search assets response: %w", err) + } + return &result, nil +} diff --git a/pkg/tools/add_data_classification_match.go b/pkg/tools/add_data_classification_match.go index 9a860eb..e3afa06 100644 --- a/pkg/tools/add_data_classification_match.go +++ b/pkg/tools/add_data_classification_match.go @@ -23,7 +23,7 @@ type AddDataClassificationMatchOutput struct { func NewAddDataClassificationMatchTool(collibraClient *http.Client) *chip.Tool[AddDataClassificationMatchInput, AddDataClassificationMatchOutput] { return &chip.Tool[AddDataClassificationMatchInput, AddDataClassificationMatchOutput]{ - 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: handleAddClassificationMatch(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog"}, diff --git a/pkg/tools/ask_glossary.go b/pkg/tools/discover_business_glossary.go similarity index 80% rename from pkg/tools/ask_glossary.go rename to pkg/tools/discover_business_glossary.go index efd7dac..9a72605 100644 --- a/pkg/tools/ask_glossary.go +++ b/pkg/tools/discover_business_glossary.go @@ -18,8 +18,8 @@ type AskGlossaryOutput struct { func NewAskGlossaryTool(collibraHttpClient *http.Client) *chip.Tool[AskGlossaryInput, AskGlossaryOutput] { return &chip.Tool[AskGlossaryInput, AskGlossaryOutput]{ - 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: handleAskGlossary(collibraHttpClient), Permissions: []string{"dgc.ai-copilot"}, } diff --git a/pkg/tools/ask_glossary_test.go b/pkg/tools/discover_business_glossary_test.go similarity index 100% rename from pkg/tools/ask_glossary_test.go rename to pkg/tools/discover_business_glossary_test.go diff --git a/pkg/tools/ask_dad.go b/pkg/tools/discover_data_assets.go similarity index 81% rename from pkg/tools/ask_dad.go rename to pkg/tools/discover_data_assets.go index 6564ca6..c588cf2 100644 --- a/pkg/tools/ask_dad.go +++ b/pkg/tools/discover_data_assets.go @@ -18,8 +18,8 @@ type AskDadOutput struct { func NewAskDadTool(collibraClient *http.Client) *chip.Tool[AskDadInput, AskDadOutput] { return &chip.Tool[AskDadInput, AskDadOutput]{ - 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: handleAskDad(collibraClient), Permissions: []string{"dgc.ai-copilot"}, } diff --git a/pkg/tools/ask_dad_test.go b/pkg/tools/discover_data_assets_test.go similarity index 100% rename from pkg/tools/ask_dad_test.go rename to pkg/tools/discover_data_assets_test.go diff --git a/pkg/tools/get_asset_details.go b/pkg/tools/get_asset_details.go index 65985fb..e332fad 100644 --- a/pkg/tools/get_asset_details.go +++ b/pkg/tools/get_asset_details.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 AssetDetailsInput struct { } type AssetDetailsOutput 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 NewAssetDetailsTool(collibraClient *http.Client) *chip.Tool[AssetDetailsInput, AssetDetailsOutput] { return &chip.Tool[AssetDetailsInput, AssetDetailsOutput]{ - 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: handleAssetDetails(collibraClient), Permissions: []string{}, } @@ -70,10 +81,103 @@ func handleAssetDetails(collibraClient *http.Client) chip.ToolHandlerFunc[AssetD 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 AssetDetailsOutput{ - 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 + } + + // Collect unique owner IDs by type to avoid duplicate lookups + ownerNames := resolveOwnerNames(ctx, collibraClient, responsibilities) + + result := make([]AssetResponsibility, 0, len(responsibilities)) + for _, r := range responsibilities { + entry := AssetResponsibility{} + 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 +} + +// resolveOwnerNames fetches display names for all unique owners in parallel. +// Returns a map of owner ID to resolved display name. +func resolveOwnerNames(ctx context.Context, collibraClient *http.Client, responsibilities []clients.Responsibility) map[string]string { + // Deduplicate owners by ID + owners := make(map[string]*clients.ResourceRef) + for _, r := range responsibilities { + if r.Owner != nil { + owners[r.Owner.ID] = r.Owner + } + } + + names := make(map[string]string, len(owners)) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, owner := range owners { + wg.Add(1) + go func(o *clients.ResourceRef) { + 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_test.go b/pkg/tools/get_asset_details_test.go index cadc930..0803840 100644 --- a/pkg/tools/get_asset_details_test.go +++ b/pkg/tools/get_asset_details_test.go @@ -3,6 +3,7 @@ package tools_test import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/collibra/chip/pkg/clients" @@ -25,6 +26,13 @@ func TestGetAssetDetails(t *testing.T) { }, } })) + handler.Handle("/rest/2.0/responsibilities", 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() @@ -40,8 +48,154 @@ 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 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", 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", 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", 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", 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", 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 := newClient(server) + + output, err := tools.NewAssetDetailsTool(client).Handler(t.Context(), tools.AssetDetailsInput{ + AssetID: assetId.String(), + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if !output.Found { + t.Fatalf("Asset not found") + } + + 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.go b/pkg/tools/get_business_term_data.go new file mode 100644 index 0000000..06c391e --- /dev/null +++ b/pkg/tools/get_business_term_data.go @@ -0,0 +1,98 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type BusinessTermDataGetInput struct { + BusinessTermID string `json:"businessTermId" jsonschema:"Required. The UUID of the Business Term asset to trace back to physical data assets."` +} + +type BusinessTermDataGetOutput struct { + BusinessTermID string `json:"businessTermId" jsonschema:"The Business Term asset ID."` + ConnectedPhysicalData []BusinessTermDataAttribute `json:"connectedPhysicalData" jsonschema:"The data attributes with their connected columns and tables."` + Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed."` +} + +type BusinessTermDataAttribute struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + Description string `json:"description"` + ConnectedColumns []ColumnWithTable `json:"connectedColumns"` +} + +func NewBusinessTermDataGetTool(collibraClient *http.Client) *chip.Tool[BusinessTermDataGetInput, BusinessTermDataGetOutput] { + return &chip.Tool[BusinessTermDataGetInput, BusinessTermDataGetOutput]{ + Name: "get_business_term_data", + Description: "Retrieve the physical data assets (Columns and Tables) associated with a Business Term via the path Business Term → Data Attribute → Column → Table.", + Handler: handleBusinessTermDataGet(collibraClient), + Permissions: []string{}, + } +} + +func handleBusinessTermDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[BusinessTermDataGetInput, BusinessTermDataGetOutput] { + return func(ctx context.Context, input BusinessTermDataGetInput) (BusinessTermDataGetOutput, error) { + if input.BusinessTermID == "" { + return BusinessTermDataGetOutput{Error: "businessTermId is required"}, nil + } + + dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.BusinessTermID, clients.GenericConnectedAssetRelID) + if err != nil { + return BusinessTermDataGetOutput{}, err + } + + physicalData := make([]BusinessTermDataAttribute, 0, len(dataAttributes)) + for _, da := range dataAttributes { + daDescription := clients.FetchDescription(ctx, collibraClient, da.ID) + + columns, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, da.ID) + if err != nil { + return BusinessTermDataGetOutput{}, err + } + + 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 BusinessTermDataGetOutput{}, 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, BusinessTermDataAttribute{ + ID: da.ID, + Name: da.Name, + AssetType: da.AssetType, + Description: daDescription, + ConnectedColumns: columnsWithDetails, + }) + } + + return BusinessTermDataGetOutput{ + BusinessTermID: input.BusinessTermID, + ConnectedPhysicalData: physicalData, + }, nil + } +} diff --git a/pkg/tools/get_column_semantics.go b/pkg/tools/get_column_semantics.go new file mode 100644 index 0000000..a4fb918 --- /dev/null +++ b/pkg/tools/get_column_semantics.go @@ -0,0 +1,103 @@ +package tools + +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 ColumnSemanticsGetInput struct { + ColumnID string `json:"columnId" jsonschema:"Required. The UUID of the column asset to retrieve semantics for."` +} + +type ColumnSemanticsGetOutput 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 NewColumnSemanticsGetTool(collibraClient *http.Client) *chip.Tool[ColumnSemanticsGetInput, ColumnSemanticsGetOutput] { + return &chip.Tool[ColumnSemanticsGetInput, ColumnSemanticsGetOutput]{ + Name: "get_column_semantics", + Description: "Retrieve all connected Data Attribute assets for a Column, including descriptions and related Measures and generic business assets with their descriptions.", + Handler: handleColumnSemanticsGet(collibraClient), + Permissions: []string{}, + } +} + +func handleColumnSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[ColumnSemanticsGetInput, ColumnSemanticsGetOutput] { + return func(ctx context.Context, input ColumnSemanticsGetInput) (ColumnSemanticsGetOutput, error) { + if input.ColumnID == "" { + return ColumnSemanticsGetOutput{Error: "columnId is required"}, nil + } + + dataAttributes, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, input.ColumnID) + if err != nil { + return ColumnSemanticsGetOutput{}, 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 ColumnSemanticsGetOutput{}, 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 ColumnSemanticsGetOutput{}, 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 ColumnSemanticsGetOutput{Semantics: semantics}, nil + } +} diff --git a/pkg/tools/get_lineage_downstream.go b/pkg/tools/get_lineage_downstream.go new file mode 100644 index 0000000..7d2b492 --- /dev/null +++ b/pkg/tools/get_lineage_downstream.go @@ -0,0 +1,31 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type GetLineageDownstreamInput struct { + EntityId string `json:"entityId" jsonschema:"Required. ID of the entity to trace downstream from. Can be numeric string or DGC UUID."` + EntityType string `json:"entityType,omitempty" jsonschema:"Optional. Filter to only include entities of this type (e.g. 'table', 'report'). Useful when you only care about specific downstream asset types."` + Limit int `json:"limit,omitempty" jsonschema:"Optional. Max relations per page. Default: 20, Min: 1, Max: 100."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` +} + +func NewGetLineageDownstreamTool(collibraClient *http.Client) *chip.Tool[GetLineageDownstreamInput, clients.GetLineageDirectionalOutput] { + return &chip.Tool[GetLineageDownstreamInput, clients.GetLineageDirectionalOutput]{ + Name: "get_lineage_downstream", + Description: "Get the downstream technical lineage graph for a data entity -- all direct and indirect consumer entities that are impacted by it, along with the transformations connecting them. This traces through all data objects across external systems (including unregistered assets, temporary tables, and source code), not just assets in the Collibra Data Catalog. Use this to answer \"What depends on this data?\" or \"If this table changes, what else is affected?\" Essential for impact analysis before modifying or deprecating a data asset. Results are paginated.", + Handler: handleGetLineageDownstream(collibraClient), + Permissions: []string{}, + } +} + +func handleGetLineageDownstream(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageDownstreamInput, clients.GetLineageDirectionalOutput] { + return func(ctx context.Context, input GetLineageDownstreamInput) (clients.GetLineageDirectionalOutput, error) { + return handleLineageDirectional(ctx, collibraClient, input.EntityId, input.EntityType, input.Limit, input.Cursor, clients.GetLineageDownstream) + } +} diff --git a/pkg/tools/get_lineage_downstream_test.go b/pkg/tools/get_lineage_downstream_test.go new file mode 100644 index 0000000..85ecf6a --- /dev/null +++ b/pkg/tools/get_lineage_downstream_test.go @@ -0,0 +1,103 @@ +package tools_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools" +) + +func TestGetLineageDownstream(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/downstream", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + 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 := newClient(server) + output, err := tools.NewGetLineageDownstreamTool(client).Handler(t.Context(), tools.GetLineageDownstreamInput{ + 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", JsonHandlerOut(func(r *http.Request) (int, string) { + return http.StatusNotFound, "entity not found" + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewGetLineageDownstreamTool(client).Handler(t.Context(), tools.GetLineageDownstreamInput{ + 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 := newClient(server) + output, err := tools.NewGetLineageDownstreamTool(client).Handler(t.Context(), tools.GetLineageDownstreamInput{}) + 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.go b/pkg/tools/get_lineage_entity.go new file mode 100644 index 0000000..9a08e1d --- /dev/null +++ b/pkg/tools/get_lineage_entity.go @@ -0,0 +1,37 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type GetLineageEntityInput struct { + EntityId string `json:"entityId" jsonschema:"Required. Unique identifier of the data entity. Can be a numeric string (e.g. '12345') or a DGC UUID (e.g. '550e8400-e29b-41d4-a716-446655440000')."` +} + +func NewGetLineageEntityTool(collibraClient *http.Client) *chip.Tool[GetLineageEntityInput, clients.GetLineageEntityOutput] { + return &chip.Tool[GetLineageEntityInput, clients.GetLineageEntityOutput]{ + Name: "get_lineage_entity", + Description: "Get detailed metadata about a specific data entity in the technical lineage graph. Technical lineage covers all data objects across external systems -- including source code, transformations, and temporary tables -- regardless of whether they are registered in Collibra (unlike business lineage, which only covers assets ingested into the Data Catalog). An entity represents any tracked data asset such as a table, column, file, report, API endpoint, or topic. Returns the entity's name, type, source systems, parent entity, and linked Data Governance Catalog (DGC) identifier. Use this when you have an entity ID from a lineage traversal, search result, or user input and need its full details.", + Handler: handleGetLineageEntity(collibraClient), + Permissions: []string{}, + } +} + +func handleGetLineageEntity(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageEntityInput, clients.GetLineageEntityOutput] { + return func(ctx context.Context, input GetLineageEntityInput) (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_test.go b/pkg/tools/get_lineage_entity_test.go new file mode 100644 index 0000000..ca8c84a --- /dev/null +++ b/pkg/tools/get_lineage_entity_test.go @@ -0,0 +1,93 @@ +package tools_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools" +) + +func TestGetLineageEntity(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1", JsonHandlerOut(func(r *http.Request) (int, clients.LineageEntity) { + return http.StatusOK, clients.LineageEntity{ + Id: "entity-1", + Name: "my_table", + Type: "table", + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewGetLineageEntityTool(client).Handler(t.Context(), tools.GetLineageEntityInput{ + 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", JsonHandlerOut(func(r *http.Request) (int, string) { + return http.StatusNotFound, "entity not found" + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewGetLineageEntityTool(client).Handler(t.Context(), tools.GetLineageEntityInput{ + 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 := newClient(server) + output, err := tools.NewGetLineageEntityTool(client).Handler(t.Context(), tools.GetLineageEntityInput{}) + 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.go b/pkg/tools/get_lineage_transformation.go new file mode 100644 index 0000000..07ea994 --- /dev/null +++ b/pkg/tools/get_lineage_transformation.go @@ -0,0 +1,37 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type GetLineageTransformationInput struct { + TransformationId string `json:"transformationId" jsonschema:"Required. ID of the transformation to be fetched (e.g. '67890')."` +} + +func NewGetLineageTransformationTool(collibraClient *http.Client) *chip.Tool[GetLineageTransformationInput, clients.GetLineageTransformationOutput] { + return &chip.Tool[GetLineageTransformationInput, clients.GetLineageTransformationOutput]{ + Name: "get_lineage_transformation", + Description: "Get detailed information about a specific data transformation, including its SQL or script logic. A transformation represents a data processing activity (ETL job, SQL query, script, etc.) that connects source entities to target entities in the lineage graph. Use this when you found a transformation ID in an upstream/downstream lineage result and want to see what the transformation actually does -- the SQL query, script content, or processing logic.", + Handler: handleGetLineageTransformation(collibraClient), + Permissions: []string{}, + } +} + +func handleGetLineageTransformation(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageTransformationInput, clients.GetLineageTransformationOutput] { + return func(ctx context.Context, input GetLineageTransformationInput) (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_test.go b/pkg/tools/get_lineage_transformation_test.go new file mode 100644 index 0000000..ef59766 --- /dev/null +++ b/pkg/tools/get_lineage_transformation_test.go @@ -0,0 +1,93 @@ +package tools_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/tools" +) + +func TestGetLineageTransformation(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations/transform-1", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + 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 := newClient(server) + output, err := tools.NewGetLineageTransformationTool(client).Handler(t.Context(), tools.GetLineageTransformationInput{ + 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", JsonHandlerOut(func(r *http.Request) (int, string) { + return http.StatusNotFound, "transformation not found" + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewGetLineageTransformationTool(client).Handler(t.Context(), tools.GetLineageTransformationInput{ + 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 := newClient(server) + output, err := tools.NewGetLineageTransformationTool(client).Handler(t.Context(), tools.GetLineageTransformationInput{}) + 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.go b/pkg/tools/get_lineage_upstream.go new file mode 100644 index 0000000..6fa5ca0 --- /dev/null +++ b/pkg/tools/get_lineage_upstream.go @@ -0,0 +1,50 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type GetLineageUpstreamInput struct { + EntityId string `json:"entityId" jsonschema:"Required. ID of the entity to trace upstream from. Can be numeric string or DGC UUID."` + EntityType string `json:"entityType,omitempty" jsonschema:"Optional. Filter to only include entities of this type (e.g. 'table', 'column'). Useful when you only care about specific upstream asset types."` + Limit int `json:"limit,omitempty" jsonschema:"Optional. Max relations per page. Default: 20, Min: 1, Max: 100."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` +} + +func NewGetLineageUpstreamTool(collibraClient *http.Client) *chip.Tool[GetLineageUpstreamInput, clients.GetLineageDirectionalOutput] { + return &chip.Tool[GetLineageUpstreamInput, clients.GetLineageDirectionalOutput]{ + Name: "get_lineage_upstream", + Description: "Get the upstream technical lineage graph for a data entity -- all direct and indirect source entities that feed data into it, along with the transformations connecting them. This traces through all data objects across external systems (including unregistered assets, temporary tables, and source code), not just assets in the Collibra Data Catalog. Use this to answer \"Where does this data come from?\" or \"What are the sources feeding this table?\" Each relation in the result connects a source entity to a target entity through one or more transformations. Results are paginated.", + Handler: handleGetLineageUpstream(collibraClient), + Permissions: []string{}, + } +} + +func handleGetLineageUpstream(collibraClient *http.Client) chip.ToolHandlerFunc[GetLineageUpstreamInput, clients.GetLineageDirectionalOutput] { + return func(ctx context.Context, input GetLineageUpstreamInput) (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_test.go b/pkg/tools/get_lineage_upstream_test.go new file mode 100644 index 0000000..43b97b4 --- /dev/null +++ b/pkg/tools/get_lineage_upstream_test.go @@ -0,0 +1,103 @@ +package tools_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools" +) + +func TestGetLineageUpstream(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities/entity-1/upstream", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + 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 := newClient(server) + output, err := tools.NewGetLineageUpstreamTool(client).Handler(t.Context(), tools.GetLineageUpstreamInput{ + 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", JsonHandlerOut(func(r *http.Request) (int, string) { + return http.StatusNotFound, "entity not found" + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewGetLineageUpstreamTool(client).Handler(t.Context(), tools.GetLineageUpstreamInput{ + 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 := newClient(server) + output, err := tools.NewGetLineageUpstreamTool(client).Handler(t.Context(), tools.GetLineageUpstreamInput{}) + 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.go b/pkg/tools/get_measure_data.go new file mode 100644 index 0000000..0ac5652 --- /dev/null +++ b/pkg/tools/get_measure_data.go @@ -0,0 +1,99 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +// 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 MeasureDataGetInput struct { + MeasureID string `json:"measureId" jsonschema:"Required. The UUID of the measure asset to trace back to its underlying physical columns."` +} + +type MeasureDataGetOutput struct { + DataHierarchy []MeasureDataAttribute `json:"dataHierarchy" jsonschema:"The list of data attributes with their connected columns and tables."` + Error string `json:"error,omitempty" jsonschema:"Error message if the operation failed."` +} + +type MeasureDataAttribute struct { + ID string `json:"id"` + Name string `json:"name"` + AssetType string `json:"assetType"` + ConnectedColumns []ColumnWithTable `json:"connectedColumns"` +} + +func NewMeasureDataGetTool(collibraClient *http.Client) *chip.Tool[MeasureDataGetInput, MeasureDataGetOutput] { + return &chip.Tool[MeasureDataGetInput, MeasureDataGetOutput]{ + Name: "get_measure_data", + Description: "Retrieve all underlying Column assets connected to a Measure via the path Measure → Data Attribute → Column, including each Column's description and parent Table.", + Handler: handleMeasureDataGet(collibraClient), + Permissions: []string{}, + } +} + +func handleMeasureDataGet(collibraClient *http.Client) chip.ToolHandlerFunc[MeasureDataGetInput, MeasureDataGetOutput] { + return func(ctx context.Context, input MeasureDataGetInput) (MeasureDataGetOutput, error) { + if input.MeasureID == "" { + return MeasureDataGetOutput{Error: "measureId is required"}, nil + } + + dataAttributes, err := clients.FindConnectedAssets(ctx, collibraClient, input.MeasureID, clients.DataAttributeRepresentsMeasureRelID) + if err != nil { + return MeasureDataGetOutput{}, err + } + + hierarchy := make([]MeasureDataAttribute, 0, len(dataAttributes)) + for _, da := range dataAttributes { + columns, err := clients.FindColumnsForDataAttribute(ctx, collibraClient, da.ID) + if err != nil { + return MeasureDataGetOutput{}, 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 MeasureDataGetOutput{}, 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, MeasureDataAttribute{ + ID: da.ID, + Name: da.Name, + AssetType: da.AssetType, + ConnectedColumns: columnsWithDetails, + }) + } + + return MeasureDataGetOutput{DataHierarchy: hierarchy}, nil + } +} diff --git a/pkg/tools/get_table_semantics.go b/pkg/tools/get_table_semantics.go new file mode 100644 index 0000000..88d20cd --- /dev/null +++ b/pkg/tools/get_table_semantics.go @@ -0,0 +1,108 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type TableSemanticsGetInput struct { + TableID string `json:"tableId" jsonschema:"Required. The UUID of the Table asset to retrieve semantics for."` +} + +type TableSemanticsGetOutput 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 NewTableSemanticsGetTool(collibraClient *http.Client) *chip.Tool[TableSemanticsGetInput, TableSemanticsGetOutput] { + return &chip.Tool[TableSemanticsGetInput, TableSemanticsGetOutput]{ + Name: "get_table_semantics", + Description: "Retrieve the semantic layer for a Table asset: Columns, their Data Attributes, and connected Measures. Answers 'What is the semantic context of this table?' or 'Which metrics use data from this table?'.", + Handler: handleTableSemanticsGet(collibraClient), + Permissions: []string{}, + } +} + +func handleTableSemanticsGet(collibraClient *http.Client) chip.ToolHandlerFunc[TableSemanticsGetInput, TableSemanticsGetOutput] { + return func(ctx context.Context, input TableSemanticsGetInput) (TableSemanticsGetOutput, error) { + if input.TableID == "" { + return TableSemanticsGetOutput{Error: "tableId is required"}, nil + } + + rawColumns, err := clients.FindConnectedAssets(ctx, collibraClient, input.TableID, clients.ColumnToTableRelID) + if err != nil { + return TableSemanticsGetOutput{}, 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 TableSemanticsGetOutput{}, 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 TableSemanticsGetOutput{}, 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 TableSemanticsGetOutput{ + TableID: input.TableID, + SemanticHierarchy: columns, + }, nil + } +} diff --git a/pkg/tools/list_asset_types.go b/pkg/tools/list_asset_types.go index be8576b..21fd3a4 100644 --- a/pkg/tools/list_asset_types.go +++ b/pkg/tools/list_asset_types.go @@ -34,7 +34,7 @@ type AssetType struct { func NewListAssetTypesTool(collibraClient *http.Client) *chip.Tool[ListAssetTypesInput, ListAssetTypesOutput] { return &chip.Tool[ListAssetTypesInput, ListAssetTypesOutput]{ - Name: "asset_types_list", + Name: "list_asset_types", Description: "List asset types available in Collibra with their properties and metadata.", Handler: handleListAssetTypes(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/list_data_contracts.go b/pkg/tools/list_data_contracts.go index 28d82a3..9833343 100644 --- a/pkg/tools/list_data_contracts.go +++ b/pkg/tools/list_data_contracts.go @@ -29,7 +29,7 @@ type DataContract struct { func NewListDataContractsTool(collibraClient *http.Client) *chip.Tool[ListDataContractsInput, ListDataContractsOutput] { return &chip.Tool[ListDataContractsInput, ListDataContractsOutput]{ - 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: handleListDataContracts(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/prepare_add_business_term.go b/pkg/tools/prepare_add_business_term.go new file mode 100644 index 0000000..ea01e63 --- /dev/null +++ b/pkg/tools/prepare_add_business_term.go @@ -0,0 +1,246 @@ +package tools + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +// PrepareAddBusinessTermInput defines the input for the prepare_add_business_term tool. +type PrepareAddBusinessTermInput struct { + Name string `json:"name" jsonschema:"The name of the business term to create"` + DomainID string `json:"domain_id,omitempty" jsonschema:"Optional. The ID of the domain to create the business term in"` + DomainName string `json:"domain_name,omitempty" jsonschema:"Optional. The name of the domain to search for"` + Description string `json:"description,omitempty" jsonschema:"Optional. Description of the business term"` +} + +// PrepareAddBusinessTermDomainInfo represents resolved domain information in the output. +type PrepareAddBusinessTermDomainInfo struct { + ID string `json:"id" jsonschema:"Domain identifier"` + Name string `json:"name" jsonschema:"Domain name"` + Description string `json:"description" jsonschema:"Domain description"` +} + +// PrepareAddBusinessTermDuplicateInfo represents a duplicate business term found. +type PrepareAddBusinessTermDuplicateInfo struct { + ID string `json:"id" jsonschema:"Asset identifier"` + Name string `json:"name" jsonschema:"Asset name"` + DomainID string `json:"domain_id" jsonschema:"Domain identifier of the duplicate"` + Description string `json:"description" jsonschema:"Asset description"` +} + +// PrepareAddBusinessTermConstraintInfo represents constraints on an attribute. +type PrepareAddBusinessTermConstraintInfo struct { + MinLength int `json:"min_length,omitempty" jsonschema:"Optional. Minimum length constraint"` + MaxLength int `json:"max_length,omitempty" jsonschema:"Optional. Maximum length constraint"` + Pattern string `json:"pattern,omitempty" jsonschema:"Optional. Regex pattern constraint"` + AllowedValues []string `json:"allowed_values,omitempty" jsonschema:"Optional. List of allowed values"` +} + +// PrepareAddBusinessTermRelationTypeInfo represents a relation type in the attribute schema. +type PrepareAddBusinessTermRelationTypeInfo struct { + ID string `json:"id" jsonschema:"Relation type identifier"` + Name string `json:"name" jsonschema:"Relation type name"` + Direction string `json:"direction" jsonschema:"Relation direction (e.g. outgoing, incoming)"` + TargetAssetTypeID string `json:"target_asset_type_id" jsonschema:"Target asset type identifier"` +} + +// PrepareAddBusinessTermAttributeInfo represents a hydrated attribute in the schema. +type PrepareAddBusinessTermAttributeInfo struct { + ID string `json:"id" jsonschema:"Attribute type identifier"` + Name string `json:"name" jsonschema:"Attribute type name"` + Kind string `json:"kind" jsonschema:"Attribute kind (e.g. String, Boolean, Numeric)"` + Required bool `json:"required" jsonschema:"Whether this attribute is required for the business term"` + Description string `json:"description" jsonschema:"Attribute description"` + Constraints *PrepareAddBusinessTermConstraintInfo `json:"constraints,omitempty" jsonschema:"Optional. Constraints for this attribute"` + RelationTypes []PrepareAddBusinessTermRelationTypeInfo `json:"relation_types,omitempty" jsonschema:"Optional. Relation types with direction and target"` +} + +// PrepareAddBusinessTermOutput defines the output for the prepare_add_business_term tool. +type PrepareAddBusinessTermOutput struct { + Status string `json:"status" jsonschema:"Preparation status: ready, incomplete, needs_clarification, or duplicate_found"` + Message string `json:"message" jsonschema:"Human-readable explanation of the current status"` + Domain *PrepareAddBusinessTermDomainInfo `json:"domain,omitempty" jsonschema:"Optional. Resolved domain information"` + AvailableDomains []PrepareAddBusinessTermDomainInfo `json:"available_domains,omitempty" jsonschema:"Optional. Available domains for selection when domain is missing or ambiguous"` + Duplicates []PrepareAddBusinessTermDuplicateInfo `json:"duplicates,omitempty" jsonschema:"Optional. Duplicate business terms found in the target domain"` + AttributeSchema []PrepareAddBusinessTermAttributeInfo `json:"attribute_schema,omitempty" jsonschema:"Optional. Hydrated attribute schema for business term creation"` +} + +// NewPrepareAddBusinessTermTool creates a new prepare_add_business_term tool instance. +func NewPrepareAddBusinessTermTool(collibraClient *http.Client) *chip.Tool[PrepareAddBusinessTermInput, PrepareAddBusinessTermOutput] { + return &chip.Tool[PrepareAddBusinessTermInput, PrepareAddBusinessTermOutput]{ + Name: "prepare_add_business_term", + Description: "Validate and prepare business term creation by resolving domains, checking for duplicates, and hydrating attribute schemas. Returns a structured status indicating readiness.", + Handler: handlePrepareAddBusinessTerm(collibraClient), + Permissions: []string{"dgc.ai-copilot"}, + } +} + +func handlePrepareAddBusinessTerm(collibraClient *http.Client) chip.ToolHandlerFunc[PrepareAddBusinessTermInput, PrepareAddBusinessTermOutput] { + return func(ctx context.Context, input PrepareAddBusinessTermInput) (PrepareAddBusinessTermOutput, error) { + // Step 1: Check if name is provided + if input.Name == "" { + domains, _ := clients.PrepareAddBusinessTermListDomains(ctx, collibraClient) + return PrepareAddBusinessTermOutput{ + Status: "incomplete", + Message: "Business term name is required.", + AvailableDomains: convertDomainResponses(domains), + }, nil + } + + // Step 2: Resolve domain + var resolvedDomain *PrepareAddBusinessTermDomainInfo + + if input.DomainID != "" { + d, err := clients.PrepareAddBusinessTermGetDomain(ctx, collibraClient, input.DomainID) + if err != nil { + domains, _ := clients.PrepareAddBusinessTermListDomains(ctx, collibraClient) + return PrepareAddBusinessTermOutput{ + Status: "needs_clarification", + Message: fmt.Sprintf("Domain with ID '%s' was not found. Please select a valid domain.", input.DomainID), + AvailableDomains: convertDomainResponses(domains), + }, nil + } + resolvedDomain = &PrepareAddBusinessTermDomainInfo{ + ID: d.ID, + Name: d.Name, + Description: d.Description, + } + } else if input.DomainName != "" { + domains, err := clients.PrepareAddBusinessTermListDomains(ctx, collibraClient) + if err != nil { + return PrepareAddBusinessTermOutput{}, fmt.Errorf("listing domains: %w", err) + } + var matches []PrepareAddBusinessTermDomainInfo + for _, d := range domains { + if strings.EqualFold(d.Name, input.DomainName) { + matches = append(matches, PrepareAddBusinessTermDomainInfo{ + ID: d.ID, + Name: d.Name, + Description: d.Description, + }) + } + } + if len(matches) == 0 { + return PrepareAddBusinessTermOutput{ + Status: "needs_clarification", + Message: fmt.Sprintf("No domain found matching name '%s'. Please select from available domains.", input.DomainName), + AvailableDomains: convertDomainResponses(domains), + }, nil + } else if len(matches) > 1 { + return PrepareAddBusinessTermOutput{ + Status: "needs_clarification", + Message: fmt.Sprintf("Multiple domains match name '%s'. Please select the correct domain.", input.DomainName), + AvailableDomains: matches, + }, nil + } + resolvedDomain = &matches[0] + } else { + domains, _ := clients.PrepareAddBusinessTermListDomains(ctx, collibraClient) + return PrepareAddBusinessTermOutput{ + Status: "incomplete", + Message: "Domain is required. Please provide a domain_id or domain_name.", + AvailableDomains: convertDomainResponses(domains), + }, nil + } + + // Step 3: Get business term asset type + assetType, err := clients.PrepareAddBusinessTermGetAssetType(ctx, collibraClient, clients.BusinessTermAssetTypePublicID) + if err != nil { + return PrepareAddBusinessTermOutput{}, fmt.Errorf("getting business term asset type: %w", err) + } + + // Step 4: Check for duplicates + searchResult, err := clients.PrepareAddBusinessTermSearchAssets(ctx, collibraClient, input.Name, assetType.ID, resolvedDomain.ID) + if err != nil { + return PrepareAddBusinessTermOutput{}, fmt.Errorf("searching for duplicate assets: %w", err) + } + if len(searchResult.Results) > 0 { + var duplicates []PrepareAddBusinessTermDuplicateInfo + for _, a := range searchResult.Results { + duplicates = append(duplicates, PrepareAddBusinessTermDuplicateInfo{ + ID: a.ID, + Name: a.Name, + DomainID: a.Domain.ID, + Description: a.Description, + }) + } + return PrepareAddBusinessTermOutput{ + Status: "duplicate_found", + Message: fmt.Sprintf("Found %d existing business term(s) with name '%s' in the specified domain.", len(duplicates), input.Name), + Domain: resolvedDomain, + Duplicates: duplicates, + }, nil + } + + // Step 5: Get attribute schema + assignments, err := clients.PrepareAddBusinessTermGetAssignments(ctx, collibraClient, assetType.ID) + if err != nil { + return PrepareAddBusinessTermOutput{}, fmt.Errorf("getting attribute assignments: %w", err) + } + + var schema []PrepareAddBusinessTermAttributeInfo + for _, assignment := range assignments { + attrType, err := clients.PrepareAddBusinessTermGetAttributeType(ctx, collibraClient, assignment.AttributeTypeID) + if err != nil { + return PrepareAddBusinessTermOutput{}, fmt.Errorf("getting attribute type %s: %w", assignment.AttributeTypeID, err) + } + + attr := PrepareAddBusinessTermAttributeInfo{ + ID: attrType.ID, + Name: attrType.Name, + Kind: attrType.Kind, + Required: assignment.Required, + Description: attrType.Description, + } + + if attrType.Constraints != nil { + attr.Constraints = &PrepareAddBusinessTermConstraintInfo{ + MinLength: attrType.Constraints.MinLength, + MaxLength: attrType.Constraints.MaxLength, + Pattern: attrType.Constraints.Pattern, + AllowedValues: attrType.Constraints.AllowedValues, + } + } + + for _, rt := range attrType.RelationTypes { + attr.RelationTypes = append(attr.RelationTypes, PrepareAddBusinessTermRelationTypeInfo{ + ID: rt.ID, + Name: rt.Name, + Direction: rt.Direction, + TargetAssetTypeID: rt.TargetAssetTypeID, + }) + } + + schema = append(schema, attr) + } + + // Step 6: Return ready + return PrepareAddBusinessTermOutput{ + Status: "ready", + Message: fmt.Sprintf("Business term '%s' is ready to be created in domain '%s'.", input.Name, resolvedDomain.Name), + Domain: resolvedDomain, + AttributeSchema: schema, + }, nil + } +} + +// convertDomainResponses converts client domain responses to output domain info. +func convertDomainResponses(domains []clients.PrepareAddBusinessTermDomainResponse) []PrepareAddBusinessTermDomainInfo { + if domains == nil { + return nil + } + result := make([]PrepareAddBusinessTermDomainInfo, len(domains)) + for i, d := range domains { + result[i] = PrepareAddBusinessTermDomainInfo{ + ID: d.ID, + Name: d.Name, + Description: d.Description, + } + } + return result +} diff --git a/pkg/tools/prepare_add_business_term_test.go b/pkg/tools/prepare_add_business_term_test.go new file mode 100644 index 0000000..ba1a1f3 --- /dev/null +++ b/pkg/tools/prepare_add_business_term_test.go @@ -0,0 +1,521 @@ +package tools_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools" +) + +// newFullHandler creates a test mux with all endpoints configured for the happy path. +func newFullHandler() *http.ServeMux { + handler := http.NewServeMux() + + // GET /rest/2.0/domains - list all domains (paginated response) + handler.Handle("GET /rest/2.0/domains", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainPagedResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainPagedResponse{ + Results: []clients.PrepareAddBusinessTermDomainResponse{ + {ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain"}, + {ID: "domain-2", Name: "Technical Glossary", Description: "Technical terms"}, + }, + Total: 2, + } + })) + + // GET /rest/2.0/domains/{id} - get domain by ID + handler.Handle("GET /rest/2.0/domains/{id}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainResponse) { + domainID := r.PathValue("id") + if domainID == "domain-1" { + return http.StatusOK, clients.PrepareAddBusinessTermDomainResponse{ + ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain", + } + } + return http.StatusNotFound, clients.PrepareAddBusinessTermDomainResponse{} + })) + + // GET /rest/2.0/assetTypes/publicId/{publicId} - get asset type + handler.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermAssetTypeResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetTypeResponse{ + ID: "at-bt-1", + PublicID: clients.BusinessTermAssetTypePublicID, + Name: "Business Term", + } + })) + + // GET /rest/2.0/assets - search for duplicates + handler.Handle("GET /rest/2.0/assets", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermSearchAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermSearchAssetsResponse{ + Results: []clients.PrepareAddBusinessTermAssetResponse{}, + Total: 0, + } + })) + + // GET /rest/2.0/assignments/assetType/{id} - get assignments + handler.Handle("GET /rest/2.0/assignments/assetType/{id}", JsonHandlerOut(func(r *http.Request) (int, []clients.PrepareAddBusinessTermAssignmentResponse) { + return http.StatusOK, []clients.PrepareAddBusinessTermAssignmentResponse{ + { + ID: "assign-1", + AssignedCharacteristicTypeReferences: []clients.PrepareAddBusinessTermCharacteristicTypeRef{ + { + ID: "ref-1", + AssignedResourceReference: clients.PrepareAddBusinessTermResourceRef{ID: "attr-type-def", Name: "Definition", ResourceDiscriminator: "StringAttributeType"}, + MinimumOccurrences: 1, + }, + { + ID: "ref-2", + AssignedResourceReference: clients.PrepareAddBusinessTermResourceRef{ID: "attr-type-note", Name: "Note", ResourceDiscriminator: "StringAttributeType"}, + MinimumOccurrences: 0, + }, + }, + }, + } + })) + + // GET /rest/2.0/attributeTypes/{id} - get attribute type details + handler.Handle("GET /rest/2.0/attributeTypes/{id}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermAttributeTypeResponse) { + attrID := r.PathValue("id") + switch attrID { + case "attr-type-def": + return http.StatusOK, clients.PrepareAddBusinessTermAttributeTypeResponse{ + ID: "attr-type-def", + Name: "Definition", + Kind: "String", + Description: "The definition of the business term", + Constraints: &clients.PrepareAddBusinessTermConstraintsResponse{ + MinLength: 1, + MaxLength: 4000, + }, + RelationTypes: []clients.PrepareAddBusinessTermRelationTypeResponse{ + {ID: "rt-1", Name: "is related to", Direction: "outgoing", TargetAssetTypeID: "at-bt-1"}, + }, + } + case "attr-type-note": + return http.StatusOK, clients.PrepareAddBusinessTermAttributeTypeResponse{ + ID: "attr-type-note", + Name: "Note", + Kind: "String", + Description: "Additional notes", + } + default: + return http.StatusNotFound, clients.PrepareAddBusinessTermAttributeTypeResponse{} + } + })) + + return handler +} + +func TestPrepareAddBusinessTermReady(t *testing.T) { + server := httptest.NewServer(newFullHandler()) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + 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.Domain == nil { + t.Fatalf("Expected domain to be set") + } + if output.Domain.ID != "domain-1" { + t.Errorf("Expected domain ID 'domain-1', got '%s'", output.Domain.ID) + } + if output.Domain.Name != "Business Glossary" { + t.Errorf("Expected domain name 'Business Glossary', got '%s'", output.Domain.Name) + } + if len(output.AttributeSchema) != 2 { + t.Fatalf("Expected 2 attributes in schema, got %d", len(output.AttributeSchema)) + } + + // Check first attribute (Definition - required) + defAttr := output.AttributeSchema[0] + if defAttr.Name != "Definition" { + t.Errorf("Expected first attribute name 'Definition', got '%s'", defAttr.Name) + } + if defAttr.Kind != "String" { + t.Errorf("Expected attribute kind 'String', got '%s'", defAttr.Kind) + } + if !defAttr.Required { + t.Errorf("Expected Definition attribute to be required") + } + if defAttr.Constraints == nil { + t.Fatalf("Expected Definition attribute to have constraints") + } + if defAttr.Constraints.MinLength != 1 { + t.Errorf("Expected MinLength 1, got %d", defAttr.Constraints.MinLength) + } + if defAttr.Constraints.MaxLength != 4000 { + t.Errorf("Expected MaxLength 4000, got %d", defAttr.Constraints.MaxLength) + } + if len(defAttr.RelationTypes) != 1 { + t.Fatalf("Expected 1 relation type, got %d", len(defAttr.RelationTypes)) + } + if defAttr.RelationTypes[0].Direction != "outgoing" { + t.Errorf("Expected relation direction 'outgoing', got '%s'", defAttr.RelationTypes[0].Direction) + } + + // Check second attribute (Note - not required, no constraints) + noteAttr := output.AttributeSchema[1] + if noteAttr.Name != "Note" { + t.Errorf("Expected second attribute name 'Note', got '%s'", noteAttr.Name) + } + if noteAttr.Required { + t.Errorf("Expected Note attribute to not be required") + } + if noteAttr.Constraints != nil { + t.Errorf("Expected Note attribute to have no constraints") + } +} + +func TestPrepareAddBusinessTermReadyByDomainName(t *testing.T) { + server := httptest.NewServer(newFullHandler()) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + DomainName: "Business Glossary", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready', got '%s': %s", output.Status, output.Message) + } + if output.Domain == nil { + t.Fatalf("Expected domain to be set") + } + if output.Domain.ID != "domain-1" { + t.Errorf("Expected domain ID 'domain-1', got '%s'", output.Domain.ID) + } +} + +func TestPrepareAddBusinessTermDuplicateFound(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("GET /rest/2.0/domains/{id}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainResponse{ + ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain", + } + })) + + handler.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermAssetTypeResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetTypeResponse{ + ID: "at-bt-1", PublicID: clients.BusinessTermAssetTypePublicID, Name: "Business Term", + } + })) + + handler.Handle("GET /rest/2.0/assets", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermSearchAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermSearchAssetsResponse{ + Results: []clients.PrepareAddBusinessTermAssetResponse{ + { + ID: "existing-1", + Name: "Revenue", + Type: clients.PrepareAddBusinessTermResourceRef{ID: "at-bt-1", Name: "Business Term"}, + Domain: clients.PrepareAddBusinessTermResourceRef{ID: "domain-1", Name: "Business Glossary"}, + Description: "Existing revenue term", + }, + }, + Total: 1, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + 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-1" { + t.Errorf("Expected duplicate ID 'existing-1', got '%s'", output.Duplicates[0].ID) + } + if output.Domain == nil || output.Domain.ID != "domain-1" { + t.Errorf("Expected resolved domain to be included in duplicate_found response") + } +} + +func TestPrepareAddBusinessTermIncompleteMissingName(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("GET /rest/2.0/domains", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainPagedResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainPagedResponse{ + Results: []clients.PrepareAddBusinessTermDomainResponse{ + {ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain"}, + }, + Total: 1, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "", + }) + 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) != 1 { + t.Errorf("Expected 1 available domain, got %d", len(output.AvailableDomains)) + } +} + +func TestPrepareAddBusinessTermIncompleteMissingDomain(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("GET /rest/2.0/domains", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainPagedResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainPagedResponse{ + Results: []clients.PrepareAddBusinessTermDomainResponse{ + {ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain"}, + {ID: "domain-2", Name: "Technical Glossary", Description: "Technical terms"}, + }, + Total: 2, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + }) + 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) != 2 { + t.Errorf("Expected 2 available domains, got %d", len(output.AvailableDomains)) + } +} + +func TestPrepareAddBusinessTermNeedsClarificationDomainIDNotFound(t *testing.T) { + handler := http.NewServeMux() + + // Domain lookup returns 404 + handler.Handle("GET /rest/2.0/domains/{id}", JsonHandlerOut(func(r *http.Request) (int, map[string]string) { + return http.StatusNotFound, map[string]string{"error": "not found"} + })) + + // List domains for fallback + handler.Handle("GET /rest/2.0/domains", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainPagedResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainPagedResponse{ + Results: []clients.PrepareAddBusinessTermDomainResponse{ + {ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain"}, + }, + Total: 1, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + DomainID: "invalid-domain-id", + }) + 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) != 1 { + t.Errorf("Expected 1 available domain, got %d", len(output.AvailableDomains)) + } +} + +func TestPrepareAddBusinessTermNeedsClarificationDomainNameNotFound(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("GET /rest/2.0/domains", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainPagedResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainPagedResponse{ + Results: []clients.PrepareAddBusinessTermDomainResponse{ + {ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain"}, + }, + Total: 1, + } + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + DomainName: "Nonexistent Domain", + }) + 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) != 1 { + t.Errorf("Expected 1 available domain as fallback, got %d", len(output.AvailableDomains)) + } +} + +func TestPrepareAddBusinessTermAssetTypeAPIError(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("GET /rest/2.0/domains/{id}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainResponse{ + ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain", + } + })) + + // Asset type endpoint returns 500 + handler.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", JsonHandlerOut(func(r *http.Request) (int, map[string]string) { + return http.StatusInternalServerError, map[string]string{"error": "internal server error"} + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + _, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + DomainID: "domain-1", + }) + if err == nil { + t.Fatalf("Expected an error for API failure, got nil") + } +} + +func TestPrepareAddBusinessTermDomainNameCaseInsensitive(t *testing.T) { + server := httptest.NewServer(newFullHandler()) + defer server.Close() + + client := newClient(server) + // Use lowercase when actual name is "Business Glossary" + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + DomainName: "business glossary", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if output.Status != "ready" { + t.Errorf("Expected status 'ready' with case-insensitive match, got '%s': %s", output.Status, output.Message) + } + if output.Domain == nil || output.Domain.ID != "domain-1" { + t.Errorf("Expected domain to resolve to domain-1") + } +} + +func TestPrepareAddBusinessTermEmptyAttributeSchema(t *testing.T) { + handler := http.NewServeMux() + + handler.Handle("GET /rest/2.0/domains/{id}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermDomainResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermDomainResponse{ + ID: "domain-1", Name: "Business Glossary", Description: "Main glossary domain", + } + })) + + handler.Handle("GET /rest/2.0/assetTypes/publicId/{publicId}", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermAssetTypeResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermAssetTypeResponse{ + ID: "at-bt-1", PublicID: clients.BusinessTermAssetTypePublicID, Name: "Business Term", + } + })) + + handler.Handle("GET /rest/2.0/assets", JsonHandlerOut(func(r *http.Request) (int, clients.PrepareAddBusinessTermSearchAssetsResponse) { + return http.StatusOK, clients.PrepareAddBusinessTermSearchAssetsResponse{ + Results: []clients.PrepareAddBusinessTermAssetResponse{}, + Total: 0, + } + })) + + // No assignments - empty attribute schema + handler.Handle("GET /rest/2.0/assignments/assetType/{id}", JsonHandlerOut(func(r *http.Request) (int, []clients.PrepareAddBusinessTermAssignmentResponse) { + return http.StatusOK, []clients.PrepareAddBusinessTermAssignmentResponse{} + })) + + server := httptest.NewServer(handler) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + 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 empty attribute schema, got %d attributes", len(output.AttributeSchema)) + } +} + +func TestPrepareAddBusinessTermOutputSerialization(t *testing.T) { + server := httptest.NewServer(newFullHandler()) + defer server.Close() + + client := newClient(server) + output, err := tools.NewPrepareAddBusinessTermTool(client).Handler(t.Context(), tools.PrepareAddBusinessTermInput{ + Name: "Revenue", + DomainID: "domain-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Verify the output serializes to valid JSON + data, err := json.Marshal(output) + if err != nil { + t.Fatalf("Failed to marshal output to JSON: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Failed to unmarshal output JSON: %v", err) + } + + if parsed["status"] != "ready" { + t.Errorf("Expected serialized status 'ready', got '%v'", parsed["status"]) + } + if _, ok := parsed["domain"]; !ok { + t.Errorf("Expected 'domain' field in serialized output") + } + if _, ok := parsed["attribute_schema"]; !ok { + t.Errorf("Expected 'attribute_schema' field in serialized output") + } +} diff --git a/pkg/tools/pull_data_contract_manifest.go b/pkg/tools/pull_data_contract_manifest.go index 2a64a11..33ec6ba 100644 --- a/pkg/tools/pull_data_contract_manifest.go +++ b/pkg/tools/pull_data_contract_manifest.go @@ -22,7 +22,7 @@ type PullDataContractManifestOutput struct { func NewPullDataContractManifestTool(collibraClient *http.Client) *chip.Tool[PullDataContractManifestInput, PullDataContractManifestOutput] { return &chip.Tool[PullDataContractManifestInput, PullDataContractManifestOutput]{ - 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: handlePullDataContractManifest(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/push_data_contract_manifest.go b/pkg/tools/push_data_contract_manifest.go index b17442c..4032cdc 100644 --- a/pkg/tools/push_data_contract_manifest.go +++ b/pkg/tools/push_data_contract_manifest.go @@ -27,7 +27,7 @@ type PushDataContractManifestOutput struct { func NewPushDataContractManifestTool(collibraClient *http.Client) *chip.Tool[PushDataContractManifestInput, PushDataContractManifestOutput] { return &chip.Tool[PushDataContractManifestInput, PushDataContractManifestOutput]{ - 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: handlePushDataContractManifest(collibraClient), Permissions: []string{"dgc.data-contract"}, diff --git a/pkg/tools/remove_data_classification_match.go b/pkg/tools/remove_data_classification_match.go index b39a91f..5a8477e 100644 --- a/pkg/tools/remove_data_classification_match.go +++ b/pkg/tools/remove_data_classification_match.go @@ -21,7 +21,7 @@ type RemoveDataClassificationMatchOutput struct { func NewRemoveDataClassificationMatchTool(collibraClient *http.Client) *chip.Tool[RemoveDataClassificationMatchInput, RemoveDataClassificationMatchOutput] { return &chip.Tool[RemoveDataClassificationMatchInput, RemoveDataClassificationMatchOutput]{ - 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: handleRemoveDataClassificationMatch(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog", "dgc.data-classes-edit"}, diff --git a/pkg/tools/keyword_search.go b/pkg/tools/search_asset_keyword.go similarity index 99% rename from pkg/tools/keyword_search.go rename to pkg/tools/search_asset_keyword.go index 1a158b9..5a3b845 100644 --- a/pkg/tools/keyword_search.go +++ b/pkg/tools/search_asset_keyword.go @@ -38,7 +38,7 @@ type SearchKeywordResource struct { func NewSearchKeywordTool(collibraClient *http.Client) *chip.Tool[SearchKeywordInput, SearchKeywordOutput] { return &chip.Tool[SearchKeywordInput, SearchKeywordOutput]{ - 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: handleSearchKeyword(collibraClient), Permissions: []string{}, diff --git a/pkg/tools/keyword_search_test.go b/pkg/tools/search_asset_keyword_test.go similarity index 100% rename from pkg/tools/keyword_search_test.go rename to pkg/tools/search_asset_keyword_test.go diff --git a/pkg/tools/search_data_classes.go b/pkg/tools/search_data_classes.go index 74f1869..28a4276 100644 --- a/pkg/tools/search_data_classes.go +++ b/pkg/tools/search_data_classes.go @@ -26,7 +26,7 @@ type SearchDataClassesOutput struct { func NewSearchDataClassesTool(collibraClient *http.Client) *chip.Tool[SearchDataClassesInput, SearchDataClassesOutput] { return &chip.Tool[SearchDataClassesInput, SearchDataClassesOutput]{ - 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: handleSearchDataClasses(collibraClient), Permissions: []string{"dgc.data-classes-read"}, diff --git a/pkg/tools/find_data_classification_matches.go b/pkg/tools/search_data_classification_matches.go similarity index 98% rename from pkg/tools/find_data_classification_matches.go rename to pkg/tools/search_data_classification_matches.go index 2c6429b..6457e71 100644 --- a/pkg/tools/find_data_classification_matches.go +++ b/pkg/tools/search_data_classification_matches.go @@ -27,7 +27,7 @@ type SearchClassificationMatchesOutput struct { func NewSearchClassificationMatchesTool(collibraClient *http.Client) *chip.Tool[SearchClassificationMatchesInput, SearchClassificationMatchesOutput] { return &chip.Tool[SearchClassificationMatchesInput, SearchClassificationMatchesOutput]{ - 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: handleSearchClassificationMatches(collibraClient), Permissions: []string{"dgc.classify", "dgc.catalog"}, diff --git a/pkg/tools/find_data_classification_matches_test.go b/pkg/tools/search_data_classification_matches_test.go similarity index 100% rename from pkg/tools/find_data_classification_matches_test.go rename to pkg/tools/search_data_classification_matches_test.go diff --git a/pkg/tools/search_lineage_entities.go b/pkg/tools/search_lineage_entities.go new file mode 100644 index 0000000..11b5db6 --- /dev/null +++ b/pkg/tools/search_lineage_entities.go @@ -0,0 +1,37 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type SearchLineageEntitiesInput 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 NewSearchLineageEntitiesTool(collibraClient *http.Client) *chip.Tool[SearchLineageEntitiesInput, clients.SearchLineageEntitiesOutput] { + return &chip.Tool[SearchLineageEntitiesInput, clients.SearchLineageEntitiesOutput]{ + Name: "search_lineage_entities", + Description: "Search for data entities in the technical lineage graph by name, type, or DGC identifier. Technical lineage covers all data objects across external systems -- including source code, transformations, and temporary tables -- regardless of whether they are registered in Collibra (unlike business lineage, which only covers assets ingested into the Data Catalog). Returns a paginated list of matching entities. This is typically the starting tool when you don't have a specific entity ID -- for example, to find all tables with \"sales\" in the name, or to find the lineage entity linked to a specific Collibra catalog asset via its DGC UUID. Supports partial name matching (case insensitive).", + Handler: handleSearchLineageEntities(collibraClient), + Permissions: []string{}, + } +} + +func handleSearchLineageEntities(collibraClient *http.Client) chip.ToolHandlerFunc[SearchLineageEntitiesInput, clients.SearchLineageEntitiesOutput] { + return func(ctx context.Context, input SearchLineageEntitiesInput) (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_test.go b/pkg/tools/search_lineage_entities_test.go new file mode 100644 index 0000000..f388812 --- /dev/null +++ b/pkg/tools/search_lineage_entities_test.go @@ -0,0 +1,81 @@ +package tools_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/tools" +) + +func TestSearchLineageEntities(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/entities", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + 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 := newClient(server) + output, err := tools.NewSearchLineageEntitiesTool(client).Handler(t.Context(), tools.SearchLineageEntitiesInput{ + 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", 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 := newClient(server) + output, err := tools.NewSearchLineageEntitiesTool(client).Handler(t.Context(), tools.SearchLineageEntitiesInput{ + 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.go b/pkg/tools/search_lineage_transformations.go new file mode 100644 index 0000000..e77669d --- /dev/null +++ b/pkg/tools/search_lineage_transformations.go @@ -0,0 +1,35 @@ +package tools + +import ( + "context" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" +) + +type SearchLineageTransformationsInput struct { + NameContains string `json:"nameContains,omitempty" jsonschema:"Optional. Partial match on transformation name (case insensitive). Min: 1, Max: 256 chars. Example: 'etl'"` + Limit int `json:"limit,omitempty" jsonschema:"Optional. Max results per page. Default: 20, Min: 1, Max: 100."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Pagination cursor from a previous response. Do not construct manually."` +} + +func NewSearchLineageTransformationsTool(collibraClient *http.Client) *chip.Tool[SearchLineageTransformationsInput, clients.SearchLineageTransformationsOutput] { + return &chip.Tool[SearchLineageTransformationsInput, clients.SearchLineageTransformationsOutput]{ + Name: "search_lineage_transformations", + Description: "Search for transformations in the technical lineage graph by name. Returns a paginated list of matching transformation summaries. Use this to discover ETL jobs, SQL queries, or other processing activities without knowing their IDs. For example, find all transformations with \"etl\" or \"sales\" in the name. To see the full transformation logic (SQL/script), use get_lineage_transformation with the returned ID.", + Handler: handleSearchLineageTransformations(collibraClient), + Permissions: []string{}, + } +} + +func handleSearchLineageTransformations(collibraClient *http.Client) chip.ToolHandlerFunc[SearchLineageTransformationsInput, clients.SearchLineageTransformationsOutput] { + return func(ctx context.Context, input SearchLineageTransformationsInput) (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_test.go b/pkg/tools/search_lineage_transformations_test.go new file mode 100644 index 0000000..d707b12 --- /dev/null +++ b/pkg/tools/search_lineage_transformations_test.go @@ -0,0 +1,77 @@ +package tools_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/collibra/chip/pkg/tools" +) + +func TestSearchLineageTransformations(t *testing.T) { + handler := http.NewServeMux() + handler.Handle("/technical_lineage_resource/rest/lineageGraphRead/v1/transformations", JsonHandlerOut(func(r *http.Request) (int, map[string]any) { + 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 := newClient(server) + output, err := tools.NewSearchLineageTransformationsTool(client).Handler(t.Context(), tools.SearchLineageTransformationsInput{ + 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", 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 := newClient(server) + output, err := tools.NewSearchLineageTransformationsTool(client).Handler(t.Context(), tools.SearchLineageTransformationsInput{ + 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/tools_register.go b/pkg/tools/tools_register.go index 93910fc..55a3201 100644 --- a/pkg/tools/tools_register.go +++ b/pkg/tools/tools_register.go @@ -19,6 +19,17 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv toolRegister(server, toolConfig, NewListDataContractsTool(client)) toolRegister(server, toolConfig, NewPushDataContractManifestTool(client)) toolRegister(server, toolConfig, NewPullDataContractManifestTool(client)) + toolRegister(server, toolConfig, NewColumnSemanticsGetTool(client)) + toolRegister(server, toolConfig, NewMeasureDataGetTool(client)) + toolRegister(server, toolConfig, NewTableSemanticsGetTool(client)) + toolRegister(server, toolConfig, NewBusinessTermDataGetTool(client)) + toolRegister(server, toolConfig, NewGetLineageEntityTool(client)) + toolRegister(server, toolConfig, NewGetLineageUpstreamTool(client)) + toolRegister(server, toolConfig, NewGetLineageDownstreamTool(client)) + toolRegister(server, toolConfig, NewSearchLineageEntitiesTool(client)) + toolRegister(server, toolConfig, NewGetLineageTransformationTool(client)) + toolRegister(server, toolConfig, NewSearchLineageTransformationsTool(client)) + toolRegister(server, toolConfig, NewPrepareAddBusinessTermTool(client)) } func toolRegister[In, Out any](server *chip.Server, toolConfig *chip.ServerToolConfig, tool *chip.Tool[In, Out]) {