diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index f4b481087..941887d02 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -8,6 +8,8 @@ ### Bug Fixes +* Encode the governed-tag key as a single path segment when it is sent as a URL path parameter, so hierarchical keys containing `/` route correctly instead of being split into extra path segments and resolving to no endpoint (`404` / `ENDPOINT_NOT_FOUND`). Affects `GetTagPolicy`/`DeleteTagPolicy`/`UpdateTagPolicy` ([tags.TagPoliciesAPI](https://pkg.go.dev/github.com/databricks/databricks-sdk-go/service/tags#TagPoliciesAPI)), `GetTagAssignment`/`DeleteTagAssignment`/`UpdateTagAssignment` ([tags.WorkspaceEntityTagAssignmentsAPI](https://pkg.go.dev/github.com/databricks/databricks-sdk-go/service/tags#WorkspaceEntityTagAssignmentsAPI)), and `Get`/`Delete`/`Update` ([catalog.EntityTagAssignmentsAPI](https://pkg.go.dev/github.com/databricks/databricks-sdk-go/service/catalog#EntityTagAssignmentsAPI)). + ### Documentation ### Internal Changes diff --git a/service/catalog/ext_entity_tag_assignments.go b/service/catalog/ext_entity_tag_assignments.go new file mode 100644 index 000000000..7cc9af4a5 --- /dev/null +++ b/service/catalog/ext_entity_tag_assignments.go @@ -0,0 +1,39 @@ +package catalog + +import ( + "context" + "net/url" +) + +// This file is hand-written (not generated). It extends the generated +// EntityTagAssignmentsAPI to percent-encode the governed-tag key when it travels +// as a URL *path* parameter. +// +// Governed tag keys are hierarchical and may contain "/" (e.g. +// "Field/Shared Technical Services"). The generated impl builds the path as +// /api/2.1/unity-catalog/entity-tag-assignments/{entity_type}/{entity_name}/tags/{tag_key}, +// so a raw "/" in the key is treated as a path separator and the request +// resolves to no endpoint (404). Encoding the key ("/" -> %2F) makes it route as +// a single path segment; the server decodes it back to the original key. +// +// These methods shadow the promoted (generated) entityTagAssignmentsImpl methods. +// Only the tag-key path parameter is encoded (entity_type/entity_name are left +// untouched). Create is intentionally NOT overridden: it sends the key in the +// JSON body, where a raw "/" is correct and must be stored verbatim. The TagKey +// field encoded below is `json:"-" url:"-"`, so it is used only in the path — +// never the body. + +func (a *EntityTagAssignmentsAPI) Get(ctx context.Context, request GetEntityTagAssignmentRequest) (*EntityTagAssignment, error) { + request.TagKey = url.PathEscape(request.TagKey) + return a.entityTagAssignmentsImpl.Get(ctx, request) +} + +func (a *EntityTagAssignmentsAPI) Delete(ctx context.Context, request DeleteEntityTagAssignmentRequest) error { + request.TagKey = url.PathEscape(request.TagKey) + return a.entityTagAssignmentsImpl.Delete(ctx, request) +} + +func (a *EntityTagAssignmentsAPI) Update(ctx context.Context, request UpdateEntityTagAssignmentRequest) (*EntityTagAssignment, error) { + request.TagKey = url.PathEscape(request.TagKey) + return a.entityTagAssignmentsImpl.Update(ctx, request) +} diff --git a/service/catalog/ext_entity_tag_assignments_test.go b/service/catalog/ext_entity_tag_assignments_test.go new file mode 100644 index 000000000..92b1b0cdf --- /dev/null +++ b/service/catalog/ext_entity_tag_assignments_test.go @@ -0,0 +1,34 @@ +package catalog + +import ( + "context" + "testing" + + "github.com/databricks/databricks-sdk-go/qa" +) + +// Verifies that the governed-tag key is percent-encoded when it is sent as a URL +// *path* parameter, so a hierarchical key containing "/" routes as a single path +// segment. The qa fixture matches on the raw request URI, so a passing match +// proves the "/" was encoded to %2F. +func TestGetEntityTagAssignmentEncodesPathKey(t *testing.T) { + requestMocks := qa.HTTPFixtures{ + { + Method: "GET", + Resource: "/api/2.1/unity-catalog/entity-tag-assignments/schemas/main.default/tags/cost%2Fcenter?", + Response: EntityTagAssignment{}, + }, + } + client, server := requestMocks.Client(t) + defer server.Close() + api := &EntityTagAssignmentsAPI{entityTagAssignmentsImpl: entityTagAssignmentsImpl{client: client}} + + _, err := api.Get(context.Background(), GetEntityTagAssignmentRequest{ + EntityType: "schemas", + EntityName: "main.default", + TagKey: "cost/center", + }) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } +} diff --git a/service/tags/ext_impl.go b/service/tags/ext_impl.go new file mode 100644 index 000000000..f612a4150 --- /dev/null +++ b/service/tags/ext_impl.go @@ -0,0 +1,56 @@ +package tags + +import ( + "context" + "net/url" +) + +// This file is hand-written (not generated). It extends the generated tag APIs +// to percent-encode the governed-tag key when it travels as a URL *path* +// parameter. +// +// Governed tag keys are hierarchical and may contain "/" (e.g. +// "Field/Shared Technical Services"). The generated impls interpolate the key +// straight into the request path: +// - TagPolicies: /api/2.1/tag-policies/{tag_key} +// - WorkspaceEntityTagAssignments: /api/2.0/entity-tag-assignments/{entity_type}/{entity_id}/tags/{tag_key} +// so a raw "/" is treated as a path separator and the request resolves to no +// endpoint (404). Encoding the key ("/" -> %2F) makes it route as a single path +// segment; the server decodes it back to the original key. +// +// These methods shadow the promoted (generated) impl methods. Only the tag-key +// path parameter is encoded (entity_type/entity_id are left untouched). The +// Create* methods are intentionally NOT overridden: they send the key in the +// JSON body, where a raw "/" is correct and must be stored verbatim. The TagKey +// fields encoded below are `json:"-" url:"-"`, so they are used only in the +// path — never the body. + +func (a *TagPoliciesAPI) GetTagPolicy(ctx context.Context, request GetTagPolicyRequest) (*TagPolicy, error) { + request.TagKey = url.PathEscape(request.TagKey) + return a.tagPoliciesImpl.GetTagPolicy(ctx, request) +} + +func (a *TagPoliciesAPI) DeleteTagPolicy(ctx context.Context, request DeleteTagPolicyRequest) error { + request.TagKey = url.PathEscape(request.TagKey) + return a.tagPoliciesImpl.DeleteTagPolicy(ctx, request) +} + +func (a *TagPoliciesAPI) UpdateTagPolicy(ctx context.Context, request UpdateTagPolicyRequest) (*TagPolicy, error) { + request.TagKey = url.PathEscape(request.TagKey) + return a.tagPoliciesImpl.UpdateTagPolicy(ctx, request) +} + +func (a *WorkspaceEntityTagAssignmentsAPI) GetTagAssignment(ctx context.Context, request GetTagAssignmentRequest) (*TagAssignment, error) { + request.TagKey = url.PathEscape(request.TagKey) + return a.workspaceEntityTagAssignmentsImpl.GetTagAssignment(ctx, request) +} + +func (a *WorkspaceEntityTagAssignmentsAPI) DeleteTagAssignment(ctx context.Context, request DeleteTagAssignmentRequest) error { + request.TagKey = url.PathEscape(request.TagKey) + return a.workspaceEntityTagAssignmentsImpl.DeleteTagAssignment(ctx, request) +} + +func (a *WorkspaceEntityTagAssignmentsAPI) UpdateTagAssignment(ctx context.Context, request UpdateTagAssignmentRequest) (*TagAssignment, error) { + request.TagKey = url.PathEscape(request.TagKey) + return a.workspaceEntityTagAssignmentsImpl.UpdateTagAssignment(ctx, request) +} diff --git a/service/tags/ext_impl_test.go b/service/tags/ext_impl_test.go new file mode 100644 index 000000000..72487c09a --- /dev/null +++ b/service/tags/ext_impl_test.go @@ -0,0 +1,80 @@ +package tags + +import ( + "context" + "testing" + + "github.com/databricks/databricks-sdk-go/qa" +) + +// These tests verify that the governed-tag key is percent-encoded when it is +// sent as a URL *path* parameter, so a hierarchical key containing "/" (e.g. +// "Field/Shared Technical Services") routes as a single path segment rather +// than being split into multiple segments (which resolves to no endpoint). +// The qa fixture matches on the raw request URI, so a passing match proves the +// "/" was encoded to %2F. + +func TestGetTagPolicyEncodesPathKey(t *testing.T) { + requestMocks := qa.HTTPFixtures{ + { + Method: "GET", + Resource: "/api/2.1/tag-policies/Field%2FShared%20Technical%20Services?", + Response: TagPolicy{TagKey: "Field/Shared Technical Services"}, + }, + } + client, server := requestMocks.Client(t) + defer server.Close() + api := &TagPoliciesAPI{tagPoliciesImpl: tagPoliciesImpl{client: client}} + + resp, err := api.GetTagPolicy(context.Background(), GetTagPolicyRequest{ + TagKey: "Field/Shared Technical Services", + }) + if err != nil { + t.Fatalf("GetTagPolicy returned error: %v", err) + } + if resp.TagKey != "Field/Shared Technical Services" { + t.Fatalf("unexpected tag_key in response: %q", resp.TagKey) + } +} + +func TestDeleteTagPolicyEncodesPathKey(t *testing.T) { + requestMocks := qa.HTTPFixtures{ + { + Method: "DELETE", + Resource: "/api/2.1/tag-policies/Field%2FShared%20Technical%20Services?", + Response: map[string]any{}, + }, + } + client, server := requestMocks.Client(t) + defer server.Close() + api := &TagPoliciesAPI{tagPoliciesImpl: tagPoliciesImpl{client: client}} + + err := api.DeleteTagPolicy(context.Background(), DeleteTagPolicyRequest{ + TagKey: "Field/Shared Technical Services", + }) + if err != nil { + t.Fatalf("DeleteTagPolicy returned error: %v", err) + } +} + +func TestGetTagAssignmentEncodesPathKey(t *testing.T) { + requestMocks := qa.HTTPFixtures{ + { + Method: "GET", + Resource: "/api/2.0/entity-tag-assignments/dashboards/123/tags/team%2Fdata-eng?", + Response: TagAssignment{}, + }, + } + client, server := requestMocks.Client(t) + defer server.Close() + api := &WorkspaceEntityTagAssignmentsAPI{workspaceEntityTagAssignmentsImpl: workspaceEntityTagAssignmentsImpl{client: client}} + + _, err := api.GetTagAssignment(context.Background(), GetTagAssignmentRequest{ + EntityType: "dashboards", + EntityId: "123", + TagKey: "team/data-eng", + }) + if err != nil { + t.Fatalf("GetTagAssignment returned error: %v", err) + } +}