diff --git a/README.md b/README.md index fcef359..e745d21 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,37 @@ output "project_secret_key" { } ``` +## Data Sources + +### `langfuse_organization` + +Reads an existing Langfuse organization by its ID or name. Exactly one of `id` or `name` must be specified. + +#### Arguments + +- `id` (String, Optional) - The unique identifier of the organization. Exactly one of `id` or `name` must be specified. +- `name` (String, Optional) - The display name of the organization. Exactly one of `id` or `name` must be specified. + +#### Attributes + +- `id` (String) - The unique identifier of the organization +- `name` (String) - The display name of the organization +- `metadata` (Map of String) - Metadata for the organization as key-value pairs + +#### Example Usage + +```hcl +# Look up by ID +data "langfuse_organization" "by_id" { + id = "org-123" +} + +# Look up by name +data "langfuse_organization" "by_name" { + name = "My Organization" +} +``` + ## Resources ### `langfuse_organization` diff --git a/internal/provider/organization_datasource.go b/internal/provider/organization_datasource.go new file mode 100644 index 0000000..9672b97 --- /dev/null +++ b/internal/provider/organization_datasource.go @@ -0,0 +1,149 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/langfuse/terraform-provider-langfuse/internal/langfuse" +) + +var _ datasource.DataSource = &organizationDataSource{} +var _ datasource.DataSourceWithValidateConfig = &organizationDataSource{} + +func NewOrganizationDataSource() datasource.DataSource { + return &organizationDataSource{} +} + +type organizationDataSourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Metadata types.Map `tfsdk:"metadata"` +} + +type organizationDataSource struct { + AdminClient langfuse.AdminClient +} + +func (d *organizationDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + d.AdminClient = req.ProviderData.(langfuse.ClientFactory).NewAdminClient() +} + +func (d *organizationDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization" +} + +func (d *organizationDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Use this data source to look up a Langfuse organization by its ID or name.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The unique identifier of the organization. Exactly one of `id` or `name` must be specified.", + }, + "name": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The display name of the organization. Exactly one of `id` or `name` must be specified.", + }, + "metadata": schema.MapAttribute{ + Computed: true, + ElementType: types.StringType, + Description: "Metadata for the organization as key-value pairs.", + }, + }, + } +} + +func (d *organizationDataSource) ValidateConfig(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + var data organizationDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + idSet := !data.ID.IsNull() && !data.ID.IsUnknown() + nameSet := !data.Name.IsNull() && !data.Name.IsUnknown() + + if !idSet && !nameSet { + resp.Diagnostics.AddError( + "Missing required argument", + "Exactly one of `id` or `name` must be specified.", + ) + } + + if idSet && nameSet { + resp.Diagnostics.AddError( + "Conflicting arguments", + "Exactly one of `id` or `name` must be specified, not both.", + ) + } +} + +func (d *organizationDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data organizationDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + var org *langfuse.Organization + var err error + + if !data.ID.IsNull() && !data.ID.IsUnknown() { + org, err = d.AdminClient.GetOrganization(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error reading organization by ID", err.Error()) + return + } + } else { + orgs, listErr := d.AdminClient.ListOrganizations(ctx) + if listErr != nil { + resp.Diagnostics.AddError("Error listing organizations", listErr.Error()) + return + } + + targetName := data.Name.ValueString() + for _, o := range orgs { + if o.Name == targetName { + org = o + break + } + } + + if org == nil { + resp.Diagnostics.AddError( + "Organization not found", + fmt.Sprintf("No organization found with name %q.", targetName), + ) + return + } + } + + var metadataMap types.Map + if len(org.Metadata) > 0 { + var diags diag.Diagnostics + metadataMap, diags = types.MapValueFrom(ctx, types.StringType, org.Metadata) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } else { + metadataMap = types.MapNull(types.StringType) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &organizationDataSourceModel{ + ID: types.StringValue(org.ID), + Name: types.StringValue(org.Name), + Metadata: metadataMap, + })...) +} diff --git a/internal/provider/organization_datasource_unit_test.go b/internal/provider/organization_datasource_unit_test.go new file mode 100644 index 0000000..d8756ce --- /dev/null +++ b/internal/provider/organization_datasource_unit_test.go @@ -0,0 +1,442 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/golang/mock/gomock" + + "github.com/langfuse/terraform-provider-langfuse/internal/langfuse" + "github.com/langfuse/terraform-provider-langfuse/internal/langfuse/mocks" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestOrganizationDataSourceMetadata(t *testing.T) { + t.Parallel() + + ctx := context.Background() + d := NewOrganizationDataSource().(*organizationDataSource) + + var resp datasource.MetadataResponse + d.Metadata(ctx, datasource.MetadataRequest{ProviderTypeName: "langfuse"}, &resp) + + expected := "langfuse_organization" + if resp.TypeName != expected { + t.Fatalf("unexpected type name. got %q, want %q", resp.TypeName, expected) + } +} + +func TestOrganizationDataSourceSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + d := NewOrganizationDataSource().(*organizationDataSource) + + var schemaResp datasource.SchemaResponse + d.Schema(ctx, datasource.SchemaRequest{}, &schemaResp) + + if schemaResp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics from Schema: %v", schemaResp.Diagnostics) + } + + if diags := schemaResp.Schema.ValidateImplementation(ctx); diags.HasError() { + t.Fatalf("schema implementation validation failed: %v", diags) + } + + idAttrRaw, ok := schemaResp.Schema.Attributes["id"] + if !ok { + t.Fatalf("schema is missing 'id' attribute") + } + + idAttr, ok := idAttrRaw.(dsschema.StringAttribute) + if !ok { + t.Fatalf("'id' attribute is not a string attribute as expected") + } + + if !idAttr.Optional || !idAttr.Computed { + t.Fatalf("'id' attribute must be Optional=true and Computed=true") + } + + nameAttrRaw, ok := schemaResp.Schema.Attributes["name"] + if !ok { + t.Fatalf("schema is missing 'name' attribute") + } + + nameAttr, ok := nameAttrRaw.(dsschema.StringAttribute) + if !ok { + t.Fatalf("'name' attribute is not a string attribute as expected") + } + + if !nameAttr.Optional || !nameAttr.Computed { + t.Fatalf("'name' attribute must be Optional=true and Computed=true") + } + + metadataAttrRaw, ok := schemaResp.Schema.Attributes["metadata"] + if !ok { + t.Fatalf("schema is missing 'metadata' attribute") + } + + metadataAttr, ok := metadataAttrRaw.(dsschema.MapAttribute) + if !ok { + t.Fatalf("'metadata' attribute is not a map attribute as expected") + } + + if !metadataAttr.Computed { + t.Fatalf("'metadata' attribute must be Computed=true") + } +} + +func TestOrganizationDataSourceValidateConfig(t *testing.T) { + t.Parallel() + + ctx := context.Background() + d := NewOrganizationDataSource().(*organizationDataSource) + + var schemaResp datasource.SchemaResponse + d.Schema(ctx, datasource.SchemaRequest{}, &schemaResp) + dataSourceSchema := schemaResp.Schema + + t.Run("NeitherIdNorName", func(t *testing.T) { + config := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, nil), + "name": tftypes.NewValue(tftypes.String, nil), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var resp datasource.ValidateConfigResponse + d.ValidateConfig(ctx, datasource.ValidateConfigRequest{Config: config}, &resp) + + if !resp.Diagnostics.HasError() { + t.Fatalf("expected error when neither id nor name is set") + } + }) + + t.Run("BothIdAndName", func(t *testing.T) { + config := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "org-123"), + "name": tftypes.NewValue(tftypes.String, "Acme Inc"), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var resp datasource.ValidateConfigResponse + d.ValidateConfig(ctx, datasource.ValidateConfigRequest{Config: config}, &resp) + + if !resp.Diagnostics.HasError() { + t.Fatalf("expected error when both id and name are set") + } + }) + + t.Run("OnlyId", func(t *testing.T) { + config := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "org-123"), + "name": tftypes.NewValue(tftypes.String, nil), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var resp datasource.ValidateConfigResponse + d.ValidateConfig(ctx, datasource.ValidateConfigRequest{Config: config}, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected error when only id is set: %v", resp.Diagnostics) + } + }) + + t.Run("OnlyName", func(t *testing.T) { + config := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, nil), + "name": tftypes.NewValue(tftypes.String, "Acme Inc"), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var resp datasource.ValidateConfigResponse + d.ValidateConfig(ctx, datasource.ValidateConfigRequest{Config: config}, &resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected error when only name is set: %v", resp.Diagnostics) + } + }) +} + +func TestOrganizationDataSourceRead(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + + d := NewOrganizationDataSource().(*organizationDataSource) + + clientFactory := mocks.NewMockClientFactory(ctrl) + + var dataSourceSchema dsschema.Schema + t.Run("Configure", func(t *testing.T) { + var configureResp datasource.ConfigureResponse + d.Configure(ctx, datasource.ConfigureRequest{ProviderData: clientFactory}, &configureResp) + + if configureResp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics from Configure: %v", configureResp.Diagnostics) + } + + if d.AdminClient == nil { + t.Fatalf("Configure did not populate AdminClient on the data source") + } + + var schemaResp datasource.SchemaResponse + d.Schema(ctx, datasource.SchemaRequest{}, &schemaResp) + if schemaResp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics from Schema: %v", schemaResp.Diagnostics) + } + dataSourceSchema = schemaResp.Schema + }) + + t.Run("ReadByID", func(t *testing.T) { + orgID := "org-123" + orgName := "Acme Inc" + orgMetadata := map[string]string{"environment": "test", "team": "platform"} + + clientFactory.AdminClient.EXPECT(). + GetOrganization(ctx, orgID). + Return(&langfuse.Organization{ + ID: orgID, + Name: orgName, + Metadata: orgMetadata, + }, nil) + + readConfig := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, orgID), + "name": tftypes.NewValue(tftypes.String, nil), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var readResp datasource.ReadResponse + readResp.State.Schema = dataSourceSchema + + d.Read(ctx, datasource.ReadRequest{Config: readConfig}, &readResp) + + if readResp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics from Read: %v", readResp.Diagnostics) + } + + var model organizationDataSourceModel + diags := readResp.State.Get(ctx, &model) + if diags.HasError() { + t.Fatalf("unexpected diagnostics getting model from state: %v", diags) + } + + if model.ID.ValueString() != orgID { + t.Fatalf("unexpected ID. got %q, want %q", model.ID.ValueString(), orgID) + } + + if model.Name.ValueString() != orgName { + t.Fatalf("unexpected name. got %q, want %q", model.Name.ValueString(), orgName) + } + + if model.Metadata.IsNull() { + t.Fatalf("metadata should not be null") + } + + var actualMetadata map[string]string + diags = model.Metadata.ElementsAs(ctx, &actualMetadata, false) + if diags.HasError() { + t.Fatalf("unexpected diagnostics extracting metadata: %v", diags) + } + + for key, expectedValue := range orgMetadata { + if actualValue, exists := actualMetadata[key]; !exists || actualValue != expectedValue { + t.Fatalf("unexpected metadata for key %q. got %q, want %q", key, actualValue, expectedValue) + } + } + }) + + t.Run("ReadByName", func(t *testing.T) { + orgID := "org-789" + orgName := "By Name Org" + orgMetadata := map[string]string{"tier": "enterprise"} + + clientFactory.AdminClient.EXPECT(). + ListOrganizations(ctx). + Return([]*langfuse.Organization{ + {ID: "org-other", Name: "Other Org", Metadata: nil}, + {ID: orgID, Name: orgName, Metadata: orgMetadata}, + }, nil) + + readConfig := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, nil), + "name": tftypes.NewValue(tftypes.String, orgName), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var readResp datasource.ReadResponse + readResp.State.Schema = dataSourceSchema + + d.Read(ctx, datasource.ReadRequest{Config: readConfig}, &readResp) + + if readResp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics from Read: %v", readResp.Diagnostics) + } + + var model organizationDataSourceModel + diags := readResp.State.Get(ctx, &model) + if diags.HasError() { + t.Fatalf("unexpected diagnostics getting model from state: %v", diags) + } + + if model.ID.ValueString() != orgID { + t.Fatalf("unexpected ID. got %q, want %q", model.ID.ValueString(), orgID) + } + + if model.Name.ValueString() != orgName { + t.Fatalf("unexpected name. got %q, want %q", model.Name.ValueString(), orgName) + } + }) + + t.Run("ReadByName_NotFound", func(t *testing.T) { + clientFactory.AdminClient.EXPECT(). + ListOrganizations(ctx). + Return([]*langfuse.Organization{ + {ID: "org-1", Name: "Existing Org", Metadata: nil}, + }, nil) + + readConfig := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, nil), + "name": tftypes.NewValue(tftypes.String, "Nonexistent Org"), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var readResp datasource.ReadResponse + readResp.State.Schema = dataSourceSchema + + d.Read(ctx, datasource.ReadRequest{Config: readConfig}, &readResp) + + if !readResp.Diagnostics.HasError() { + t.Fatalf("expected error when organization name is not found") + } + + found := false + for _, diag := range readResp.Diagnostics.Errors() { + if diag.Summary() == "Organization not found" { + found = true + break + } + } + if !found { + t.Fatalf("expected 'Organization not found' error, got: %v", readResp.Diagnostics) + } + }) + + t.Run("ReadByID_EmptyMetadata", func(t *testing.T) { + orgID := "org-456" + orgName := "Empty Org" + + clientFactory.AdminClient.EXPECT(). + GetOrganization(ctx, orgID). + Return(&langfuse.Organization{ + ID: orgID, + Name: orgName, + Metadata: nil, + }, nil) + + readConfig := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, orgID), + "name": tftypes.NewValue(tftypes.String, nil), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var readResp datasource.ReadResponse + readResp.State.Schema = dataSourceSchema + + d.Read(ctx, datasource.ReadRequest{Config: readConfig}, &readResp) + + if readResp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics from Read: %v", readResp.Diagnostics) + } + + var model organizationDataSourceModel + diags := readResp.State.Get(ctx, &model) + if diags.HasError() { + t.Fatalf("unexpected diagnostics getting model from state: %v", diags) + } + + if model.ID.ValueString() != orgID { + t.Fatalf("unexpected ID. got %q, want %q", model.ID.ValueString(), orgID) + } + + if model.Name.ValueString() != orgName { + t.Fatalf("unexpected name. got %q, want %q", model.Name.ValueString(), orgName) + } + + if !model.Metadata.IsNull() { + t.Fatalf("metadata should be null when empty, got %v", model.Metadata) + } + }) + + t.Run("ReadByID_Error", func(t *testing.T) { + orgID := "org-bad" + + clientFactory.AdminClient.EXPECT(). + GetOrganization(ctx, orgID). + Return(nil, fmt.Errorf("not found")) + + readConfig := tfsdk.Config{ + Raw: buildDataSourceObjectValue(map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, orgID), + "name": tftypes.NewValue(tftypes.String, nil), + "metadata": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + }), + Schema: dataSourceSchema, + } + + var readResp datasource.ReadResponse + readResp.State.Schema = dataSourceSchema + + d.Read(ctx, datasource.ReadRequest{Config: readConfig}, &readResp) + + if !readResp.Diagnostics.HasError() { + t.Fatalf("expected error when GetOrganization fails") + } + }) +} + +func buildDataSourceObjectValue(values map[string]tftypes.Value) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "metadata": tftypes.Map{ElementType: tftypes.String}, + }, + }, + values, + ) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 7eda0a2..63828b0 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -68,7 +68,9 @@ func (p *langfuseProvider) Configure(ctx context.Context, req provider.Configure } func (p *langfuseProvider) DataSources(ctx context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{} + return []func() datasource.DataSource{ + NewOrganizationDataSource, + } } func (p *langfuseProvider) Resources(ctx context.Context) []func() resource.Resource {