diff --git a/.changes/unreleased/added-20260330-193000.yaml b/.changes/unreleased/added-20260330-193000.yaml new file mode 100644 index 000000000..bb79e0298 --- /dev/null +++ b/.changes/unreleased/added-20260330-193000.yaml @@ -0,0 +1,5 @@ +kind: added +body: Add powerplatform_publisher resource and data source with Dataverse CRUD support and nested address handling +time: 2026-03-30T19:30:00Z +custom: + Issue: 1133 diff --git a/docs/data-sources/publisher.md b/docs/data-sources/publisher.md new file mode 100644 index 000000000..4175c46da --- /dev/null +++ b/docs/data-sources/publisher.md @@ -0,0 +1,80 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "powerplatform_publisher Data Source - Power Platform" +subcategory: "" +description: |- + Fetches a Dataverse publisher by Dataverse publisher id or unique name. +--- + +# powerplatform_publisher (Data Source) + +Fetches a Dataverse publisher by Dataverse publisher id or unique name. + +## Example Usage + +```terraform +data "powerplatform_publisher" "example" { + environment_id = "00000000-0000-0000-0000-000000000001" + uniquename = "contoso" +} +``` + + +## Schema + +### Required + +- `environment_id` (String) Id of the Dataverse-enabled environment containing the publisher. + +### Optional + +- `id` (String) Dataverse publisher id. +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) +- `uniquename` (String) Unique name of the publisher. + +### Read-Only + +- `address` (Attributes List) Publisher addresses mapped from Dataverse address slots 1 and 2. (see [below for nested schema](#nestedatt--address)) +- `customization_option_value_prefix` (Number) Option value prefix used for option set values created by this publisher. +- `customization_prefix` (String) Customization prefix used for solution components created by this publisher. +- `description` (String) Description of the publisher. +- `email_address` (String) Email address for the publisher. +- `friendly_name` (String) Display name of the publisher. +- `is_read_only` (Boolean) Whether Dataverse reports this publisher as read only. +- `supporting_website_url` (String) Supporting website URL for the publisher. + + +### Nested Schema for `timeouts` + +Optional: + +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Read operations occur during any refresh or planning operation when refresh is enabled. + + + +### Nested Schema for `address` + +Read-Only: + +- `address_id` (String) Dataverse address identifier for the slot. +- `address_type_code` (Number) Address type code. +- `city` (String) City name. +- `country` (String) Country or region name. +- `county` (String) County name. +- `fax` (String) Fax number. +- `latitude` (Number) Latitude value. +- `line1` (String) Street line 1. +- `line2` (String) Street line 2. +- `line3` (String) Street line 3. +- `longitude` (Number) Longitude value. +- `name` (String) Address name. +- `post_office_box` (String) Post office box. +- `postal_code` (String) Postal code. +- `shipping_method_code` (Number) Shipping method code. +- `slot` (Number) Address slot number in Dataverse. Valid values are `1` and `2`. +- `state_or_province` (String) State or province name. +- `telephone1` (String) Primary telephone number. +- `telephone2` (String) Secondary telephone number. +- `telephone3` (String) Tertiary telephone number. +- `ups_zone` (String) UPS zone value. +- `utc_offset` (Number) UTC offset for the address. diff --git a/docs/resources/publisher.md b/docs/resources/publisher.md new file mode 100644 index 000000000..e907f4b14 --- /dev/null +++ b/docs/resources/publisher.md @@ -0,0 +1,124 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "powerplatform_publisher Resource - Power Platform" +subcategory: "" +description: |- + Manages a Dataverse publisher record. Publishers own solution customization prefixes and related metadata. +--- + +# powerplatform_publisher (Resource) + +Manages a Dataverse publisher record. Publishers own solution customization prefixes and related metadata. + +## Example Usage + +```terraform +resource "powerplatform_publisher" "example" { + environment_id = "00000000-0000-0000-0000-000000000001" + uniquename = "contoso" + friendly_name = "Contoso Publisher" + customization_prefix = "cts" + description = "Terraform-managed Dataverse publisher" + email_address = "publisher@contoso.example" + supporting_website_url = "https://contoso.example" + + address = [ + { + slot = 1 + line1 = "1 Collins Street" + city = "Melbourne" + country = "Australia" + postal_code = "3000" + telephone1 = "+61-3-5555-0101" + }, + { + slot = 2 + line1 = "100 Queen Street" + city = "Auckland" + country = "New Zealand" + postal_code = "1010" + telephone1 = "+64-9-555-0102" + } + ] +} +``` + + +## Schema + +### Required + +- `customization_prefix` (String) Customization prefix used for solution components created by this publisher. +- `environment_id` (String) Id of the Dataverse-enabled environment containing the publisher. +- `friendly_name` (String) Display name of the publisher. +- `uniquename` (String) Unique name of the publisher. + +### Optional + +- `address` (Attributes List) Up to two publisher addresses, mapped to Dataverse address slots 1 and 2. (see [below for nested schema](#nestedatt--address)) +- `customization_option_value_prefix` (Number) Option value prefix used for option set values created by this publisher. When omitted, the provider derives the same default value generated by the Power Apps publisher UI from `customization_prefix`. +- `description` (String) Description of the publisher. +- `email_address` (String) Email address for the publisher. +- `supporting_website_url` (String) Supporting website URL for the publisher. +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + +### Read-Only + +- `id` (String) Dataverse publisher id. +- `is_read_only` (Boolean) Whether Dataverse reports this publisher as read only. + + +### Nested Schema for `address` + +Required: + +- `slot` (Number) Address slot number in Dataverse. Valid values are `1` and `2`. + +Optional: + +- `address_type_code` (Number) Address type code. +- `city` (String) City name. +- `country` (String) Country or region name. +- `county` (String) County name. +- `fax` (String) Fax number. +- `latitude` (Number) Latitude value. +- `line1` (String) Street line 1. +- `line2` (String) Street line 2. +- `line3` (String) Street line 3. +- `longitude` (Number) Longitude value. +- `name` (String) Address name. +- `post_office_box` (String) Post office box. +- `postal_code` (String) Postal code. +- `shipping_method_code` (Number) Shipping method code. +- `state_or_province` (String) State or province name. +- `telephone1` (String) Primary telephone number. +- `telephone2` (String) Secondary telephone number. +- `telephone3` (String) Tertiary telephone number. +- `ups_zone` (String) UPS zone value. +- `utc_offset` (Number) UTC offset for the address. + +Read-Only: + +- `address_id` (String) Dataverse address identifier for the slot. + + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). +- `delete` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Setting a timeout for a Delete operation is only applicable if changes are saved into state before the destroy operation occurs. +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Read operations occur during any refresh or planning operation when refresh is enabled. +- `update` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# Publisher resource can be imported using the composite id _ +terraform import powerplatform_publisher.example 00000000-0000-0000-0000-000000000001_11111111-1111-1111-1111-111111111111 +``` diff --git a/examples/data-sources/powerplatform_publisher/data-source.tf b/examples/data-sources/powerplatform_publisher/data-source.tf new file mode 100644 index 000000000..eeade1eb1 --- /dev/null +++ b/examples/data-sources/powerplatform_publisher/data-source.tf @@ -0,0 +1,4 @@ +data "powerplatform_publisher" "example" { + environment_id = "00000000-0000-0000-0000-000000000001" + uniquename = "contoso" +} diff --git a/examples/data-sources/powerplatform_publisher/outputs.tf b/examples/data-sources/powerplatform_publisher/outputs.tf new file mode 100644 index 000000000..36ffa9370 --- /dev/null +++ b/examples/data-sources/powerplatform_publisher/outputs.tf @@ -0,0 +1,3 @@ +output "publisher" { + value = data.powerplatform_publisher.example +} diff --git a/examples/resources/powerplatform_publisher/import.sh b/examples/resources/powerplatform_publisher/import.sh new file mode 100644 index 000000000..13387162d --- /dev/null +++ b/examples/resources/powerplatform_publisher/import.sh @@ -0,0 +1,2 @@ +# Publisher resource can be imported using the composite id _ +terraform import powerplatform_publisher.example 00000000-0000-0000-0000-000000000001_11111111-1111-1111-1111-111111111111 diff --git a/examples/resources/powerplatform_publisher/outputs.tf b/examples/resources/powerplatform_publisher/outputs.tf new file mode 100644 index 000000000..a6affa16b --- /dev/null +++ b/examples/resources/powerplatform_publisher/outputs.tf @@ -0,0 +1,3 @@ +output "publisher" { + value = powerplatform_publisher.example +} diff --git a/examples/resources/powerplatform_publisher/resource.tf b/examples/resources/powerplatform_publisher/resource.tf new file mode 100644 index 000000000..446fcf5f2 --- /dev/null +++ b/examples/resources/powerplatform_publisher/resource.tf @@ -0,0 +1,28 @@ +resource "powerplatform_publisher" "example" { + environment_id = "00000000-0000-0000-0000-000000000001" + uniquename = "contoso" + friendly_name = "Contoso Publisher" + customization_prefix = "cts" + description = "Terraform-managed Dataverse publisher" + email_address = "publisher@contoso.example" + supporting_website_url = "https://contoso.example" + + address = [ + { + slot = 1 + line1 = "1 Collins Street" + city = "Melbourne" + country = "Australia" + postal_code = "3000" + telephone1 = "+61-3-5555-0101" + }, + { + slot = 2 + line1 = "100 Queen Street" + city = "Auckland" + country = "New Zealand" + postal_code = "1010" + telephone1 = "+64-9-555-0102" + } + ] +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 65d94ef31..950f71192 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -46,6 +46,7 @@ import ( "github.com/microsoft/terraform-provider-power-platform/internal/services/locations" "github.com/microsoft/terraform-provider-power-platform/internal/services/managed_environment" "github.com/microsoft/terraform-provider-power-platform/internal/services/powerapps" + "github.com/microsoft/terraform-provider-power-platform/internal/services/publisher" "github.com/microsoft/terraform-provider-power-platform/internal/services/rest" "github.com/microsoft/terraform-provider-power-platform/internal/services/solution" "github.com/microsoft/terraform-provider-power-platform/internal/services/solution_checker_rules" @@ -405,6 +406,7 @@ func (p *PowerPlatformProvider) Resources(ctx context.Context) []func() resource func() resource.Resource { return licensing.NewBillingPolicyResource() }, func() resource.Resource { return authorization.NewUserResource() }, func() resource.Resource { return data_record.NewDataRecordResource() }, + func() resource.Resource { return publisher.NewPublisherResource() }, func() resource.Resource { return environment_settings.NewEnvironmentSettingsResource() }, func() resource.Resource { return connection.NewConnectionResource() }, func() resource.Resource { return rest.NewDataverseWebApiResource() }, @@ -442,6 +444,7 @@ func (p *PowerPlatformProvider) DataSources(ctx context.Context) []func() dataso func() datasource.DataSource { return authorization.NewSecurityRolesDataSource() }, func() datasource.DataSource { return application.NewTenantApplicationPackagesDataSource() }, func() datasource.DataSource { return data_record.NewDataRecordDataSource() }, + func() datasource.DataSource { return publisher.NewPublisherDataSource() }, func() datasource.DataSource { return rest.NewDataverseWebApiDatasource() }, func() datasource.DataSource { return connection.NewConnectionsDataSource() }, func() datasource.DataSource { return connection.NewConnectionSharesDataSource() }, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 57ace85a9..79f92cd9f 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -38,6 +38,7 @@ import ( "github.com/microsoft/terraform-provider-power-platform/internal/services/locations" "github.com/microsoft/terraform-provider-power-platform/internal/services/managed_environment" "github.com/microsoft/terraform-provider-power-platform/internal/services/powerapps" + "github.com/microsoft/terraform-provider-power-platform/internal/services/publisher" "github.com/microsoft/terraform-provider-power-platform/internal/services/rest" "github.com/microsoft/terraform-provider-power-platform/internal/services/solution" "github.com/microsoft/terraform-provider-power-platform/internal/services/solution_checker_rules" @@ -69,6 +70,7 @@ func TestUnitPowerPlatformProviderHasChildDataSources_Basic(t *testing.T) { connection.NewConnectionsDataSource(), connection.NewConnectionSharesDataSource(), data_record.NewDataRecordDataSource(), + publisher.NewPublisherDataSource(), rest.NewDataverseWebApiDatasource(), capacity.NewTenantCapcityDataSource(), tenant.NewTenantDataSource(), @@ -96,6 +98,7 @@ func TestUnitPowerPlatformProviderHasChildResources_Basic(t *testing.T) { authorization.NewUserResource(), environment_settings.NewEnvironmentSettingsResource(), data_record.NewDataRecordResource(), + publisher.NewPublisherResource(), rest.NewDataverseWebApiResource(), connection.NewConnectionResource(), connection.NewConnectionShareResource(), diff --git a/internal/services/publisher/api_publisher.go b/internal/services/publisher/api_publisher.go new file mode 100644 index 000000000..8514a2e50 --- /dev/null +++ b/internal/services/publisher/api_publisher.go @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/constants" + "github.com/microsoft/terraform-provider-power-platform/internal/customerrors" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" + "github.com/microsoft/terraform-provider-power-platform/internal/services/environment" +) + +type client struct { + Api *api.Client + environmentClient environment.Client +} + +func newPublisherClient(apiClient *api.Client) client { + return client{ + Api: apiClient, + environmentClient: environment.NewEnvironmentClient(apiClient), + } +} + +func (client *client) CreatePublisher(ctx context.Context, environmentId string, model *ResourceModel) (*publisherDto, error) { + environmentHost, err := client.environmentClient.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return nil, err + } + + apiUrl := helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.2/publishers", nil) + resp, err := client.Api.Execute(ctx, nil, http.MethodPost, apiUrl, nil, publisherBodyFromModel(model), []int{http.StatusCreated, http.StatusNoContent, http.StatusForbidden}, nil) + if err != nil { + return nil, err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + + publisherId, err := getPublisherIdFromResponse(resp) + if err != nil { + return nil, err + } + + return client.GetPublisherById(ctx, environmentId, publisherId) +} + +func (client *client) GetPublisherById(ctx context.Context, environmentId, publisherId string) (*publisherDto, error) { + environmentHost, err := client.environmentClient.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return nil, err + } + + apiUrl := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.2/publishers(%s)", publisherId), nil) + publisher := publisherDto{} + resp, err := client.Api.Execute(ctx, nil, http.MethodGet, apiUrl, nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &publisher) + if err != nil { + if resp != nil && resp.HttpResponse.StatusCode == http.StatusNotFound { + return nil, customerrors.WrapIntoProviderError(err, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), fmt.Sprintf("publisher '%s' not found", publisherId)) + } + return nil, err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := client.Api.HandleNotFoundResponse(resp); err != nil { + return nil, customerrors.WrapIntoProviderError(err, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), fmt.Sprintf("publisher '%s' not found", publisherId)) + } + + return &publisher, nil +} + +func (client *client) GetPublisherByUniqueName(ctx context.Context, environmentId, uniqueName string) (*publisherDto, error) { + environmentHost, err := client.environmentClient.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return nil, err + } + + values := url.Values{} + values.Add("$filter", fmt.Sprintf("uniquename eq '%s'", escapeODataString(uniqueName))) + apiUrl := helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.2/publishers", values) + + publishers := publishersDto{} + resp, err := client.Api.Execute(ctx, nil, http.MethodGet, apiUrl, nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &publishers) + if err != nil { + return nil, err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := client.Api.HandleNotFoundResponse(resp); err != nil { + return nil, customerrors.WrapIntoProviderError(err, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), fmt.Sprintf("publisher '%s' not found", uniqueName)) + } + + if len(publishers.Value) == 0 { + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), fmt.Sprintf("publisher '%s' not found", uniqueName)) + } + + return &publishers.Value[0], nil +} + +func (client *client) UpdatePublisher(ctx context.Context, environmentId, publisherId string, model *ResourceModel) (*publisherDto, error) { + environmentHost, err := client.environmentClient.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return nil, err + } + + apiUrl := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.2/publishers(%s)", publisherId), nil) + resp, err := client.Api.Execute(ctx, nil, http.MethodPatch, apiUrl, nil, publisherBodyFromModel(model), []int{http.StatusNoContent, http.StatusForbidden}, nil) + if err != nil { + return nil, err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + + return client.GetPublisherById(ctx, environmentId, publisherId) +} + +func (client *client) DeletePublisher(ctx context.Context, environmentId, publisherId string) error { + environmentHost, err := client.environmentClient.GetEnvironmentHostById(ctx, environmentId) + if err != nil { + return err + } + + apiUrl := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.2/publishers(%s)", publisherId), nil) + resp, err := client.Api.Execute(ctx, nil, http.MethodDelete, apiUrl, nil, nil, []int{http.StatusNoContent, http.StatusNotFound, http.StatusForbidden}, nil) + if err != nil { + return err + } + if err := client.Api.HandleForbiddenResponse(resp); err != nil { + return err + } + if resp.HttpResponse.StatusCode == http.StatusNotFound { + return customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), fmt.Sprintf("publisher '%s' not found", publisherId)) + } + + return nil +} + +func escapeODataString(input string) string { + return strings.ReplaceAll(input, "'", "''") +} diff --git a/internal/services/publisher/datasource_publisher.go b/internal/services/publisher/datasource_publisher.go new file mode 100644 index 000000000..187d45b42 --- /dev/null +++ b/internal/services/publisher/datasource_publisher.go @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" +) + +var _ datasource.DataSource = &DataSource{} +var _ datasource.DataSourceWithConfigure = &DataSource{} + +func NewPublisherDataSource() datasource.DataSource { + return &DataSource{ + TypeInfo: helpers.TypeInfo{ + TypeName: "publisher", + }, + } +} + +func (d *DataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + d.ProviderTypeName = req.ProviderTypeName + + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + resp.TypeName = d.FullTypeName() + tflog.Debug(ctx, fmt.Sprintf("METADATA: %s", resp.TypeName)) +} + +func (d *DataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + resp.Schema = schema.Schema{ + MarkdownDescription: "Fetches a Dataverse publisher by Dataverse publisher id or unique name.", + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Read: true, + }), + "id": schema.StringAttribute{ + MarkdownDescription: "Dataverse publisher id.", + Optional: true, + Computed: true, + }, + "environment_id": schema.StringAttribute{ + MarkdownDescription: "Id of the Dataverse-enabled environment containing the publisher.", + Required: true, + }, + "uniquename": schema.StringAttribute{ + MarkdownDescription: "Unique name of the publisher.", + Optional: true, + Computed: true, + }, + "friendly_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the publisher.", + Computed: true, + }, + "customization_prefix": schema.StringAttribute{ + MarkdownDescription: "Customization prefix used for solution components created by this publisher.", + Computed: true, + }, + "customization_option_value_prefix": schema.Int64Attribute{ + MarkdownDescription: "Option value prefix used for option set values created by this publisher.", + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description of the publisher.", + Computed: true, + }, + "email_address": schema.StringAttribute{ + MarkdownDescription: "Email address for the publisher.", + Computed: true, + }, + "supporting_website_url": schema.StringAttribute{ + MarkdownDescription: "Supporting website URL for the publisher.", + Computed: true, + }, + "is_read_only": schema.BoolAttribute{ + MarkdownDescription: "Whether Dataverse reports this publisher as read only.", + Computed: true, + }, + "address": schema.ListNestedAttribute{ + MarkdownDescription: "Publisher addresses mapped from Dataverse address slots 1 and 2.", + Computed: true, + Validators: []validator.List{ + listvalidator.SizeAtMost(2), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "slot": schema.Int64Attribute{ + MarkdownDescription: "Address slot number in Dataverse. Valid values are `1` and `2`.", + Computed: true, + Validators: []validator.Int64{ + int64validator.OneOf(1, 2), + }, + }, + "address_id": schema.StringAttribute{ + MarkdownDescription: "Dataverse address identifier for the slot.", + Computed: true, + }, + "address_type_code": schema.Int64Attribute{ + MarkdownDescription: "Address type code.", + Computed: true, + }, + "city": schema.StringAttribute{ + MarkdownDescription: "City name.", + Computed: true, + }, + "country": schema.StringAttribute{ + MarkdownDescription: "Country or region name.", + Computed: true, + }, + "county": schema.StringAttribute{ + MarkdownDescription: "County name.", + Computed: true, + }, + "fax": schema.StringAttribute{ + MarkdownDescription: "Fax number.", + Computed: true, + }, + "latitude": schema.Float64Attribute{ + MarkdownDescription: "Latitude value.", + Computed: true, + }, + "line1": schema.StringAttribute{ + MarkdownDescription: "Street line 1.", + Computed: true, + }, + "line2": schema.StringAttribute{ + MarkdownDescription: "Street line 2.", + Computed: true, + }, + "line3": schema.StringAttribute{ + MarkdownDescription: "Street line 3.", + Computed: true, + }, + "longitude": schema.Float64Attribute{ + MarkdownDescription: "Longitude value.", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Address name.", + Computed: true, + }, + "postal_code": schema.StringAttribute{ + MarkdownDescription: "Postal code.", + Computed: true, + }, + "post_office_box": schema.StringAttribute{ + MarkdownDescription: "Post office box.", + Computed: true, + }, + "shipping_method_code": schema.Int64Attribute{ + MarkdownDescription: "Shipping method code.", + Computed: true, + }, + "state_or_province": schema.StringAttribute{ + MarkdownDescription: "State or province name.", + Computed: true, + }, + "telephone1": schema.StringAttribute{ + MarkdownDescription: "Primary telephone number.", + Computed: true, + }, + "telephone2": schema.StringAttribute{ + MarkdownDescription: "Secondary telephone number.", + Computed: true, + }, + "telephone3": schema.StringAttribute{ + MarkdownDescription: "Tertiary telephone number.", + Computed: true, + }, + "ups_zone": schema.StringAttribute{ + MarkdownDescription: "UPS zone value.", + Computed: true, + }, + "utc_offset": schema.Int64Attribute{ + MarkdownDescription: "UTC offset for the address.", + Computed: true, + }, + }, + }, + }, + }, + } +} + +func (d *DataSource) ConfigValidators(ctx context.Context) []datasource.ConfigValidator { + return []datasource.ConfigValidator{ + datasourcevalidator.ExactlyOneOf( + path.MatchRoot("id"), + path.MatchRoot("uniquename"), + ), + } +} + +func (d *DataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + if req.ProviderData == nil { + return + } + + providerClient, ok := req.ProviderData.(*api.ProviderClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected ProviderData Type", + fmt.Sprintf("Expected *api.ProviderClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.PublisherClient = newPublisherClient(providerClient.Api) +} + +func (d *DataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, d.TypeInfo, req) + defer exitContext() + + var config DataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + var publisher *publisherDto + var err error + switch lookupTarget := publisherLookupTarget(config); lookupTarget { + case publisherLookupTargetDeferred: + tflog.Debug(ctx, "Skipping publisher lookup until the selected lookup attribute is known") + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) + return + case publisherLookupTargetID: + publisher, err = d.PublisherClient.GetPublisherById(ctx, config.EnvironmentId.ValueString(), config.Id.ValueString()) + case publisherLookupTargetUniqueName: + publisher, err = d.PublisherClient.GetPublisherByUniqueName(ctx, config.EnvironmentId.ValueString(), config.UniqueName.ValueString()) + default: + resp.Diagnostics.AddError( + "Invalid publisher lookup configuration", + "Exactly one of `id` or `uniquename` must be configured with a known non-empty value.", + ) + return + } + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", d.FullTypeName()), err.Error()) + return + } + + setDataSourceModelFromDto(&config, config.EnvironmentId.ValueString(), publisher) + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +func setDataSourceModelFromDto(model *DataSourceModel, environmentId string, publisher *publisherDto) { + model.Id = types.StringValue(publisher.Id) + model.EnvironmentId = types.StringValue(environmentId) + model.UniqueName = types.StringValue(publisher.UniqueName) + model.FriendlyName = types.StringValue(publisher.FriendlyName) + model.CustomizationPrefix = types.StringValue(publisher.CustomizationPrefix) + model.CustomizationOptionValuePrefix = types.Int64Value(publisher.CustomizationOptionValuePrefix) + model.Description = nullableStringValue(publisher.Description) + model.EmailAddress = nullableStringValue(publisher.EmailAddress) + model.SupportingWebsiteURL = nullableStringValue(publisher.SupportingWebsiteURL) + model.IsReadOnly = types.BoolValue(publisher.IsReadOnly) + model.Address = addressModelsFromDto(publisher, model.Address) +} + +type publisherLookupTargetType string + +const ( + publisherLookupTargetInvalid publisherLookupTargetType = "invalid" + publisherLookupTargetDeferred publisherLookupTargetType = "deferred" + publisherLookupTargetID publisherLookupTargetType = "id" + publisherLookupTargetUniqueName publisherLookupTargetType = "uniquename" +) + +func publisherLookupTarget(config DataSourceModel) publisherLookupTargetType { + if !config.Id.IsNull() { + if config.Id.IsUnknown() { + return publisherLookupTargetDeferred + } + if config.Id.ValueString() != "" { + return publisherLookupTargetID + } + return publisherLookupTargetInvalid + } + + if !config.UniqueName.IsNull() { + if config.UniqueName.IsUnknown() { + return publisherLookupTargetDeferred + } + if config.UniqueName.ValueString() != "" { + return publisherLookupTargetUniqueName + } + return publisherLookupTargetInvalid + } + + return publisherLookupTargetInvalid +} diff --git a/internal/services/publisher/datasource_publisher_internal_test.go b/internal/services/publisher/datasource_publisher_internal_test.go new file mode 100644 index 000000000..d94a8947c --- /dev/null +++ b/internal/services/publisher/datasource_publisher_internal_test.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUnitPublisherLookupTarget_UsesIDWhenKnown(t *testing.T) { + got := publisherLookupTarget(DataSourceModel{ + Id: types.StringValue("11111111-1111-1111-1111-111111111111"), + UniqueName: types.StringNull(), + }) + + if got != publisherLookupTargetID { + t.Fatalf("expected id lookup target, got %q", got) + } +} + +func TestUnitPublisherLookupTarget_DefersUnknownID(t *testing.T) { + got := publisherLookupTarget(DataSourceModel{ + Id: types.StringUnknown(), + UniqueName: types.StringNull(), + }) + + if got != publisherLookupTargetDeferred { + t.Fatalf("expected deferred lookup target for unknown id, got %q", got) + } +} + +func TestUnitPublisherLookupTarget_UsesUniqueNameWhenKnown(t *testing.T) { + got := publisherLookupTarget(DataSourceModel{ + Id: types.StringNull(), + UniqueName: types.StringValue("contoso"), + }) + + if got != publisherLookupTargetUniqueName { + t.Fatalf("expected uniquename lookup target, got %q", got) + } +} + +func TestUnitPublisherLookupTarget_DefersUnknownUniqueName(t *testing.T) { + got := publisherLookupTarget(DataSourceModel{ + Id: types.StringNull(), + UniqueName: types.StringUnknown(), + }) + + if got != publisherLookupTargetDeferred { + t.Fatalf("expected deferred lookup target for unknown uniquename, got %q", got) + } +} diff --git a/internal/services/publisher/datasource_publisher_test.go b/internal/services/publisher/datasource_publisher_test.go new file mode 100644 index 000000000..c53e14612 --- /dev/null +++ b/internal/services/publisher/datasource_publisher_test.go @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher_test + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/jarcoal/httpmock" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" + "github.com/microsoft/terraform-provider-power-platform/internal/mocks" +) + +func TestUnitPublisherDataSource_Validate_Read_ByUniqueName(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + registerPublisherEnvironmentMock() + + httpmock.RegisterResponder("GET", "https://"+testPublisherHost+"/api/data/v9.2/publishers?%24filter=uniquename+eq+%27contoso%27", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[`+publisherCreateResponse()+`]}`), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + ResourceName: "data.powerplatform_publisher.example", + Config: ` +data "powerplatform_publisher" "example" { + environment_id = "` + testEnvironmentID + `" + uniquename = "contoso" +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.powerplatform_publisher.example", "id", testPublisherID), + resource.TestCheckResourceAttr("data.powerplatform_publisher.example", "friendly_name", "Contoso Publisher"), + resource.TestCheckResourceAttr("data.powerplatform_publisher.example", "address.#", "2"), + ), + }, + }, + }) +} + +func TestAccPublisherDataSource_Validate_Read(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: mocks.TestAccProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "time": { + Source: "hashicorp/time", + }, + }, + Steps: []resource.TestStep{ + { + Config: publisherAcceptanceDataSourceByUniqueNameConfig(mocks.TestName(), "terraformpublisherds", "Terraform Publisher DS", "tpd"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr("data.powerplatform_publisher.example", "id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestCheckResourceAttr("data.powerplatform_publisher.example", "uniquename", "terraformpublisherds"), + resource.TestCheckResourceAttr("data.powerplatform_publisher.example", "friendly_name", "Terraform Publisher DS"), + ), + }, + { + Config: publisherAcceptanceDataSourceByIDConfig(mocks.TestName(), "terraformpublisherds", "Terraform Publisher DS", "tpd"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr("data.powerplatform_publisher.example", "id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestCheckResourceAttrPair("data.powerplatform_publisher.example", "id", "powerplatform_publisher.example", "id"), + ), + }, + }, + }) +} + +func publisherAcceptanceDataSourceByUniqueNameConfig(environmentDisplayName, uniqueName, friendlyName, customizationPrefix string) string { + return fmt.Sprintf(` +resource "powerplatform_environment" "environment" { + display_name = "%s" + location = "unitedstates" + environment_type = "Sandbox" + dataverse = { + language_code = "1033" + currency_code = "USD" + security_group_id = "00000000-0000-0000-0000-000000000000" + } +} + +resource "time_sleep" "wait_120_seconds" { + depends_on = [powerplatform_environment.environment] + create_duration = "120s" +} + +resource "powerplatform_publisher" "example" { + depends_on = [time_sleep.wait_120_seconds] + environment_id = powerplatform_environment.environment.id + uniquename = "%s" + friendly_name = "%s" + customization_prefix = "%s" +} + +data "powerplatform_publisher" "example" { + environment_id = powerplatform_environment.environment.id + uniquename = powerplatform_publisher.example.uniquename +} +`, environmentDisplayName, uniqueName, friendlyName, customizationPrefix) +} + +func publisherAcceptanceDataSourceByIDConfig(environmentDisplayName, uniqueName, friendlyName, customizationPrefix string) string { + return fmt.Sprintf(` +resource "powerplatform_environment" "environment" { + display_name = "%s" + location = "unitedstates" + environment_type = "Sandbox" + dataverse = { + language_code = "1033" + currency_code = "USD" + security_group_id = "00000000-0000-0000-0000-000000000000" + } +} + +resource "time_sleep" "wait_120_seconds" { + depends_on = [powerplatform_environment.environment] + create_duration = "120s" +} + +resource "powerplatform_publisher" "example" { + depends_on = [time_sleep.wait_120_seconds] + environment_id = powerplatform_environment.environment.id + uniquename = "%s" + friendly_name = "%s" + customization_prefix = "%s" +} + +data "powerplatform_publisher" "example" { + environment_id = powerplatform_environment.environment.id + id = powerplatform_publisher.example.id +} +`, environmentDisplayName, uniqueName, friendlyName, customizationPrefix) +} diff --git a/internal/services/publisher/dto.go b/internal/services/publisher/dto.go new file mode 100644 index 000000000..ee8d0129c --- /dev/null +++ b/internal/services/publisher/dto.go @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +type publisherDto struct { + Id string `json:"publisherid"` + FriendlyName string `json:"friendlyname"` + UniqueName string `json:"uniquename"` + CustomizationPrefix string `json:"customizationprefix"` + CustomizationOptionValuePrefix int64 `json:"customizationoptionvalueprefix"` + Description string `json:"description"` + EmailAddress string `json:"emailaddress"` + SupportingWebsiteURL string `json:"supportingwebsiteurl"` + IsReadOnly bool `json:"isreadonly"` + + Address1AddressId string `json:"address1_addressid"` + Address1AddressTypeCode *int64 `json:"address1_addresstypecode"` + Address1City string `json:"address1_city"` + Address1Country string `json:"address1_country"` + Address1County string `json:"address1_county"` + Address1Fax string `json:"address1_fax"` + Address1Latitude *float64 `json:"address1_latitude"` + Address1Line1 string `json:"address1_line1"` + Address1Line2 string `json:"address1_line2"` + Address1Line3 string `json:"address1_line3"` + Address1Longitude *float64 `json:"address1_longitude"` + Address1Name string `json:"address1_name"` + Address1PostalCode string `json:"address1_postalcode"` + Address1PostOfficeBox string `json:"address1_postofficebox"` + Address1ShippingMethodCode *int64 `json:"address1_shippingmethodcode"` + Address1StateOrProvince string `json:"address1_stateorprovince"` + Address1Telephone1 string `json:"address1_telephone1"` + Address1Telephone2 string `json:"address1_telephone2"` + Address1Telephone3 string `json:"address1_telephone3"` + Address1UpsZone string `json:"address1_upszone"` + Address1UtcOffset *int64 `json:"address1_utcoffset"` + + Address2AddressId string `json:"address2_addressid"` + Address2AddressTypeCode *int64 `json:"address2_addresstypecode"` + Address2City string `json:"address2_city"` + Address2Country string `json:"address2_country"` + Address2County string `json:"address2_county"` + Address2Fax string `json:"address2_fax"` + Address2Latitude *float64 `json:"address2_latitude"` + Address2Line1 string `json:"address2_line1"` + Address2Line2 string `json:"address2_line2"` + Address2Line3 string `json:"address2_line3"` + Address2Longitude *float64 `json:"address2_longitude"` + Address2Name string `json:"address2_name"` + Address2PostalCode string `json:"address2_postalcode"` + Address2PostOfficeBox string `json:"address2_postofficebox"` + Address2ShippingMethodCode *int64 `json:"address2_shippingmethodcode"` + Address2StateOrProvince string `json:"address2_stateorprovince"` + Address2Telephone1 string `json:"address2_telephone1"` + Address2Telephone2 string `json:"address2_telephone2"` + Address2Telephone3 string `json:"address2_telephone3"` + Address2UpsZone string `json:"address2_upszone"` + Address2UtcOffset *int64 `json:"address2_utcoffset"` +} + +type publishersDto struct { + Value []publisherDto `json:"value"` +} diff --git a/internal/services/publisher/models.go b/internal/services/publisher/models.go new file mode 100644 index 000000000..556398db6 --- /dev/null +++ b/internal/services/publisher/models.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +import ( + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" +) + +type Resource struct { + helpers.TypeInfo + PublisherClient client +} + +type DataSource struct { + helpers.TypeInfo + PublisherClient client +} + +type PublisherAddressModel struct { + Slot types.Int64 `tfsdk:"slot"` + AddressId types.String `tfsdk:"address_id"` + AddressTypeCode types.Int64 `tfsdk:"address_type_code"` + City types.String `tfsdk:"city"` + Country types.String `tfsdk:"country"` + County types.String `tfsdk:"county"` + Fax types.String `tfsdk:"fax"` + Latitude types.Float64 `tfsdk:"latitude"` + Line1 types.String `tfsdk:"line1"` + Line2 types.String `tfsdk:"line2"` + Line3 types.String `tfsdk:"line3"` + Longitude types.Float64 `tfsdk:"longitude"` + Name types.String `tfsdk:"name"` + PostalCode types.String `tfsdk:"postal_code"` + PostOfficeBox types.String `tfsdk:"post_office_box"` + ShippingMethodCode types.Int64 `tfsdk:"shipping_method_code"` + StateOrProvince types.String `tfsdk:"state_or_province"` + Telephone1 types.String `tfsdk:"telephone1"` + Telephone2 types.String `tfsdk:"telephone2"` + Telephone3 types.String `tfsdk:"telephone3"` + UpsZone types.String `tfsdk:"ups_zone"` + UtcOffset types.Int64 `tfsdk:"utc_offset"` +} + +type ResourceModel struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + Id types.String `tfsdk:"id"` + EnvironmentId types.String `tfsdk:"environment_id"` + UniqueName types.String `tfsdk:"uniquename"` + FriendlyName types.String `tfsdk:"friendly_name"` + CustomizationPrefix types.String `tfsdk:"customization_prefix"` + CustomizationOptionValuePrefix types.Int64 `tfsdk:"customization_option_value_prefix"` + Description types.String `tfsdk:"description"` + EmailAddress types.String `tfsdk:"email_address"` + SupportingWebsiteURL types.String `tfsdk:"supporting_website_url"` + IsReadOnly types.Bool `tfsdk:"is_read_only"` + Address []PublisherAddressModel `tfsdk:"address"` +} + +type DataSourceModel struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + Id types.String `tfsdk:"id"` + EnvironmentId types.String `tfsdk:"environment_id"` + UniqueName types.String `tfsdk:"uniquename"` + FriendlyName types.String `tfsdk:"friendly_name"` + CustomizationPrefix types.String `tfsdk:"customization_prefix"` + CustomizationOptionValuePrefix types.Int64 `tfsdk:"customization_option_value_prefix"` + Description types.String `tfsdk:"description"` + EmailAddress types.String `tfsdk:"email_address"` + SupportingWebsiteURL types.String `tfsdk:"supporting_website_url"` + IsReadOnly types.Bool `tfsdk:"is_read_only"` + Address []PublisherAddressModel `tfsdk:"address"` +} diff --git a/internal/services/publisher/resource_publisher.go b/internal/services/publisher/resource_publisher.go new file mode 100644 index 000000000..4abcebde2 --- /dev/null +++ b/internal/services/publisher/resource_publisher.go @@ -0,0 +1,895 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + stdpath "path" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/customerrors" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" +) + +var _ resource.Resource = &Resource{} +var _ resource.ResourceWithConfigure = &Resource{} +var _ resource.ResourceWithImportState = &Resource{} +var _ resource.ResourceWithModifyPlan = &Resource{} + +var canonicalGuidRegex = regexp.MustCompile(helpers.GuidRegex) + +func NewPublisherResource() resource.Resource { + return &Resource{ + TypeInfo: helpers.TypeInfo{ + TypeName: "publisher", + }, + } +} + +func (r *Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + r.ProviderTypeName = req.ProviderTypeName + + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + resp.TypeName = r.FullTypeName() + tflog.Debug(ctx, fmt.Sprintf("METADATA: %s", resp.TypeName)) +} + +func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a Dataverse publisher record. Publishers own solution customization prefixes and related metadata.", + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Read: true, + Update: true, + Delete: true, + }), + "id": schema.StringAttribute{ + MarkdownDescription: "Dataverse publisher id.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "environment_id": schema.StringAttribute{ + MarkdownDescription: "Id of the Dataverse-enabled environment containing the publisher.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "uniquename": schema.StringAttribute{ + MarkdownDescription: "Unique name of the publisher.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "friendly_name": schema.StringAttribute{ + MarkdownDescription: "Display name of the publisher.", + Required: true, + }, + "customization_prefix": schema.StringAttribute{ + MarkdownDescription: "Customization prefix used for solution components created by this publisher.", + Required: true, + }, + "customization_option_value_prefix": schema.Int64Attribute{ + MarkdownDescription: "Option value prefix used for option set values created by this publisher. When omitted, the provider derives the same default value generated by the Power Apps publisher UI from `customization_prefix`.", + Optional: true, + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description of the publisher.", + Optional: true, + }, + "email_address": schema.StringAttribute{ + MarkdownDescription: "Email address for the publisher.", + Optional: true, + }, + "supporting_website_url": schema.StringAttribute{ + MarkdownDescription: "Supporting website URL for the publisher.", + Optional: true, + }, + "is_read_only": schema.BoolAttribute{ + MarkdownDescription: "Whether Dataverse reports this publisher as read only.", + Computed: true, + }, + "address": schema.ListNestedAttribute{ + MarkdownDescription: "Up to two publisher addresses, mapped to Dataverse address slots 1 and 2.", + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.List{ + listvalidator.SizeAtMost(2), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "slot": schema.Int64Attribute{ + MarkdownDescription: "Address slot number in Dataverse. Valid values are `1` and `2`.", + Required: true, + Validators: []validator.Int64{ + int64validator.OneOf(1, 2), + }, + }, + "address_id": schema.StringAttribute{ + MarkdownDescription: "Dataverse address identifier for the slot.", + Computed: true, + }, + "address_type_code": schema.Int64Attribute{ + MarkdownDescription: "Address type code.", + Optional: true, + }, + "city": schema.StringAttribute{ + MarkdownDescription: "City name.", + Optional: true, + }, + "country": schema.StringAttribute{ + MarkdownDescription: "Country or region name.", + Optional: true, + }, + "county": schema.StringAttribute{ + MarkdownDescription: "County name.", + Optional: true, + }, + "fax": schema.StringAttribute{ + MarkdownDescription: "Fax number.", + Optional: true, + }, + "latitude": schema.Float64Attribute{ + MarkdownDescription: "Latitude value.", + Optional: true, + }, + "line1": schema.StringAttribute{ + MarkdownDescription: "Street line 1.", + Optional: true, + }, + "line2": schema.StringAttribute{ + MarkdownDescription: "Street line 2.", + Optional: true, + }, + "line3": schema.StringAttribute{ + MarkdownDescription: "Street line 3.", + Optional: true, + }, + "longitude": schema.Float64Attribute{ + MarkdownDescription: "Longitude value.", + Optional: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Address name.", + Optional: true, + }, + "postal_code": schema.StringAttribute{ + MarkdownDescription: "Postal code.", + Optional: true, + }, + "post_office_box": schema.StringAttribute{ + MarkdownDescription: "Post office box.", + Optional: true, + }, + "shipping_method_code": schema.Int64Attribute{ + MarkdownDescription: "Shipping method code.", + Optional: true, + }, + "state_or_province": schema.StringAttribute{ + MarkdownDescription: "State or province name.", + Optional: true, + }, + "telephone1": schema.StringAttribute{ + MarkdownDescription: "Primary telephone number.", + Optional: true, + }, + "telephone2": schema.StringAttribute{ + MarkdownDescription: "Secondary telephone number.", + Optional: true, + }, + "telephone3": schema.StringAttribute{ + MarkdownDescription: "Tertiary telephone number.", + Optional: true, + }, + "ups_zone": schema.StringAttribute{ + MarkdownDescription: "UPS zone value.", + Optional: true, + }, + "utc_offset": schema.Int64Attribute{ + MarkdownDescription: "UTC offset for the address.", + Optional: true, + }, + }, + }, + }, + }, + } +} + +func (r *Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + if req.Plan.Raw.IsNull() { + return + } + + var config ResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + var plan ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state ResourceModel + hasState := !req.State.Raw.IsNull() + if hasState { + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + } + + setDerivedCustomizationOptionValuePrefix(&plan, &config, &state, hasState) + resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) +} + +func (r *Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + if req.ProviderData == nil { + return + } + + providerClient, ok := req.ProviderData.(*api.ProviderClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected ProviderData Type", + fmt.Sprintf("Expected *api.ProviderClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.PublisherClient = newPublisherClient(providerClient.Api) +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(validateAddressSlots(plan.Address)...) + if resp.Diagnostics.HasError() { + return + } + + publisher, err := r.PublisherClient.CreatePublisher(ctx, plan.EnvironmentId.ValueString(), &plan) + if err != nil { + if isPublisherAlreadyExistsError(err) { + existing, lookupErr := r.PublisherClient.GetPublisherByUniqueName(ctx, plan.EnvironmentId.ValueString(), plan.UniqueName.ValueString()) + if lookupErr == nil && existing != nil { + resp.Diagnostics.AddError( + "Publisher already exists", + fmt.Sprintf( + "A Dataverse publisher with unique name `%s` already exists in environment `%s`. Import it using `terraform import %s` or choose a different `uniquename`.", + plan.UniqueName.ValueString(), + plan.EnvironmentId.ValueString(), + buildPublisherImportId(plan.EnvironmentId.ValueString(), existing.Id), + ), + ) + return + } + + resp.Diagnostics.AddError( + "Publisher already exists", + fmt.Sprintf( + "A Dataverse publisher with unique name `%s` already exists in environment `%s`. Import the existing publisher into state or choose a different `uniquename`.", + plan.UniqueName.ValueString(), + plan.EnvironmentId.ValueString(), + ), + ) + return + } + + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating %s", r.FullTypeName()), err.Error()) + return + } + + setResourceModelFromDto(&plan, plan.EnvironmentId.ValueString(), publisher) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + publisher, err := r.PublisherClient.GetPublisherById(ctx, state.EnvironmentId.ValueString(), state.Id.ValueString()) + if err != nil { + if errors.Is(err, customerrors.ErrObjectNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", r.FullTypeName()), err.Error()) + return + } + + setResourceModelFromDto(&state, state.EnvironmentId.ValueString(), publisher) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + var state ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(validateAddressSlots(plan.Address)...) + if resp.Diagnostics.HasError() { + return + } + + publisher, err := r.PublisherClient.UpdatePublisher(ctx, state.EnvironmentId.ValueString(), state.Id.ValueString(), &plan) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when updating %s", r.FullTypeName()), err.Error()) + return + } + + setResourceModelFromDto(&plan, state.EnvironmentId.ValueString(), publisher) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if state.Id.IsNull() || state.EnvironmentId.IsNull() { + return + } + + err := r.PublisherClient.DeletePublisher(ctx, state.EnvironmentId.ValueString(), state.Id.ValueString()) + if err != nil && !errors.Is(err, customerrors.ErrObjectNotFound) { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when deleting %s", r.FullTypeName()), err.Error()) + } +} + +func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + parts := strings.SplitN(req.ID, "_", 2) + if len(parts) != 2 || !isGuid(parts[0]) || !isGuid(parts[1]) { + resp.Diagnostics.AddError( + "Invalid import ID", + fmt.Sprintf("Expected import ID in format 'environment_id_publisher_id', got '%s'", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("environment_id"), parts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), parts[1])...) +} + +func validateAddressSlots(addresses []PublisherAddressModel) (diags diag.Diagnostics) { + seen := map[int64]struct{}{} + for idx, address := range addresses { + if address.Slot.IsNull() || address.Slot.IsUnknown() { + diags.AddAttributeError( + path.Root("address").AtListIndex(idx).AtName("slot"), + "Missing address slot", + "Each address entry must declare a slot value of 1 or 2.", + ) + continue + } + + slot := address.Slot.ValueInt64() + if _, exists := seen[slot]; exists { + diags.AddAttributeError( + path.Root("address").AtListIndex(idx).AtName("slot"), + "Duplicate address slot", + fmt.Sprintf("Only one address entry may target slot %d.", slot), + ) + continue + } + seen[slot] = struct{}{} + } + return diags +} + +func setResourceModelFromDto(model *ResourceModel, environmentId string, publisher *publisherDto) { + existingAddresses := model.Address + existingDescription := model.Description + existingEmailAddress := model.EmailAddress + existingSupportingWebsiteURL := model.SupportingWebsiteURL + + model.Id = types.StringValue(publisher.Id) + model.EnvironmentId = types.StringValue(environmentId) + model.UniqueName = types.StringValue(publisher.UniqueName) + model.FriendlyName = normalizeCaseInsensitiveConfigString(publisher.FriendlyName, model.FriendlyName) + model.CustomizationPrefix = types.StringValue(publisher.CustomizationPrefix) + model.CustomizationOptionValuePrefix = types.Int64Value(publisher.CustomizationOptionValuePrefix) + model.Description = normalizeNullableConfigString(publisher.Description, existingDescription) + model.EmailAddress = normalizeNullableConfigString(publisher.EmailAddress, existingEmailAddress) + model.SupportingWebsiteURL = normalizeNullableConfigString(publisher.SupportingWebsiteURL, existingSupportingWebsiteURL) + model.IsReadOnly = types.BoolValue(publisher.IsReadOnly) + model.Address = addressModelsFromDto(publisher, existingAddresses) +} + +func publisherBodyFromModel(model *ResourceModel) map[string]any { + body := map[string]any{ + "uniquename": model.UniqueName.ValueString(), + "friendlyname": model.FriendlyName.ValueString(), + "customizationprefix": model.CustomizationPrefix.ValueString(), + "customizationoptionvalueprefix": effectiveCustomizationOptionValuePrefix(model), + "description": nullableStringPointer(model.Description), + "emailaddress": nullableStringPointer(model.EmailAddress), + "supportingwebsiteurl": nullableStringPointer(model.SupportingWebsiteURL), + } + + addressBySlot := map[int64]PublisherAddressModel{} + for _, address := range model.Address { + if address.Slot.IsNull() || address.Slot.IsUnknown() { + continue + } + addressBySlot[address.Slot.ValueInt64()] = address + } + + for _, slot := range []int64{1, 2} { + address := addressBySlot[slot] + prefix := fmt.Sprintf("address%d_", slot) + body[prefix+"addresstypecode"] = nullableInt64Pointer(address.AddressTypeCode) + body[prefix+"city"] = nullableStringPointer(address.City) + body[prefix+"country"] = nullableStringPointer(address.Country) + body[prefix+"county"] = nullableStringPointer(address.County) + body[prefix+"fax"] = nullableStringPointer(address.Fax) + body[prefix+"latitude"] = nullableFloat64Pointer(address.Latitude) + body[prefix+"line1"] = nullableStringPointer(address.Line1) + body[prefix+"line2"] = nullableStringPointer(address.Line2) + body[prefix+"line3"] = nullableStringPointer(address.Line3) + body[prefix+"longitude"] = nullableFloat64Pointer(address.Longitude) + body[prefix+"name"] = nullableStringPointer(address.Name) + body[prefix+"postalcode"] = nullableStringPointer(address.PostalCode) + body[prefix+"postofficebox"] = nullableStringPointer(address.PostOfficeBox) + body[prefix+"shippingmethodcode"] = nullableInt64Pointer(address.ShippingMethodCode) + body[prefix+"stateorprovince"] = nullableStringPointer(address.StateOrProvince) + body[prefix+"telephone1"] = nullableStringPointer(address.Telephone1) + body[prefix+"telephone2"] = nullableStringPointer(address.Telephone2) + body[prefix+"telephone3"] = nullableStringPointer(address.Telephone3) + body[prefix+"upszone"] = nullableStringPointer(address.UpsZone) + body[prefix+"utcoffset"] = nullableInt64Pointer(address.UtcOffset) + } + + return body +} + +func addressModelsFromDto(publisher *publisherDto, existing []PublisherAddressModel) []PublisherAddressModel { + var addresses []PublisherAddressModel + + if address1 := addressModelFromDtoWithExisting(1, publisher, findAddressBySlot(existing, 1)); address1 != nil { + if !isPlaceholderAddressModel(*address1) || hasAddressSlot(existing, 1) { + addresses = append(addresses, *address1) + } + } + if address2 := addressModelFromDtoWithExisting(2, publisher, findAddressBySlot(existing, 2)); address2 != nil { + if !isPlaceholderAddressModel(*address2) || hasAddressSlot(existing, 2) { + addresses = append(addresses, *address2) + } + } + + if len(addresses) == 0 { + if existing != nil { + return []PublisherAddressModel{} + } + return nil + } + + return addresses +} + +func addressModelFromDto(slot int64, publisher *publisherDto) *PublisherAddressModel { + if publisher == nil { + return nil + } + + model := PublisherAddressModel{ + Slot: types.Int64Value(slot), + } + + switch slot { + case 1: + model.AddressId = nullableStringValue(publisher.Address1AddressId) + model.AddressTypeCode = nullableInt64Value(publisher.Address1AddressTypeCode) + model.City = nullableStringValue(publisher.Address1City) + model.Country = nullableStringValue(publisher.Address1Country) + model.County = nullableStringValue(publisher.Address1County) + model.Fax = nullableStringValue(publisher.Address1Fax) + model.Latitude = nullableFloat64Value(publisher.Address1Latitude) + model.Line1 = nullableStringValue(publisher.Address1Line1) + model.Line2 = nullableStringValue(publisher.Address1Line2) + model.Line3 = nullableStringValue(publisher.Address1Line3) + model.Longitude = nullableFloat64Value(publisher.Address1Longitude) + model.Name = nullableStringValue(publisher.Address1Name) + model.PostalCode = nullableStringValue(publisher.Address1PostalCode) + model.PostOfficeBox = nullableStringValue(publisher.Address1PostOfficeBox) + model.ShippingMethodCode = nullableInt64Value(publisher.Address1ShippingMethodCode) + model.StateOrProvince = nullableStringValue(publisher.Address1StateOrProvince) + model.Telephone1 = nullableStringValue(publisher.Address1Telephone1) + model.Telephone2 = nullableStringValue(publisher.Address1Telephone2) + model.Telephone3 = nullableStringValue(publisher.Address1Telephone3) + model.UpsZone = nullableStringValue(publisher.Address1UpsZone) + model.UtcOffset = nullableInt64Value(publisher.Address1UtcOffset) + case 2: + model.AddressId = nullableStringValue(publisher.Address2AddressId) + model.AddressTypeCode = nullableInt64Value(publisher.Address2AddressTypeCode) + model.City = nullableStringValue(publisher.Address2City) + model.Country = nullableStringValue(publisher.Address2Country) + model.County = nullableStringValue(publisher.Address2County) + model.Fax = nullableStringValue(publisher.Address2Fax) + model.Latitude = nullableFloat64Value(publisher.Address2Latitude) + model.Line1 = nullableStringValue(publisher.Address2Line1) + model.Line2 = nullableStringValue(publisher.Address2Line2) + model.Line3 = nullableStringValue(publisher.Address2Line3) + model.Longitude = nullableFloat64Value(publisher.Address2Longitude) + model.Name = nullableStringValue(publisher.Address2Name) + model.PostalCode = nullableStringValue(publisher.Address2PostalCode) + model.PostOfficeBox = nullableStringValue(publisher.Address2PostOfficeBox) + model.ShippingMethodCode = nullableInt64Value(publisher.Address2ShippingMethodCode) + model.StateOrProvince = nullableStringValue(publisher.Address2StateOrProvince) + model.Telephone1 = nullableStringValue(publisher.Address2Telephone1) + model.Telephone2 = nullableStringValue(publisher.Address2Telephone2) + model.Telephone3 = nullableStringValue(publisher.Address2Telephone3) + model.UpsZone = nullableStringValue(publisher.Address2UpsZone) + model.UtcOffset = nullableInt64Value(publisher.Address2UtcOffset) + } + + if model.AddressId.IsNull() && + model.AddressTypeCode.IsNull() && + model.City.IsNull() && + model.Country.IsNull() && + model.County.IsNull() && + model.Fax.IsNull() && + model.Latitude.IsNull() && + model.Line1.IsNull() && + model.Line2.IsNull() && + model.Line3.IsNull() && + model.Longitude.IsNull() && + model.Name.IsNull() && + model.PostalCode.IsNull() && + model.PostOfficeBox.IsNull() && + model.ShippingMethodCode.IsNull() && + model.StateOrProvince.IsNull() && + model.Telephone1.IsNull() && + model.Telephone2.IsNull() && + model.Telephone3.IsNull() && + model.UpsZone.IsNull() && + model.UtcOffset.IsNull() { + return nil + } + + if isAddressContentEmpty(model) && model.AddressTypeCode.IsNull() && model.ShippingMethodCode.IsNull() { + return nil + } + + return &model +} + +func addressModelFromDtoWithExisting(slot int64, publisher *publisherDto, existing *PublisherAddressModel) *PublisherAddressModel { + model := addressModelFromDto(slot, publisher) + if model == nil || existing == nil { + return model + } + + model.City = normalizeNullableConfigStringValue(model.City, existing.City) + model.Country = normalizeNullableConfigStringValue(model.Country, existing.Country) + model.County = normalizeNullableConfigStringValue(model.County, existing.County) + model.Fax = normalizeNullableConfigStringValue(model.Fax, existing.Fax) + model.Line1 = normalizeNullableConfigStringValue(model.Line1, existing.Line1) + model.Line2 = normalizeNullableConfigStringValue(model.Line2, existing.Line2) + model.Line3 = normalizeNullableConfigStringValue(model.Line3, existing.Line3) + model.Name = normalizeNullableConfigStringValue(model.Name, existing.Name) + model.PostalCode = normalizeNullableConfigStringValue(model.PostalCode, existing.PostalCode) + model.PostOfficeBox = normalizeNullableConfigStringValue(model.PostOfficeBox, existing.PostOfficeBox) + model.StateOrProvince = normalizeNullableConfigStringValue(model.StateOrProvince, existing.StateOrProvince) + model.Telephone1 = normalizeNullableConfigStringValue(model.Telephone1, existing.Telephone1) + model.Telephone2 = normalizeNullableConfigStringValue(model.Telephone2, existing.Telephone2) + model.Telephone3 = normalizeNullableConfigStringValue(model.Telephone3, existing.Telephone3) + model.UpsZone = normalizeNullableConfigStringValue(model.UpsZone, existing.UpsZone) + + return model +} + +func hasAddressSlot(addresses []PublisherAddressModel, slot int64) bool { + for _, address := range addresses { + if address.Slot.IsNull() || address.Slot.IsUnknown() { + continue + } + if address.Slot.ValueInt64() == slot { + return true + } + } + return false +} + +func findAddressBySlot(addresses []PublisherAddressModel, slot int64) *PublisherAddressModel { + for idx := range addresses { + if addresses[idx].Slot.IsNull() || addresses[idx].Slot.IsUnknown() { + continue + } + if addresses[idx].Slot.ValueInt64() == slot { + return &addresses[idx] + } + } + return nil +} + +func isPlaceholderAddressModel(model PublisherAddressModel) bool { + if !isAddressContentEmpty(model) { + return false + } + + return isDefaultOrNullInt64(model.AddressTypeCode, 1) && + isDefaultOrNullInt64(model.ShippingMethodCode, 1) +} + +func isAddressContentEmpty(model PublisherAddressModel) bool { + return model.City.IsNull() && + model.Country.IsNull() && + model.County.IsNull() && + model.Fax.IsNull() && + model.Latitude.IsNull() && + model.Line1.IsNull() && + model.Line2.IsNull() && + model.Line3.IsNull() && + model.Longitude.IsNull() && + model.Name.IsNull() && + model.PostalCode.IsNull() && + model.PostOfficeBox.IsNull() && + model.StateOrProvince.IsNull() && + model.Telephone1.IsNull() && + model.Telephone2.IsNull() && + model.Telephone3.IsNull() && + model.UpsZone.IsNull() && + model.UtcOffset.IsNull() +} + +func isDefaultOrNullInt64(value types.Int64, defaultValue int64) bool { + return value.IsNull() || (!value.IsUnknown() && value.ValueInt64() == defaultValue) +} + +func buildPublisherImportId(environmentId, publisherId string) string { + return fmt.Sprintf("%s_%s", environmentId, publisherId) +} + +func getPublisherIdFromResponse(resp *api.Response) (string, error) { + var entityId string + for headerName, values := range resp.HttpResponse.Header { + if strings.EqualFold(headerName, "OData-EntityId") && len(values) > 0 { + entityId = values[0] + break + } + } + if entityId == "" { + return "", errors.New("no publisher id returned from the API") + } + + parsed, err := url.Parse(entityId) + if err != nil { + return "", err + } + + entitySegment := stdpath.Base(parsed.Path) + publisherId, found := strings.CutPrefix(entitySegment, "publishers(") + if !found { + return "", errors.New("no publisher id returned from the API") + } + + publisherId = strings.TrimSuffix(publisherId, ")") + if !canonicalGuidRegex.MatchString(publisherId) { + return "", errors.New("no publisher id returned from the API") + } + + return publisherId, nil +} + +func nullableStringValue(value string) types.String { + if value == "" { + return types.StringNull() + } + return types.StringValue(value) +} + +func normalizeNullableConfigString(value string, existing types.String) types.String { + if value != "" { + return types.StringValue(value) + } + + if !existing.IsNull() && !existing.IsUnknown() && existing.ValueString() == "" { + return types.StringValue("") + } + + return types.StringNull() +} + +func normalizeCaseInsensitiveConfigString(value string, existing types.String) types.String { + if !existing.IsNull() && !existing.IsUnknown() && strings.EqualFold(value, existing.ValueString()) { + return existing + } + + return types.StringValue(value) +} + +func normalizeNullableConfigStringValue(value types.String, existing types.String) types.String { + if !value.IsNull() { + return value + } + + if !existing.IsNull() && !existing.IsUnknown() && existing.ValueString() == "" { + return types.StringValue("") + } + + return types.StringNull() +} + +func effectiveCustomizationOptionValuePrefix(model *ResourceModel) int64 { + if !model.CustomizationOptionValuePrefix.IsNull() && !model.CustomizationOptionValuePrefix.IsUnknown() { + return model.CustomizationOptionValuePrefix.ValueInt64() + } + + return deriveCustomizationOptionValuePrefix(model.CustomizationPrefix.ValueString(), model.Id.ValueString()) +} + +func setDerivedCustomizationOptionValuePrefix(plan, config, state *ResourceModel, hasState bool) { + if !config.CustomizationOptionValuePrefix.IsNull() || config.CustomizationOptionValuePrefix.IsUnknown() { + return + } + + if hasState && !state.CustomizationOptionValuePrefix.IsNull() && !state.CustomizationOptionValuePrefix.IsUnknown() { + plan.CustomizationOptionValuePrefix = state.CustomizationOptionValuePrefix + return + } + + if plan.CustomizationPrefix.IsNull() || plan.CustomizationPrefix.IsUnknown() { + return + } + + publisherId := "" + if !plan.Id.IsNull() && !plan.Id.IsUnknown() { + publisherId = plan.Id.ValueString() + } else if hasState && !state.Id.IsNull() && !state.Id.IsUnknown() { + publisherId = state.Id.ValueString() + } + + plan.CustomizationOptionValuePrefix = types.Int64Value( + deriveCustomizationOptionValuePrefix(plan.CustomizationPrefix.ValueString(), publisherId), + ) +} + +func deriveCustomizationOptionValuePrefix(prefix, publisherId string) int64 { + if strings.EqualFold(publisherId, "d21aab71-79e7-11dd-8874-00188b01e34f") { + return 10000 + } + + normalizedPrefix := strings.ToUpper(prefix) + return customizationOptionValuePrefixFromHash(hashCustomizationPrefix(normalizedPrefix)) +} + +func hashCustomizationPrefix(prefix string) int32 { + var hash int32 + for _, ch := range prefix { + hash = (hash << 5) - hash + ch + } + return hash +} + +func customizationOptionValuePrefixFromHash(hash int32) int64 { + value := int64(hash) + if value < 0 { + value = -value + } + return (value % 90000) + 10000 +} + +func isPublisherAlreadyExistsError(err error) bool { + var unexpectedStatusErr customerrors.UnexpectedHttpStatusCodeError + if !errors.As(err, &unexpectedStatusErr) { + return false + } + + if unexpectedStatusErr.StatusCode != http.StatusPreconditionFailed { + return false + } + + body := string(unexpectedStatusErr.Body) + return strings.Contains(body, `"code":"0x80040237"`) || + strings.Contains(body, "matching key values already exists") +} + +func nullableInt64Value(value *int64) types.Int64 { + if value == nil { + return types.Int64Null() + } + return types.Int64Value(*value) +} + +func nullableFloat64Value(value *float64) types.Float64 { + if value == nil { + return types.Float64Null() + } + return types.Float64Value(*value) +} + +func nullableStringPointer(value types.String) *string { + if value.IsNull() || value.IsUnknown() { + return nil + } + return value.ValueStringPointer() +} + +func nullableInt64Pointer(value types.Int64) *int64 { + if value.IsNull() || value.IsUnknown() { + return nil + } + return value.ValueInt64Pointer() +} + +func nullableFloat64Pointer(value types.Float64) *float64 { + if value.IsNull() || value.IsUnknown() { + return nil + } + return value.ValueFloat64Pointer() +} + +func isGuid(value string) bool { + return canonicalGuidRegex.MatchString(value) +} diff --git a/internal/services/publisher/resource_publisher_mapper_test.go b/internal/services/publisher/resource_publisher_mapper_test.go new file mode 100644 index 000000000..1a002170d --- /dev/null +++ b/internal/services/publisher/resource_publisher_mapper_test.go @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher + +import ( + "math" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/customerrors" +) + +func TestUnitAddressModelFromDto_IgnoresEmptySlotWithOnlyAddressID(t *testing.T) { + dto := &publisherDto{ + Address2AddressId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + } + + model := addressModelFromDto(2, dto) + if model != nil { + t.Fatalf("expected address slot 2 to be ignored when only address id remains, got %#v", model) + } +} + +func TestUnitAddressModelsFromDto_IgnoresPlaceholderSlotWithoutExistingState(t *testing.T) { + dto := &publisherDto{ + Address1AddressId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Address1AddressTypeCode: int64Pointer(1), + Address1ShippingMethodCode: int64Pointer(1), + } + + models := addressModelsFromDto(dto, nil) + if models != nil { + t.Fatalf("expected placeholder address slot to be ignored, got %#v", models) + } +} + +func TestUnitAddressModelsFromDto_PreservesPlaceholderSlotWhenAlreadyTracked(t *testing.T) { + dto := &publisherDto{ + Address1AddressId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Address1AddressTypeCode: int64Pointer(1), + Address1ShippingMethodCode: int64Pointer(1), + } + + existing := []PublisherAddressModel{ + { + Slot: types.Int64Value(1), + AddressTypeCode: types.Int64Value(1), + ShippingMethodCode: types.Int64Value(1), + }, + } + + models := addressModelsFromDto(dto, existing) + if len(models) != 1 { + t.Fatalf("expected tracked placeholder address slot to be preserved, got %#v", models) + } + if models[0].Slot.ValueInt64() != 1 { + t.Fatalf("expected preserved address slot 1, got %#v", models[0]) + } +} + +func TestUnitSetResourceModelFromDto_PreservesExplicitEmptyTopLevelStrings(t *testing.T) { + model := ResourceModel{ + Description: types.StringValue(""), + EmailAddress: types.StringValue(""), + SupportingWebsiteURL: types.StringValue(""), + } + + setResourceModelFromDto(&model, "00000000-0000-0000-0000-000000000001", &publisherDto{ + Id: "11111111-1111-1111-1111-111111111111", + UniqueName: "testpublisher", + FriendlyName: "Test Publisher", + CustomizationPrefix: "tp", + CustomizationOptionValuePrefix: 12345, + }) + + if model.Description.IsNull() || model.Description.ValueString() != "" { + t.Fatalf("expected empty description to be preserved, got %#v", model.Description) + } + if model.EmailAddress.IsNull() || model.EmailAddress.ValueString() != "" { + t.Fatalf("expected empty email_address to be preserved, got %#v", model.EmailAddress) + } + if model.SupportingWebsiteURL.IsNull() || model.SupportingWebsiteURL.ValueString() != "" { + t.Fatalf("expected empty supporting_website_url to be preserved, got %#v", model.SupportingWebsiteURL) + } +} + +func TestUnitSetResourceModelFromDto_PreservesCaseOnlyFriendlyNameDifferences(t *testing.T) { + model := ResourceModel{ + FriendlyName: types.StringValue("MetaForm"), + } + + setResourceModelFromDto(&model, "00000000-0000-0000-0000-000000000001", &publisherDto{ + Id: "11111111-1111-1111-1111-111111111111", + UniqueName: "metaform", + FriendlyName: "Metaform", + CustomizationPrefix: "mf", + CustomizationOptionValuePrefix: 12457, + }) + + if model.FriendlyName.ValueString() != "MetaForm" { + t.Fatalf("expected configured friendly_name casing to be preserved, got %q", model.FriendlyName.ValueString()) + } +} + +func TestUnitAddressModelsFromDto_PreservesExplicitEmptyAddressList(t *testing.T) { + models := addressModelsFromDto(&publisherDto{}, []PublisherAddressModel{}) + if models == nil { + t.Fatal("expected explicit empty address list to be preserved") + } + if len(models) != 0 { + t.Fatalf("expected no address entries, got %#v", models) + } +} + +func TestUnitAddressModelsFromDto_PreservesTrackedEmptyStringAddressFields(t *testing.T) { + dto := &publisherDto{ + Address1AddressId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Address1AddressTypeCode: int64Pointer(1), + Address1ShippingMethodCode: int64Pointer(1), + } + + existing := []PublisherAddressModel{ + { + Slot: types.Int64Value(1), + AddressTypeCode: types.Int64Value(1), + ShippingMethodCode: types.Int64Value(1), + Line1: types.StringValue(""), + }, + } + + models := addressModelsFromDto(dto, existing) + if len(models) != 1 { + t.Fatalf("expected tracked address slot to be preserved, got %#v", models) + } + if models[0].Line1.IsNull() || models[0].Line1.ValueString() != "" { + t.Fatalf("expected empty line1 to be preserved, got %#v", models[0].Line1) + } +} + +func TestUnitDeriveCustomizationOptionValuePrefix_MatchesClientAlgorithm(t *testing.T) { + got := deriveCustomizationOptionValuePrefix("mf", "") + if got != 12457 { + t.Fatalf("expected derived prefix 12457 for 'mf', got %d", got) + } +} + +func TestUnitDeriveCustomizationOptionValuePrefix_UsesPublisherSpecialCase(t *testing.T) { + got := deriveCustomizationOptionValuePrefix("anything", "d21aab71-79e7-11dd-8874-00188b01e34f") + if got != 10000 { + t.Fatalf("expected special-case derived prefix 10000, got %d", got) + } +} + +func TestUnitDeriveCustomizationOptionValuePrefix_UsesPublisherSpecialCaseCaseInsensitive(t *testing.T) { + got := deriveCustomizationOptionValuePrefix("anything", "D21AAB71-79E7-11DD-8874-00188B01E34F") + if got != 10000 { + t.Fatalf("expected case-insensitive special-case derived prefix 10000, got %d", got) + } +} + +func TestUnitCustomizationOptionValuePrefixFromHash_HandlesMinInt32(t *testing.T) { + got := customizationOptionValuePrefixFromHash(math.MinInt32) + if got != 93648 { + t.Fatalf("expected min-int32 hash to produce 93648, got %d", got) + } +} + +func TestUnitSetDerivedCustomizationOptionValuePrefix_DerivesWhenConfigOmitted(t *testing.T) { + plan := ResourceModel{ + CustomizationPrefix: types.StringValue("mf"), + CustomizationOptionValuePrefix: types.Int64Unknown(), + } + config := ResourceModel{ + CustomizationOptionValuePrefix: types.Int64Null(), + } + + setDerivedCustomizationOptionValuePrefix(&plan, &config, &ResourceModel{}, false) + + if plan.CustomizationOptionValuePrefix.IsUnknown() || plan.CustomizationOptionValuePrefix.IsNull() { + t.Fatal("expected derived customization option value prefix to be planned") + } + if plan.CustomizationOptionValuePrefix.ValueInt64() != 12457 { + t.Fatalf("expected derived customization option value prefix 12457, got %d", plan.CustomizationOptionValuePrefix.ValueInt64()) + } +} + +func TestUnitSetDerivedCustomizationOptionValuePrefix_PreservesExplicitConfigValue(t *testing.T) { + plan := ResourceModel{ + CustomizationPrefix: types.StringValue("mf"), + CustomizationOptionValuePrefix: types.Int64Value(77777), + } + config := ResourceModel{ + CustomizationOptionValuePrefix: types.Int64Value(77777), + } + + setDerivedCustomizationOptionValuePrefix(&plan, &config, &ResourceModel{}, false) + + if plan.CustomizationOptionValuePrefix.ValueInt64() != 77777 { + t.Fatalf("expected explicit customization option value prefix to be preserved, got %d", plan.CustomizationOptionValuePrefix.ValueInt64()) + } +} + +func TestUnitSetDerivedCustomizationOptionValuePrefix_PreservesStateValueAfterCreate(t *testing.T) { + plan := ResourceModel{ + Id: types.StringValue("11111111-1111-1111-1111-111111111111"), + CustomizationPrefix: types.StringValue("ab"), + CustomizationOptionValuePrefix: types.Int64Unknown(), + } + config := ResourceModel{ + CustomizationOptionValuePrefix: types.Int64Null(), + } + state := ResourceModel{ + Id: types.StringValue("11111111-1111-1111-1111-111111111111"), + CustomizationPrefix: types.StringValue("old"), + CustomizationOptionValuePrefix: types.Int64Value(77074), + } + + setDerivedCustomizationOptionValuePrefix(&plan, &config, &state, true) + + if plan.CustomizationOptionValuePrefix.ValueInt64() != state.CustomizationOptionValuePrefix.ValueInt64() { + t.Fatalf("expected existing customization option value prefix to be preserved from state, got %d", plan.CustomizationOptionValuePrefix.ValueInt64()) + } +} + +func TestUnitGetPublisherIdFromResponse_ParsesCanonicalGuid(t *testing.T) { + resp := &api.Response{ + HttpResponse: &http.Response{ + Header: http.Header{ + "OData-EntityId": []string{"https://example.crm.dynamics.com/api/data/v9.2/publishers(11111111-1111-1111-1111-111111111111)"}, + }, + }, + } + + got, err := getPublisherIdFromResponse(resp) + if err != nil { + t.Fatalf("expected publisher id to be parsed, got error: %v", err) + } + if got != "11111111-1111-1111-1111-111111111111" { + t.Fatalf("expected canonical publisher id, got %q", got) + } +} + +func TestUnitGetPublisherIdFromResponse_RejectsNonCanonicalGuid(t *testing.T) { + resp := &api.Response{ + HttpResponse: &http.Response{ + Header: http.Header{ + "OData-EntityId": []string{"https://example.crm.dynamics.com/api/data/v9.2/publishers(11111111-1111-1111-1111-11111111111)"}, + }, + }, + } + + _, err := getPublisherIdFromResponse(resp) + if err == nil { + t.Fatal("expected malformed publisher id to be rejected") + } +} + +func TestUnitIsGuid_RequiresCanonicalGuidFormat(t *testing.T) { + if !isGuid("11111111-1111-1111-1111-111111111111") { + t.Fatal("expected canonical guid to be recognized") + } + if isGuid("11111111-1111-1111-1111-11111111111") { + t.Fatal("expected malformed guid to be rejected") + } +} + +func int64Pointer(value int64) *int64 { + return &value +} + +func TestUnitIsPublisherAlreadyExistsError_MatchesDataverseDuplicateKeyResponse(t *testing.T) { + err := customerrors.NewUnexpectedHttpStatusCodeError( + []int{201, 204}, + 412, + "412 Precondition Failed", + []byte(`{"error":{"code":"0x80040237","message":"A record with matching key values already exists."}}`), + ) + + if !isPublisherAlreadyExistsError(err) { + t.Fatal("expected Dataverse duplicate-key response to be recognized as already exists") + } +} + +func TestUnitIsPublisherAlreadyExistsError_IgnoresOtherUnexpectedStatusErrors(t *testing.T) { + err := customerrors.NewUnexpectedHttpStatusCodeError( + []int{201, 204}, + 409, + "409 Conflict", + []byte(`{"error":{"code":"other","message":"conflict"}}`), + ) + + if isPublisherAlreadyExistsError(err) { + t.Fatal("expected non-412 error to be ignored") + } +} diff --git a/internal/services/publisher/resource_publisher_test.go b/internal/services/publisher/resource_publisher_test.go new file mode 100644 index 000000000..890a8b5de --- /dev/null +++ b/internal/services/publisher/resource_publisher_test.go @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package publisher_test + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/jarcoal/httpmock" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" + "github.com/microsoft/terraform-provider-power-platform/internal/mocks" +) + +const ( + testEnvironmentID = "00000000-0000-0000-0000-000000000001" + testPublisherID = "11111111-1111-1111-1111-111111111111" + testPublisherHost = "00000000-0000-0000-0000-000000000001.crm4.dynamics.com" +) + +func TestUnitPublisherResource_Validate_CRUD(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + registerPublisherEnvironmentMock() + + currentResponse := publisherCreateResponse() + + httpmock.RegisterResponder("POST", fmt.Sprintf("https://%s/api/data/v9.2/publishers", testPublisherHost), + func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + if !strings.Contains(string(body), `"friendlyname":"Contoso Publisher"`) { + return httpmock.NewStringResponse(http.StatusBadRequest, `{"error":"missing friendly name"}`), nil + } + if !strings.Contains(string(body), `"customizationoptionvalueprefix":77074`) { + return httpmock.NewStringResponse(http.StatusBadRequest, `{"error":"missing derived customization option value prefix"}`), nil + } + + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + resp.Header.Set("OData-EntityId", fmt.Sprintf("https://%s/api/data/v9.2/publishers(%s)", testPublisherHost, testPublisherID)) + return resp, nil + }) + + httpmock.RegisterResponder("GET", encodedPublisherURL(), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, currentResponse), nil + }) + + httpmock.RegisterResponder("PATCH", encodedPublisherURL(), + func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + if !strings.Contains(string(body), `"friendlyname":"Updated Contoso Publisher"`) { + return httpmock.NewStringResponse(http.StatusBadRequest, `{"error":"missing updated friendly name"}`), nil + } + if !strings.Contains(string(body), `"customizationoptionvalueprefix":72710`) { + return httpmock.NewStringResponse(http.StatusBadRequest, `{"error":"missing explicit customization option value prefix override"}`), nil + } + if !strings.Contains(string(body), `"address2_city":null`) { + return httpmock.NewStringResponse(http.StatusBadRequest, `{"error":"expected address slot 2 to be cleared"}`), nil + } + + currentResponse = publisherUpdateResponse() + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterResponder("DELETE", encodedPublisherURL(), + httpmock.NewStringResponder(http.StatusNoContent, "")) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + ResourceName: "powerplatform_publisher.example", + Config: ` +resource "powerplatform_publisher" "example" { + environment_id = "` + testEnvironmentID + `" + uniquename = "contoso" + friendly_name = "Contoso Publisher" + customization_prefix = "cts" + description = "Initial publisher" + email_address = "publisher@contoso.example" + supporting_website_url = "https://contoso.example" + + address = [ + { + slot = 1 + line1 = "1 Collins Street" + city = "Melbourne" + country = "Australia" + postal_code = "3000" + telephone1 = "+61-3-5555-0101" + }, + { + slot = 2 + line1 = "100 Queen Street" + city = "Auckland" + country = "New Zealand" + postal_code = "1010" + telephone1 = "+64-9-555-0102" + } + ] +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_publisher.example", "id", testPublisherID), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "friendly_name", "Contoso Publisher"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "customization_option_value_prefix", "77074"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "address.#", "2"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "address.0.slot", "1"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "address.1.slot", "2"), + ), + }, + { + ResourceName: "powerplatform_publisher.example", + Config: ` +resource "powerplatform_publisher" "example" { + environment_id = "` + testEnvironmentID + `" + uniquename = "contoso" + friendly_name = "Updated Contoso Publisher" + customization_prefix = "cts" + customization_option_value_prefix = 72710 + description = "Updated publisher" + email_address = "updated@contoso.example" + supporting_website_url = "https://support.contoso.example" + + address = [ + { + slot = 1 + line1 = "200 Collins Street" + city = "Sydney" + country = "Australia" + postal_code = "2000" + telephone1 = "+61-2-5555-0103" + } + ] +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_publisher.example", "friendly_name", "Updated Contoso Publisher"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "customization_option_value_prefix", "72710"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "address.#", "1"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "address.0.city", "Sydney"), + ), + }, + { + ResourceName: "powerplatform_publisher.example", + ImportState: true, + ImportStateVerify: true, + ImportStateId: testEnvironmentID + "_" + testPublisherID, + }, + }, + }) +} + +func TestAccPublisherResource_Validate_Create(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: mocks.TestAccProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "time": { + Source: "hashicorp/time", + }, + }, + Steps: []resource.TestStep{ + { + Config: publisherAcceptanceResourceConfig(mocks.TestName(), "terraformpublisher", "Terraform Publisher", "tfp"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr("powerplatform_publisher.example", "id", regexp.MustCompile(helpers.GuidRegex)), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "uniquename", "terraformpublisher"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "friendly_name", "Terraform Publisher"), + resource.TestCheckResourceAttr("powerplatform_publisher.example", "customization_prefix", "tfp"), + resource.TestMatchResourceAttr("powerplatform_publisher.example", "customization_option_value_prefix", regexp.MustCompile(`^[1-9]\d{4}$`)), + ), + }, + { + Config: publisherAcceptanceResourceConfig(mocks.TestName(), "terraformpublisher", "Terraform Publisher Updated", "tfp"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_publisher.example", "friendly_name", "Terraform Publisher Updated"), + resource.TestMatchResourceAttr("powerplatform_publisher.example", "id", regexp.MustCompile(helpers.GuidRegex)), + ), + }, + { + ResourceName: "powerplatform_publisher.example", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + environmentState := state.RootModule().Resources["powerplatform_environment.environment"] + publisherState := state.RootModule().Resources["powerplatform_publisher.example"] + return environmentState.Primary.ID + "_" + publisherState.Primary.ID, nil + }, + }, + }, + }) +} + +func registerPublisherEnvironmentMock() { + httpmock.RegisterResponder("GET", "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/"+testEnvironmentID+"?%24expand=permissions%2Cproperties.capacity%2Cproperties%2FbillingPolicy%2Cproperties%2FcopilotPolicies&api-version=2023-06-01", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{ + "name": "`+testEnvironmentID+`", + "properties": { + "linkedEnvironmentMetadata": { + "instanceUrl": "https://`+testPublisherHost+`/" + } + } +}`), nil + }) +} + +func publisherCreateResponse() string { + return `{ + "publisherid": "` + testPublisherID + `", + "friendlyname": "Contoso Publisher", + "uniquename": "contoso", + "customizationprefix": "cts", + "customizationoptionvalueprefix": 77074, + "description": "Initial publisher", + "emailaddress": "publisher@contoso.example", + "supportingwebsiteurl": "https://contoso.example", + "isreadonly": false, + "address1_addressid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "address1_city": "Melbourne", + "address1_country": "Australia", + "address1_line1": "1 Collins Street", + "address1_postalcode": "3000", + "address1_telephone1": "+61-3-5555-0101", + "address2_addressid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "address2_city": "Auckland", + "address2_country": "New Zealand", + "address2_line1": "100 Queen Street", + "address2_postalcode": "1010", + "address2_telephone1": "+64-9-555-0102" +}` +} + +func publisherUpdateResponse() string { + return `{ + "publisherid": "` + testPublisherID + `", + "friendlyname": "Updated Contoso Publisher", + "uniquename": "contoso", + "customizationprefix": "cts", + "customizationoptionvalueprefix": 72710, + "description": "Updated publisher", + "emailaddress": "updated@contoso.example", + "supportingwebsiteurl": "https://support.contoso.example", + "isreadonly": false, + "address1_addressid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "address1_city": "Sydney", + "address1_country": "Australia", + "address1_line1": "200 Collins Street", + "address1_postalcode": "2000", + "address1_telephone1": "+61-2-5555-0103" +}` +} + +func encodedPublisherURL() string { + return fmt.Sprintf("https://%s/api/data/v9.2/publishers%%28%s%%29", testPublisherHost, testPublisherID) +} + +func publisherAcceptanceResourceConfig(environmentDisplayName, uniqueName, friendlyName, customizationPrefix string) string { + return fmt.Sprintf(` +resource "powerplatform_environment" "environment" { + display_name = "%s" + location = "unitedstates" + environment_type = "Sandbox" + dataverse = { + language_code = "1033" + currency_code = "USD" + security_group_id = "00000000-0000-0000-0000-000000000000" + } +} + +resource "time_sleep" "wait_120_seconds" { + depends_on = [powerplatform_environment.environment] + create_duration = "120s" +} + +resource "powerplatform_publisher" "example" { + depends_on = [time_sleep.wait_120_seconds] + environment_id = powerplatform_environment.environment.id + uniquename = "%s" + friendly_name = "%s" + customization_prefix = "%s" +} +`, environmentDisplayName, uniqueName, friendlyName, customizationPrefix) +}