From b03a307b0fe16d548181ab0a04a45c3204fa32c1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jul 2024 04:49:01 +0000 Subject: [PATCH] chore: add uuid terraform type --- internal/provider/group_data_source.go | 35 ++--- internal/provider/group_resource.go | 40 ++--- internal/provider/organization_data_source.go | 17 +-- internal/provider/provider.go | 14 +- internal/provider/template_resource.go | 71 +++------ internal/provider/user_data_source.go | 7 +- internal/provider/user_resource.go | 13 +- internal/provider/uuid.go | 143 ++++++++++++++++++ 8 files changed, 215 insertions(+), 125 deletions(-) create mode 100644 internal/provider/uuid.go diff --git a/internal/provider/group_data_source.go b/internal/provider/group_data_source.go index 62e1cbf..5a02620 100644 --- a/internal/provider/group_data_source.go +++ b/internal/provider/group_data_source.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/coder/coder/v2/codersdk" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" @@ -29,9 +28,9 @@ type GroupDataSource struct { // GroupDataSourceModel describes the data source data model. type GroupDataSourceModel struct { // ID or name and organization ID must be set - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Name types.String `tfsdk:"name"` - OrganizationID types.String `tfsdk:"organization_id"` + OrganizationID UUID `tfsdk:"organization_id"` DisplayName types.String `tfsdk:"display_name"` AvatarURL types.String `tfsdk:"avatar_url"` @@ -41,7 +40,7 @@ type GroupDataSourceModel struct { } type Member struct { - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Username types.String `tfsdk:"username"` Email types.String `tfsdk:"email"` CreatedAt types.Int64 `tfsdk:"created_at"` @@ -64,6 +63,7 @@ func (d *GroupDataSource) Schema(ctx context.Context, req datasource.SchemaReque MarkdownDescription: "The ID of the group to retrieve. This field will be populated if a name and organization ID is supplied.", Optional: true, Computed: true, + CustomType: UUIDType{}, Validators: []validator.String{ stringvalidator.AtLeastOneOf(path.Expressions{ path.MatchRoot("name"), @@ -78,6 +78,7 @@ func (d *GroupDataSource) Schema(ctx context.Context, req datasource.SchemaReque }, "organization_id": schema.StringAttribute{ MarkdownDescription: "The organization ID that the group belongs to. This field will be populated if an ID is supplied. Defaults to the provider default organization ID.", + CustomType: UUIDType{}, Optional: true, Computed: true, }, @@ -101,7 +102,8 @@ func (d *GroupDataSource) Schema(ctx context.Context, req datasource.SchemaReque NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Computed: true, + CustomType: UUIDType{}, + Computed: true, }, "username": schema.StringAttribute{ Computed: true, @@ -169,36 +171,27 @@ func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, client := d.data.Client if data.OrganizationID.IsNull() { - data.OrganizationID = types.StringValue(d.data.DefaultOrganizationID) + data.OrganizationID = UUIDValue(d.data.DefaultOrganizationID) } var group codersdk.Group + var err error if !data.ID.IsNull() { - groupID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) - return - } - + groupID := data.ID.ValueUUID() group, err = client.Group(ctx, groupID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group by ID, got error: %s", err)) return } data.Name = types.StringValue(group.Name) - data.OrganizationID = types.StringValue(group.OrganizationID.String()) + data.OrganizationID = UUIDValue(group.OrganizationID) } else { - orgID, err := uuid.Parse(data.OrganizationID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) - return - } - group, err = client.GroupByOrgAndName(ctx, orgID, data.Name.ValueString()) + group, err = client.GroupByOrgAndName(ctx, data.OrganizationID.ValueUUID(), data.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("Failed to get group by name and org ID", err.Error()) return } - data.ID = types.StringValue(group.ID.String()) + data.ID = UUIDValue(group.ID) } data.DisplayName = types.StringValue(group.DisplayName) @@ -207,7 +200,7 @@ func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, members := make([]Member, 0, len(group.Members)) for _, member := range group.Members { members = append(members, Member{ - ID: types.StringValue(member.ID.String()), + ID: UUIDValue(member.ID), Username: types.StringValue(member.Username), Email: types.StringValue(member.Email), CreatedAt: types.Int64Value(member.CreatedAt.Unix()), diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go index 05d84d0..034335c 100644 --- a/internal/provider/group_resource.go +++ b/internal/provider/group_resource.go @@ -35,13 +35,13 @@ type GroupResource struct { // GroupResourceModel describes the resource data model. type GroupResourceModel struct { - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Name types.String `tfsdk:"name"` DisplayName types.String `tfsdk:"display_name"` AvatarURL types.String `tfsdk:"avatar_url"` QuotaAllowance types.Int32 `tfsdk:"quota_allowance"` - OrganizationID types.String `tfsdk:"organization_id"` + OrganizationID UUID `tfsdk:"organization_id"` Members types.Set `tfsdk:"members"` } @@ -56,6 +56,7 @@ func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Group ID.", + CustomType: UUIDType{}, Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), @@ -84,6 +85,7 @@ func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, }, "organization_id": schema.StringAttribute{ MarkdownDescription: "The organization ID that the group belongs to. Defaults to the provider default organization ID.", + CustomType: UUIDType{}, Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ @@ -132,14 +134,10 @@ func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest, client := r.data.Client if data.OrganizationID.IsUnknown() { - data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + data.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) } - orgID, err := uuid.Parse(data.OrganizationID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) - return - } + orgID := data.OrganizationID.ValueUUID() displayName := data.Name.ValueString() if data.DisplayName.ValueString() != "" { @@ -160,7 +158,7 @@ func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest, tflog.Trace(ctx, "successfully created group", map[string]any{ "id": group.ID.String(), }) - data.ID = types.StringValue(group.ID.String()) + data.ID = UUIDValue(group.ID) data.DisplayName = types.StringValue(group.DisplayName) tflog.Trace(ctx, "setting group members") @@ -196,11 +194,7 @@ func (r *GroupResource) Read(ctx context.Context, req resource.ReadRequest, resp client := r.data.Client - groupID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) - return - } + groupID := data.ID.ValueUUID() group, err := client.Group(ctx, groupID) if err != nil { @@ -212,7 +206,7 @@ func (r *GroupResource) Read(ctx context.Context, req resource.ReadRequest, resp data.DisplayName = types.StringValue(group.DisplayName) data.AvatarURL = types.StringValue(group.AvatarURL) data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance)) - data.OrganizationID = types.StringValue(group.OrganizationID.String()) + data.OrganizationID = UUIDValue(group.OrganizationID) if !data.Members.IsNull() { members := make([]attr.Value, 0, len(group.Members)) for _, member := range group.Members { @@ -237,13 +231,9 @@ func (r *GroupResource) Update(ctx context.Context, req resource.UpdateRequest, client := r.data.Client if data.OrganizationID.IsUnknown() { - data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) - } - groupID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) - return + data.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) } + groupID := data.ID.ValueUUID() group, err := client.Group(ctx, groupID) if err != nil { @@ -301,14 +291,10 @@ func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest, } client := r.data.Client - groupID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) - return - } + groupID := data.ID.ValueUUID() tflog.Trace(ctx, "deleting group") - err = client.DeleteGroup(ctx, groupID) + err := client.DeleteGroup(ctx, groupID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete group, got error: %s", err)) return diff --git a/internal/provider/organization_data_source.go b/internal/provider/organization_data_source.go index d57f68a..6a97df3 100644 --- a/internal/provider/organization_data_source.go +++ b/internal/provider/organization_data_source.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/coder/coder/v2/codersdk" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -30,7 +29,7 @@ type OrganizationDataSource struct { // OrganizationDataSourceModel describes the data source data model. type OrganizationDataSourceModel struct { // Exactly one of ID, IsDefault, or Name must be set. - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` IsDefault types.Bool `tfsdk:"is_default"` Name types.String `tfsdk:"name"` @@ -52,6 +51,7 @@ func (d *OrganizationDataSource) Schema(ctx context.Context, req datasource.Sche Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "The ID of the organization to retrieve. This field will be populated if the organization is found by name, or if the default organization is requested.", + CustomType: UUIDType{}, Optional: true, Computed: true, }, @@ -116,23 +116,19 @@ func (d *OrganizationDataSource) Read(ctx context.Context, req datasource.ReadRe client := d.data.Client var org codersdk.Organization + var err error if !data.ID.IsNull() { // By ID - orgID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied ID as UUID, got error: %s", err)) - return - } + orgID := data.ID.ValueUUID() org, err = client.Organization(ctx, orgID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by ID, got error: %s", err)) return } - if org.ID.String() != data.ID.ValueString() { + if org.ID != data.ID.ValueUUID() { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Organization ID %s does not match requested ID %s", org.ID, data.ID)) return } } else if data.IsDefault.ValueBool() { // Get Default - var err error org, err = client.OrganizationByName(ctx, "default") if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get default organization, got error: %s", err)) @@ -143,7 +139,6 @@ func (d *OrganizationDataSource) Read(ctx context.Context, req datasource.ReadRe return } } else { // By Name - var err error org, err = client.OrganizationByName(ctx, data.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization by name, got error: %s", err)) @@ -154,7 +149,7 @@ func (d *OrganizationDataSource) Read(ctx context.Context, req datasource.ReadRe return } } - data.ID = types.StringValue(org.ID.String()) + data.ID = UUIDValue(org.ID) data.Name = types.StringValue(org.Name) data.IsDefault = types.BoolValue(org.IsDefault) data.CreatedAt = types.Int64Value(org.CreatedAt.Unix()) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2c63823..58b2054 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,7 @@ import ( "strings" "cdr.dev/slog" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -31,10 +32,8 @@ type CoderdProvider struct { } type CoderdProviderData struct { - Client *codersdk.Client - // TODO(ethanndickson): We should use a custom TFPF type for UUIDs everywhere - // possible, instead of `string` and `types.String`. - DefaultOrganizationID string + Client *codersdk.Client + DefaultOrganizationID uuid.UUID } // CoderdProviderModel describes the provider data model. @@ -42,7 +41,7 @@ type CoderdProviderModel struct { URL types.String `tfsdk:"url"` Token types.String `tfsdk:"token"` - DefaultOrganizationID types.String `tfsdk:"default_organization_id"` + DefaultOrganizationID UUID `tfsdk:"default_organization_id"` } func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { @@ -63,6 +62,7 @@ func (p *CoderdProvider) Schema(ctx context.Context, req provider.SchemaRequest, }, "default_organization_id": schema.StringAttribute{ MarkdownDescription: "Default organization ID to use when creating resources. Defaults to the first organization the token has access to.", + CustomType: UUIDType{}, Optional: true, }, }, @@ -109,11 +109,11 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe resp.Diagnostics.AddError("default_organization_id", "failed to get default organization ID: "+err.Error()) return } - data.DefaultOrganizationID = types.StringValue(user.OrganizationIDs[0].String()) + data.DefaultOrganizationID = UUIDValue(user.OrganizationIDs[0]) } providerData := &CoderdProviderData{ Client: client, - DefaultOrganizationID: data.DefaultOrganizationID.ValueString(), + DefaultOrganizationID: data.DefaultOrganizationID.ValueUUID(), } resp.DataSourceData = providerData resp.ResourceData = providerData diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 7cdc212..53bddc6 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -41,12 +41,12 @@ type TemplateResource struct { // TemplateResourceModel describes the resource data model. type TemplateResourceModel struct { - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Name types.String `tfsdk:"name"` DisplayName types.String `tfsdk:"display_name"` Description types.String `tfsdk:"description"` - OrganizationID types.String `tfsdk:"organization_id"` + OrganizationID UUID `tfsdk:"organization_id"` Icon types.String `tfsdk:"icon"` AllowUserAutoStart types.Bool `tfsdk:"allow_user_auto_start"` AllowUserAutoStop types.Bool `tfsdk:"allow_user_auto_stop"` @@ -68,7 +68,7 @@ func (m TemplateResourceModel) EqualTemplateMetadata(other TemplateResourceModel } type TemplateVersion struct { - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Name types.String `tfsdk:"name"` Message types.String `tfsdk:"message"` Directory types.String `tfsdk:"directory"` @@ -80,7 +80,7 @@ type TemplateVersion struct { type Versions []TemplateVersion -func (v Versions) ByID(id types.String) *TemplateVersion { +func (v Versions) ByID(id UUID) *TemplateVersion { for _, m := range v { if m.ID.Equal(id) { return &m @@ -145,6 +145,8 @@ func (a *ACL) Equal(other *ACL) bool { } type Permission struct { + // Purposefully left as a string so we can later support an `everyone` shortcut + // identifier for the Everyone group. ID types.String `tfsdk:"id"` Role types.String `tfsdk:"role"` } @@ -182,6 +184,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "The ID of the template.", + CustomType: UUIDType{}, Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), @@ -207,6 +210,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques }, "organization_id": schema.StringAttribute{ MarkdownDescription: "The ID of the organization. Defaults to the provider's default organization", + CustomType: UUIDType{}, Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ @@ -246,7 +250,8 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ - Computed: true, + CustomType: UUIDType{}, + Computed: true, }, "name": schema.StringAttribute{ MarkdownDescription: "The name of the template version. Automatically generated if not provided.", @@ -323,7 +328,7 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques } if data.OrganizationID.IsUnknown() { - data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + data.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) } if data.DisplayName.IsUnknown() { @@ -331,11 +336,7 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques } client := r.data.Client - orgID, err := uuid.Parse(data.OrganizationID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) - return - } + orgID := data.OrganizationID.ValueUUID() var templateResp codersdk.Template for idx, version := range data.Versions { newVersionRequest := newVersionRequest{ @@ -381,10 +382,10 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques return } } - data.Versions[idx].ID = types.StringValue(versionResp.ID.String()) + data.Versions[idx].ID = UUIDValue(versionResp.ID) data.Versions[idx].Name = types.StringValue(versionResp.Name) } - data.ID = types.StringValue(templateResp.ID.String()) + data.ID = UUIDValue(templateResp.ID) data.DisplayName = types.StringValue(templateResp.DisplayName) // Save data into Terraform state @@ -402,11 +403,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r client := r.data.Client - templateID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied template ID as UUID, got error: %s", err)) - return - } + templateID := data.ID.ValueUUID() template, err := client.Template(ctx, templateID) if err != nil { @@ -417,7 +414,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r data.Name = types.StringValue(template.Name) data.DisplayName = types.StringValue(template.DisplayName) data.Description = types.StringValue(template.Description) - data.OrganizationID = types.StringValue(template.OrganizationID.String()) + data.OrganizationID = UUIDValue(template.OrganizationID) data.Icon = types.StringValue(template.Icon) data.AllowUserAutoStart = types.BoolValue(template.AllowUserAutostart) data.AllowUserAutoStop = types.BoolValue(template.AllowUserAutostop) @@ -430,11 +427,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r data.ACL = convertResponseToACL(acl) for idx, version := range data.Versions { - versionID, err := uuid.Parse(version.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied version ID as UUID, got error: %s", err)) - return - } + versionID := version.ID.ValueUUID() versionResp, err := client.TemplateVersion(ctx, versionID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err)) @@ -471,24 +464,16 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques } if planState.OrganizationID.IsUnknown() { - planState.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + planState.OrganizationID = UUIDValue(r.data.DefaultOrganizationID) } if planState.DisplayName.IsUnknown() { planState.DisplayName = planState.Name } - orgID, err := uuid.Parse(planState.OrganizationID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) - return - } + orgID := planState.OrganizationID.ValueUUID() - templateID, err := uuid.Parse(planState.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied template ID as UUID, got error: %s", err)) - return - } + templateID := planState.ID.ValueUUID() client := r.data.Client @@ -531,11 +516,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques curVersionID = versionResp.ID } else { // Or if it's an existing version, get the ID - curVersionID, err = uuid.Parse(plannedVersion.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse version ID stored in state as UUID, got error: %s", err)) - return - } + curVersionID = plannedVersion.ID.ValueUUID() } versionResp, err := client.TemplateVersion(ctx, curVersionID) if err != nil { @@ -551,7 +532,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } } - planState.Versions[idx].ID = types.StringValue(versionResp.ID.String()) + planState.Versions[idx].ID = UUIDValue(versionResp.ID) } // Save updated data into Terraform state @@ -570,13 +551,9 @@ func (r *TemplateResource) Delete(ctx context.Context, req resource.DeleteReques client := r.data.Client - templateID, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied template ID as UUID, got error: %s", err)) - return - } + templateID := data.ID.ValueUUID() - err = client.DeleteTemplate(ctx, templateID) + err := client.DeleteTemplate(ctx, templateID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to delete template: %s", err)) return diff --git a/internal/provider/user_data_source.go b/internal/provider/user_data_source.go index a8654ce..e62d53d 100644 --- a/internal/provider/user_data_source.go +++ b/internal/provider/user_data_source.go @@ -29,7 +29,7 @@ type UserDataSource struct { // UserDataSourceModel describes the data source data model. type UserDataSourceModel struct { // Username or ID must be set - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Username types.String `tfsdk:"username"` Name types.String `tfsdk:"name"` @@ -55,6 +55,7 @@ func (d *UserDataSource) Schema(ctx context.Context, req datasource.SchemaReques // Validation handled by ConfigValidators Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ + CustomType: UUIDType{}, MarkdownDescription: "The ID of the user to retrieve. This field will be populated if a username is supplied.", Optional: true, }, @@ -155,7 +156,7 @@ func (d *UserDataSource) Read(ctx context.Context, req datasource.ReadRequest, r resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") return } - if !data.ID.IsNull() && user.ID.String() != data.ID.ValueString() { + if !data.ID.IsNull() && user.ID != data.ID.ValueUUID() { resp.Diagnostics.AddError("Client Error", "Retrieved User's ID does not match the provided ID") return } else if !data.Username.IsNull() && user.Username != data.Username.ValueString() { @@ -163,7 +164,7 @@ func (d *UserDataSource) Read(ctx context.Context, req datasource.ReadRequest, r return } - data.ID = types.StringValue(user.ID.String()) + data.ID = UUIDValue(user.ID) data.Username = types.StringValue(user.Username) data.Name = types.StringValue(user.Name) data.Email = types.StringValue(user.Email) diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go index 9bc8018..241aef6 100644 --- a/internal/provider/user_resource.go +++ b/internal/provider/user_resource.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -39,7 +38,7 @@ type UserResource struct { // UserResourceModel describes the resource data model. type UserResourceModel struct { - ID types.String `tfsdk:"id"` + ID UUID `tfsdk:"id"` Username types.String `tfsdk:"username"` Name types.String `tfsdk:"name"` @@ -60,6 +59,7 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ + CustomType: UUIDType{}, Computed: true, MarkdownDescription: "User ID", PlanModifiers: []planmodifier.String{ @@ -186,7 +186,7 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r tflog.Trace(ctx, "successfully created user", map[string]any{ "id": user.ID.String(), }) - data.ID = types.StringValue(user.ID.String()) + data.ID = UUIDValue(user.ID) tflog.Trace(ctx, "updating user profile") name := data.Username.ValueString() @@ -358,13 +358,8 @@ func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, r client := r.data.Client - id, err := uuid.Parse(data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Data Error", fmt.Sprintf("Unable to parse user ID, got error: %s", err)) - return - } tflog.Trace(ctx, "deleting user") - err = client.DeleteUser(ctx, id) + err := client.DeleteUser(ctx, data.ID.ValueUUID()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete user, got error: %s", err)) return diff --git a/internal/provider/uuid.go b/internal/provider/uuid.go new file mode 100644 index 0000000..55e80d2 --- /dev/null +++ b/internal/provider/uuid.go @@ -0,0 +1,143 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type UUIDType struct { + basetypes.StringType +} + +var _ basetypes.StringTypable = UUIDType{} + +// String implements basetypes.StringTypable. +func (t UUIDType) String() string { + return "UUID" +} + +func (t UUIDType) ValueType(ctx context.Context) attr.Value { + return UUID{} +} + +// Equal implements basetypes.StringTypable. +func (t UUIDType) Equal(o attr.Type) bool { + if o, ok := o.(UUIDType); ok { + return t.StringType.Equal(o.StringType) + } + return false +} + +// ValueFromString implements basetypes.StringTypable. +func (t UUIDType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + if in.IsNull() { + return NewUUIDNull(), diags + } + if in.IsUnknown() { + return NewUUIDUnknown(), diags + } + + value, err := uuid.Parse(in.ValueString()) + if err != nil { + return NewUUIDUnknown(), diags + } + + return UUIDValue(value), diags +} + +// ValueFromTerraform implements basetypes.StringTypable. +func (t UUIDType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + + if !ok { + return nil, fmt.Errorf("unexpected type %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} + +type UUID struct { + // The framework requires custom types extend a primitive or object. + basetypes.StringValue + value uuid.UUID +} + +var ( + _ basetypes.StringValuable = UUID{} + _ xattr.ValidateableAttribute = UUID{} +) + +func NewUUIDNull() UUID { + return UUID{ + StringValue: basetypes.NewStringNull(), + } +} + +func NewUUIDUnknown() UUID { + return UUID{ + StringValue: basetypes.NewStringUnknown(), + } +} + +func UUIDValue(value uuid.UUID) UUID { + return UUID{ + StringValue: basetypes.NewStringValue(value.String()), + value: value, + } +} + +// Equal implements basetypes.StringValuable. +func (v UUID) Equal(o attr.Value) bool { + if o, ok := o.(UUID); ok { + return v.StringValue.Equal(o.StringValue) + } + return false +} + +// Type implements basetypes.StringValuable. +func (v UUID) Type(context.Context) attr.Type { + return UUIDType{} +} + +// ValueUUID returns the UUID value. If the value is null or unknown, returns the Nil UUID +func (v UUID) ValueUUID() uuid.UUID { + return v.value +} + +// ValidateAttribute implements xattr.ValidateableAttribute. +func (v UUID) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { + if v.IsNull() || v.IsUnknown() { + return + } + + if _, err := uuid.Parse(v.ValueString()); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid UUID", + "The provided value cannot be parsed as a UUID\n\n"+ + "Path: "+req.Path.String()+"\n"+ + "Error: "+err.Error(), + ) + } +}