diff --git a/go.mod b/go.mod index 55ea72a001..c41ef69218 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( require ( github.com/hashicorp/terraform-json v0.25.0 + github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 go.mongodb.org/atlas-sdk/v20250312004 v20250312004.0.0 ) diff --git a/go.sum b/go.sum index 86bc142877..0c2a8bf334 100644 --- a/go.sum +++ b/go.sum @@ -532,6 +532,8 @@ github.com/hashicorp/terraform-json v0.25.0 h1:rmNqc/CIfcWawGiwXmRuiXJKEiJu1ntGo github.com/hashicorp/terraform-json v0.25.0/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= github.com/hashicorp/terraform-plugin-framework v1.15.0 h1:LQ2rsOfmDLxcn5EeIwdXFtr03FVsNktbbBci8cOKdb4= github.com/hashicorp/terraform-plugin-framework v1.15.0/go.mod h1:hxrNI/GY32KPISpWqlCoTLM9JZsGH3CyYlir09bD/fI= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E= github.com/hashicorp/terraform-plugin-framework-timeouts v0.5.0 h1:I/N0g/eLZ1ZkLZXUQ0oRSXa8YG/EF0CEuQP1wXdrzKw= github.com/hashicorp/terraform-plugin-framework-timeouts v0.5.0/go.mod h1:t339KhmxnaF4SzdpxmqW8HnQBHVGYazwtfxU0qCs4eE= github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 h1:OQnlOt98ua//rCw+QhBbSqfW3QbwtVrcdWeQN5gI3Hw= diff --git a/internal/common/autogen/marshal.go b/internal/common/autogen/marshal.go index fcfe52aeb9..8f9153992d 100644 --- a/internal/common/autogen/marshal.go +++ b/internal/common/autogen/marshal.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -105,8 +106,14 @@ func getModelAttr(val attr.Value) (any, error) { return getListAttr(v.Elements()) case types.Set: return getListAttr(v.Elements()) + case jsontypes.Normalized: + var valueJSON any + if err := json.Unmarshal([]byte(v.ValueString()), &valueJSON); err != nil { + return nil, fmt.Errorf("marshal failed for JSON custom type: %v", err) + } + return valueJSON, nil default: - return nil, fmt.Errorf("unmarshal not supported yet for type %T", v) + return nil, fmt.Errorf("marshal not supported yet for type %T", v) } } diff --git a/internal/common/autogen/marshal_test.go b/internal/common/autogen/marshal_test.go index 4a92d0ed1d..8c53c75089 100644 --- a/internal/common/autogen/marshal_test.go +++ b/internal/common/autogen/marshal_test.go @@ -3,6 +3,7 @@ package autogen_test import ( "testing" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/autogen" @@ -42,17 +43,17 @@ const epsilon = 10e-15 // float tolerance in test equality func TestMarshalBasic(t *testing.T) { model := struct { - AttrFloat types.Float64 `tfsdk:"attr_float"` - AttrString types.String `tfsdk:"attr_string"` - // values with tag `omitjson` are not marshaled, and they don't need to be Terraform types - AttrOmit types.String `tfsdk:"attr_omit" autogen:"omitjson"` - AttrOmitNoTerraform string `autogen:"omitjson"` - AttrUnkown types.String `tfsdk:"attr_unknown"` - AttrNull types.String `tfsdk:"attr_null"` - AttrInt types.Int64 `tfsdk:"attr_int"` - AttrBoolTrue types.Bool `tfsdk:"attr_bool_true"` - AttrBoolFalse types.Bool `tfsdk:"attr_bool_false"` - AttrBoolNull types.Bool `tfsdk:"attr_bool_null"` + AttrFloat types.Float64 `tfsdk:"attr_float"` + AttrString types.String `tfsdk:"attr_string"` + AttrOmit types.String `tfsdk:"attr_omit" autogen:"omitjson"` + AttrUnkown types.String `tfsdk:"attr_unknown"` + AttrNull types.String `tfsdk:"attr_null"` + AttrJSON jsontypes.Normalized `tfsdk:"attr_json"` + AttrOmitNoTerraform string `autogen:"omitjson"` + AttrInt types.Int64 `tfsdk:"attr_int"` + AttrBoolTrue types.Bool `tfsdk:"attr_bool_true"` + AttrBoolFalse types.Bool `tfsdk:"attr_bool_false"` + AttrBoolNull types.Bool `tfsdk:"attr_bool_null"` }{ AttrFloat: types.Float64Value(1.234), AttrString: types.StringValue("hello"), @@ -64,8 +65,9 @@ func TestMarshalBasic(t *testing.T) { AttrBoolTrue: types.BoolValue(true), AttrBoolFalse: types.BoolValue(false), AttrBoolNull: types.BoolNull(), // null values are not marshaled + AttrJSON: jsontypes.NewNormalizedValue("{\"hello\": \"there\"}"), } - const expectedJSON = `{ "attrString": "hello", "attrInt": 1, "attrFloat": 1.234, "attrBoolTrue": true, "attrBoolFalse": false }` + const expectedJSON = `{ "attrString": "hello", "attrInt": 1, "attrFloat": 1.234, "attrBoolTrue": true, "attrBoolFalse": false, "attrJSON": {"hello": "there"} }` raw, err := autogen.Marshal(&model, false) require.NoError(t, err) assert.JSONEq(t, expectedJSON, string(raw)) diff --git a/internal/common/autogen/unknown.go b/internal/common/autogen/unknown.go index e277cb7023..bead1860cb 100644 --- a/internal/common/autogen/unknown.go +++ b/internal/common/autogen/unknown.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -116,6 +117,9 @@ func getNullAttr(attrType attr.Type) (attr.Value, error) { if mapType, ok := attrType.(types.MapType); ok { return types.MapNull(mapType.ElemType), nil } + if _, ok := attrType.(jsontypes.NormalizedType); ok { + return jsontypes.NewNormalizedNull(), nil + } return nil, fmt.Errorf("unmarshal to get null value not supported yet for type %T", attrType) } } diff --git a/internal/common/autogen/unmarshal.go b/internal/common/autogen/unmarshal.go index 911af4dc21..37511474f0 100644 --- a/internal/common/autogen/unmarshal.go +++ b/internal/common/autogen/unmarshal.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -108,6 +109,13 @@ func getTfAttr(value any, valueType attr.Type, oldVal attr.Value, name string) ( } return nil, errUnmarshal(value, valueType, "Number", nameErr) case map[string]any: + if _, ok := valueType.(jsontypes.NormalizedType); ok { + jsonBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal object to JSON for attribute %s: %v", nameErr, err) + } + return jsontypes.NewNormalizedValue(string(jsonBytes)), nil + } if obj, ok := oldVal.(types.Object); ok { objNew, err := setObjAttrModel(obj, v) if err != nil { @@ -124,6 +132,13 @@ func getTfAttr(value any, valueType attr.Type, oldVal attr.Value, name string) ( } return nil, errUnmarshal(value, valueType, "Object", nameErr) case []any: + if _, ok := valueType.(jsontypes.NormalizedType); ok { + jsonBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal array to JSON for attribute %s: %v", nameErr, err) + } + return jsontypes.NewNormalizedValue(string(jsonBytes)), nil + } if list, ok := oldVal.(types.List); ok { listNew, err := setListAttrModel(list, v, nameErr) if err != nil { diff --git a/internal/common/autogen/unmarshal_test.go b/internal/common/autogen/unmarshal_test.go index 361d893628..9b855b55c9 100644 --- a/internal/common/autogen/unmarshal_test.go +++ b/internal/common/autogen/unmarshal_test.go @@ -3,6 +3,7 @@ package autogen_test import ( "testing" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/autogen" @@ -12,14 +13,15 @@ import ( func TestUnmarshalBasic(t *testing.T) { var model struct { - AttrFloat types.Float64 `tfsdk:"attr_float"` - AttrFloatWithInt types.Float64 `tfsdk:"attr_float_with_int"` - AttrString types.String `tfsdk:"attr_string"` - AttrNotInJSON types.String `tfsdk:"attr_not_in_json"` - AttrInt types.Int64 `tfsdk:"attr_int"` - AttrIntWithFloat types.Int64 `tfsdk:"attr_int_with_float"` - AttrTrue types.Bool `tfsdk:"attr_true"` - AttrFalse types.Bool `tfsdk:"attr_false"` + AttrFloat types.Float64 `tfsdk:"attr_float"` + AttrFloatWithInt types.Float64 `tfsdk:"attr_float_with_int"` + AttrString types.String `tfsdk:"attr_string"` + AttrNotInJSON types.String `tfsdk:"attr_not_in_json"` + AttrJSON jsontypes.Normalized `tfsdk:"attr_json"` + AttrInt types.Int64 `tfsdk:"attr_int"` + AttrIntWithFloat types.Int64 `tfsdk:"attr_int_with_float"` + AttrTrue types.Bool `tfsdk:"attr_true"` + AttrFalse types.Bool `tfsdk:"attr_false"` } const ( // attribute_not_in_model is ignored because it is not in the model, no error is thrown. @@ -34,7 +36,8 @@ func TestUnmarshalBasic(t *testing.T) { "attrFloat": 456.1, "attrFloatWithInt": 13, "attrNotInModel": "val", - "attrNull": null + "attrNull": null, + "attrJSON": {"hello": "there"} } ` ) @@ -47,6 +50,7 @@ func TestUnmarshalBasic(t *testing.T) { assert.InEpsilon(t, float64(456.1), model.AttrFloat.ValueFloat64(), epsilon) assert.InEpsilon(t, float64(13), model.AttrFloatWithInt.ValueFloat64(), epsilon) assert.True(t, model.AttrNotInJSON.IsNull()) // attributes not in JSON response are not changed, so null is kept. + assert.JSONEq(t, "{\"hello\":\"there\"}", model.AttrJSON.ValueString()) } func TestUnmarshalNestedAllTypes(t *testing.T) { @@ -67,6 +71,7 @@ func TestUnmarshalNestedAllTypes(t *testing.T) { AttrMapSimple types.Map `tfsdk:"attr_map_simple"` AttrMapSimpleExisting types.Map `tfsdk:"attr_map_simple_existing"` AttrMapObj types.Map `tfsdk:"attr_map_obj"` + AttrJSONList types.List `tfsdk:"attr_json_list"` } model := modelst{ AttrObj: types.ObjectValueMust(objTypeTest.AttrTypes, map[string]attr.Value{ @@ -100,7 +105,8 @@ func TestUnmarshalNestedAllTypes(t *testing.T) { "existing": types.StringValue("valexisting"), "existingCHANGE": types.StringValue("before"), }), - AttrMapObj: types.MapUnknown(objTypeTest), + AttrMapObj: types.MapUnknown(objTypeTest), + AttrJSONList: types.ListUnknown(jsontypes.NormalizedType{}), } // attrUnexisting is ignored because it is in JSON but not in the model, no error is returned const ( @@ -226,7 +232,11 @@ func TestUnmarshalNestedAllTypes(t *testing.T) { "attrFloat": 22.2, "attrBool": true } - } + }, + "attrJSONList": [ + {"hello1": "there1"}, + {"hello2": "there2"} + ] } ` ) @@ -375,6 +385,10 @@ func TestUnmarshalNestedAllTypes(t *testing.T) { "attr_bool": types.BoolValue(true), }), }), + AttrJSONList: types.ListValueMust(jsontypes.NormalizedType{}, []attr.Value{ + jsontypes.NewNormalizedValue(`{"hello1":"there1"}`), + jsontypes.NewNormalizedValue(`{"hello2":"there2"}`), + }), } require.NoError(t, autogen.Unmarshal([]byte(jsonResp), &model)) assert.Equal(t, modelExpected, model) diff --git a/internal/serviceapi/streaminstanceapi/resource_schema.go b/internal/serviceapi/streaminstanceapi/resource_schema.go index 77ae9e49dd..0f8b2e6239 100755 --- a/internal/serviceapi/streaminstanceapi/resource_schema.go +++ b/internal/serviceapi/streaminstanceapi/resource_schema.go @@ -168,11 +168,11 @@ func ResourceSchema(ctx context.Context) schema.Schema { }, "networking": schema.SingleNestedAttribute{ Computed: true, - MarkdownDescription: "Networking Access Type can either be 'PUBLIC' (default) or VPC. VPC type is in public preview, please file a support ticket to enable VPC Network Access.", + MarkdownDescription: "Networking configuration for Streams connections.", Attributes: map[string]schema.Attribute{ "access": schema.SingleNestedAttribute{ Computed: true, - MarkdownDescription: "Information about the networking access.", + MarkdownDescription: "Information about networking access.", Attributes: map[string]schema.Attribute{ "connection_id": schema.StringAttribute{ Computed: true, diff --git a/internal/serviceapi/streamprocessorapi/main_test.go b/internal/serviceapi/streamprocessorapi/main_test.go new file mode 100644 index 0000000000..5e243c3a48 --- /dev/null +++ b/internal/serviceapi/streamprocessorapi/main_test.go @@ -0,0 +1,14 @@ +package streamprocessorapi_test + +import ( + "os" + "testing" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/acc" +) + +func TestMain(m *testing.M) { + acc.SetupSharedResources() + exitCode := m.Run() + os.Exit(exitCode) +} diff --git a/internal/serviceapi/streamprocessorapi/resource.go b/internal/serviceapi/streamprocessorapi/resource.go new file mode 100755 index 0000000000..cd06bcd8d7 --- /dev/null +++ b/internal/serviceapi/streamprocessorapi/resource.go @@ -0,0 +1,175 @@ +// Code generated by terraform-provider-mongodbatlas using `make generate-resource`. DO NOT EDIT. + +package streamprocessorapi + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/autogen" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" +) + +var _ resource.ResourceWithConfigure = &rs{} +var _ resource.ResourceWithImportState = &rs{} + +const apiVersionHeader = "application/vnd.atlas.2024-05-30+json" + +func Resource() resource.Resource { + return &rs{ + RSCommon: config.RSCommon{ + ResourceName: "stream_processor_api", + }, + } +} + +type rs struct { + config.RSCommon +} + +func (r *rs) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = ResourceSchema(ctx) + conversion.UpdateSchemaDescription(&resp.Schema) +} + +func (r *rs) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan TFModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + pathParams := map[string]string{ + "groupId": plan.GroupId.ValueString(), + "tenantName": plan.TenantName.ValueString(), + } + callParams := config.APICallParams{ + VersionHeader: apiVersionHeader, + RelativePath: "/api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor", + PathParams: pathParams, + Method: "POST", + } + reqHandle := autogen.HandleCreateReq{ + Resp: resp, + Client: r.Client, + Plan: &plan, + CallParams: &callParams, + Wait: &autogen.WaitReq{ + StateProperty: "state", + PendingStates: []string{"INIT", "CREATING"}, + TargetStates: []string{"CREATED"}, + TimeoutSeconds: 300, + MinTimeoutSeconds: 10, + DelaySeconds: 10, + CallParams: readAPICallParams(&plan), + }, + } + autogen.HandleCreate(ctx, reqHandle) +} + +func (r *rs) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state TFModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + reqHandle := autogen.HandleReadReq{ + Resp: resp, + Client: r.Client, + State: &state, + CallParams: readAPICallParams(&state), + } + autogen.HandleRead(ctx, reqHandle) +} + +func (r *rs) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan TFModel + var state TFModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + // Path params are grabbed from state as they may be computed-only and not present in the plan + pathParams := map[string]string{ + "groupId": state.GroupId.ValueString(), + "tenantName": state.TenantName.ValueString(), + "name": state.Name.ValueString(), + } + callParams := config.APICallParams{ + VersionHeader: apiVersionHeader, + RelativePath: "/api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{name}", + PathParams: pathParams, + Method: "PATCH", + } + reqHandle := autogen.HandleUpdateReq{ + Resp: resp, + Client: r.Client, + Plan: &plan, + CallParams: &callParams, + Wait: &autogen.WaitReq{ + StateProperty: "state", + PendingStates: []string{"INIT", "CREATING"}, + TargetStates: []string{"CREATED"}, + TimeoutSeconds: 300, + MinTimeoutSeconds: 10, + DelaySeconds: 10, + CallParams: readAPICallParams(&state), + }, + } + autogen.HandleUpdate(ctx, reqHandle) +} + +func (r *rs) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state TFModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + pathParams := map[string]string{ + "groupId": state.GroupId.ValueString(), + "tenantName": state.TenantName.ValueString(), + "name": state.Name.ValueString(), + } + callParams := config.APICallParams{ + VersionHeader: apiVersionHeader, + RelativePath: "/api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{name}", + PathParams: pathParams, + Method: "DELETE", + } + reqHandle := autogen.HandleDeleteReq{ + Resp: resp, + Client: r.Client, + State: &state, + CallParams: &callParams, + Wait: &autogen.WaitReq{ + StateProperty: "state", + PendingStates: []string{"INIT", "CREATING", "CREATED", "STARTED", "STOPPED"}, + TargetStates: []string{"DELETED"}, + TimeoutSeconds: 300, + MinTimeoutSeconds: 10, + DelaySeconds: 10, + CallParams: readAPICallParams(&state), + }, + } + autogen.HandleDelete(ctx, reqHandle) +} + +func (r *rs) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idAttributes := []string{"group_id", "tenant_name", "name"} + autogen.HandleImport(ctx, idAttributes, req, resp) +} + +func readAPICallParams(state *TFModel) *config.APICallParams { + pathParams := map[string]string{ + "groupId": state.GroupId.ValueString(), + "tenantName": state.TenantName.ValueString(), + "name": state.Name.ValueString(), + } + return &config.APICallParams{ + VersionHeader: apiVersionHeader, + RelativePath: "/api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{name}", + PathParams: pathParams, + Method: "GET", + } +} diff --git a/internal/serviceapi/streamprocessorapi/resource_schema.go b/internal/serviceapi/streamprocessorapi/resource_schema.go new file mode 100755 index 0000000000..f4ddafcda5 --- /dev/null +++ b/internal/serviceapi/streamprocessorapi/resource_schema.go @@ -0,0 +1,92 @@ +// Code generated by terraform-provider-mongodbatlas using `make generate-resource`. DO NOT EDIT. + +package streamprocessorapi + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "group_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access.\n\n**NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups.", + }, + "name": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Human-readable name of the stream processor.", + }, + "options": schema.SingleNestedAttribute{ + Computed: true, + Optional: true, + MarkdownDescription: "Optional configuration for the stream processor.", + Attributes: map[string]schema.Attribute{ + "dlq": schema.SingleNestedAttribute{ + Optional: true, + MarkdownDescription: "Dead letter queue for the stream processor.", + Attributes: map[string]schema.Attribute{ + "coll": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Name of the collection to use for the DLQ.", + }, + "connection_name": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Name of the connection to write DLQ messages to. Must be an Atlas connection.", + }, + "db": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Name of the database to use for the DLQ.", + }, + }, + }, + "resume_from_checkpoint": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "When true, the modified stream processor resumes from its last checkpoint.", + }, + }, + }, + "pipeline": schema.ListAttribute{ + Optional: true, + MarkdownDescription: "Stream aggregation pipeline you want to apply to your streaming data.", + ElementType: jsontypes.NormalizedType{}, + }, + "state": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The state of the stream processor. Commonly occurring states are 'CREATED', 'STARTED', 'STOPPED' and 'FAILED'.", + }, + "stats": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The stats associated with the stream processor.", + CustomType: jsontypes.NormalizedType{}, + }, + "tenant_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Human-readable label that identifies the stream instance.", + }, + }, + } +} + +type TFModel struct { + GroupId types.String `tfsdk:"group_id" autogen:"omitjson"` + Name types.String `tfsdk:"name"` + Options types.Object `tfsdk:"options"` + Pipeline types.List `tfsdk:"pipeline"` + State types.String `tfsdk:"state" autogen:"omitjson"` + Stats jsontypes.Normalized `tfsdk:"stats" autogen:"omitjson"` + TenantName types.String `tfsdk:"tenant_name" autogen:"omitjson"` +} +type TFOptionsModel struct { + Dlq types.Object `tfsdk:"dlq"` + ResumeFromCheckpoint types.Bool `tfsdk:"resume_from_checkpoint"` +} +type TFOptionsDlqModel struct { + Coll types.String `tfsdk:"coll"` + ConnectionName types.String `tfsdk:"connection_name"` + Db types.String `tfsdk:"db"` +} diff --git a/internal/serviceapi/streamprocessorapi/resource_test.go b/internal/serviceapi/streamprocessorapi/resource_test.go new file mode 100644 index 0000000000..59bbe61a80 --- /dev/null +++ b/internal/serviceapi/streamprocessorapi/resource_test.go @@ -0,0 +1,172 @@ +package streamprocessorapi_test + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/acc" +) + +const ( + resourceName = "mongodbatlas_stream_processor_api.test" + + pipeline = `[ + jsonencode({ "$source" = { "connectionName" = "sample_stream_solar" }}), + jsonencode({ "$emit" = { "connectionName" = "__testLog" }}) + ]` + + pipelineEquivalentWithBlankLines = `[ + jsonencode({ + "$source" = { + "connectionName" = "sample_stream_solar" + } + }), + jsonencode({ + "$emit" = { + "connectionName" = "__testLog" + } + }) + ]` + + pipelineInvalidJSON = `[ "invalid json" ]` +) + +func TestAccStreamProcessorAPI_basic(t *testing.T) { + var ( + projectID = acc.ProjectIDExecution(t) + instanceName = acc.RandomName() + processorName = acc.RandomName() + ) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acc.PreCheckBasic(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + CheckDestroy: checkDestroy, + Steps: []resource.TestStep{ + { + Config: configBasic(projectID, instanceName, processorName, pipeline), + Check: checkBasic(projectID, instanceName, processorName), + }, + { + Config: configBasic(projectID, instanceName, processorName, pipelineEquivalentWithBlankLines), + Check: checkBasic(projectID, instanceName, processorName), + PlanOnly: true, // no plan changes if the pipeline JSON is equivalent. + }, + { + ResourceName: resourceName, + ImportStateIdFunc: importStateIDFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"stats"}, + ImportStateVerifyIdentifierAttribute: "name", // id is not used because _id is returned in Atlas which is not a legal name for a Terraform attribute. + }, + { + Config: configBasic(projectID, instanceName, processorName, pipelineInvalidJSON), + ExpectError: regexp.MustCompile("marshal failed for JSON custom type"), + }, + }, + }) +} + +func configBasic(projectID, instanceName, processorName, pipeline string) string { + return fmt.Sprintf(` + resource "mongodbatlas_stream_instance_api" "test" { + group_id = %[1]q + name = %[2]q + data_process_region = { + region = "VIRGINIA_USA" + cloud_provider = "AWS" + } + stream_config = { + tier = "SP10" + } + } + + resource "mongodbatlas_stream_connection" "test" { + project_id = mongodbatlas_stream_instance_api.test.group_id + instance_name = mongodbatlas_stream_instance_api.test.name + connection_name = "sample_stream_solar" + type = "Sample" + depends_on = [mongodbatlas_stream_instance_api.test] + } + + resource "mongodbatlas_stream_processor_api" "test" { + group_id = mongodbatlas_stream_instance_api.test.group_id + tenant_name = mongodbatlas_stream_instance_api.test.name + name = %[3]q + + pipeline = %[4]s + + depends_on = [mongodbatlas_stream_connection.test] + } + `, projectID, instanceName, processorName, pipeline) +} + +func checkBasic(projectID, instanceName, processorName string) resource.TestCheckFunc { + mapChecks := map[string]string{ + "group_id": projectID, + "tenant_name": instanceName, + "name": processorName, + } + checks := acc.AddAttrChecks(resourceName, nil, mapChecks) + checks = append(checks, checkExists(resourceName)) + return resource.ComposeAggregateTestCheckFunc(checks...) +} + +func checkExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + groupID := rs.Primary.Attributes["group_id"] + tenantName := rs.Primary.Attributes["tenant_name"] + processorName := rs.Primary.Attributes["name"] + if groupID == "" || tenantName == "" || processorName == "" { + return fmt.Errorf("checkExists, attributes not found for: %s", resourceName) + } + if _, _, err := acc.ConnV2().StreamsApi.GetStreamProcessor(context.Background(), groupID, tenantName, processorName).Execute(); err == nil { + return nil + } + return fmt.Errorf("stream processor(%s/%s/%s) does not exist", groupID, tenantName, processorName) + } +} + +func checkDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "mongodbatlas_stream_processor_api" { + continue + } + groupID := rs.Primary.Attributes["group_id"] + tenantName := rs.Primary.Attributes["tenant_name"] + processorName := rs.Primary.Attributes["name"] + if groupID == "" || tenantName == "" || processorName == "" { + return fmt.Errorf("checkDestroy, attributes not found for: %s", resourceName) + } + _, _, err := acc.ConnV2().StreamsApi.GetStreamProcessor(context.Background(), groupID, tenantName, processorName).Execute() + if err == nil { + return fmt.Errorf("stream processor (%s/%s/%s) still exists", groupID, tenantName, processorName) + } + } + return nil +} + +func importStateIDFunc(resourceName string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("not found: %s", resourceName) + } + groupID := rs.Primary.Attributes["group_id"] + tenantName := rs.Primary.Attributes["tenant_name"] + processorName := rs.Primary.Attributes["name"] + if groupID == "" || tenantName == "" || processorName == "" { + return "", fmt.Errorf("import, attributes not found for: %s", resourceName) + } + return fmt.Sprintf("%s/%s/%s", groupID, tenantName, processorName), nil + } +} diff --git a/tools/codegen/codespec/attribute.go b/tools/codegen/codespec/attribute.go index 8633cb0625..4d08ad2110 100644 --- a/tools/codegen/codespec/attribute.go +++ b/tools/codegen/codespec/attribute.go @@ -168,14 +168,22 @@ func (s *APISpecSchema) buildArrayAttr(name string, computability ComputedOption isSet := s.Schema.Format == OASFormatSet || (s.Schema.UniqueItems != nil && *s.Schema.UniqueItems) createAttribute := func(nestedObject *NestedAttributeObject, elemType ElemType) *Attribute { - attr := &Attribute{ - Name: stringcase.FromCamelCase(name), - ComputedOptionalRequired: computability, - DeprecationMessage: s.GetDeprecationMessage(), - Description: s.GetDescription(), + var ( + attr = &Attribute{ + Name: stringcase.FromCamelCase(name), + ComputedOptionalRequired: computability, + DeprecationMessage: s.GetDeprecationMessage(), + Description: s.GetDescription(), + } + isNested = nestedObject != nil + isNestedEmpty = isNested && len(nestedObject.Attributes) == 0 + ) + + if isNested && isNestedEmpty { // objects without attributes use JSON custom type + elemType = CustomTypeJSON } - if nestedObject != nil { + if isNested && !isNestedEmpty { if isSet { attr.SetNested = &SetNestedAttribute{NestedObject: *nestedObject} } else { @@ -259,20 +267,25 @@ func (s *APISpecSchema) buildMapAttr(name string, computability ComputedOptional } func (s *APISpecSchema) buildSingleNestedAttr(name string, computability ComputedOptionalRequired, isFromRequest bool) (*Attribute, error) { - objectAttributes, err := buildResourceAttrs(s, isFromRequest) - if err != nil { - return nil, err - } - - return &Attribute{ + attr := &Attribute{ Name: stringcase.FromCamelCase(name), ComputedOptionalRequired: computability, DeprecationMessage: s.GetDeprecationMessage(), Description: s.GetDescription(), - SingleNested: &SingleNestedAttribute{ + } + objectAttributes, err := buildResourceAttrs(s, isFromRequest) + if err != nil { + return nil, err + } + if len(objectAttributes) > 0 { + attr.SingleNested = &SingleNestedAttribute{ NestedObject: NestedAttributeObject{ Attributes: objectAttributes, }, - }, - }, nil + } + } else { // objects without attributes use JSON custom type + attr.CustomType = &CustomTypeJSONVar + attr.String = &StringAttribute{} + } + return attr, nil } diff --git a/tools/codegen/codespec/model.go b/tools/codegen/codespec/model.go index b58c0b772a..dce93dd96a 100644 --- a/tools/codegen/codespec/model.go +++ b/tools/codegen/codespec/model.go @@ -10,6 +10,7 @@ const ( Int64 Number String + CustomTypeJSON Unknown ) @@ -58,7 +59,7 @@ type Attributes []Attribute // Add this field to the Attribute struct // Usage AttributeUsage type Attribute struct { - SetNested *SetNestedAttribute + Set *SetAttribute String *StringAttribute Float64 *Float64Attribute List *ListAttribute @@ -66,15 +67,16 @@ type Attribute struct { ListNested *ListNestedAttribute Map *MapAttribute MapNested *MapNestedAttribute - Int64 *Int64Attribute Number *NumberAttribute - Set *SetAttribute - SingleNested *SingleNestedAttribute + Int64 *Int64Attribute Timeouts *TimeoutsAttribute + SingleNested *SingleNestedAttribute + SetNested *SetNestedAttribute Description *string DeprecationMessage *string - Name stringcase.SnakeCaseString + CustomType *CustomType ComputedOptionalRequired ComputedOptionalRequired + Name stringcase.SnakeCaseString ReqBodyUsage AttributeReqBodyUsage Sensitive bool } @@ -152,3 +154,13 @@ type CustomDefault struct { Definition string Imports []string } + +type CustomType struct { + Model string + Schema string +} + +var CustomTypeJSONVar = CustomType{ + Model: "jsontypes.Normalized", + Schema: "jsontypes.NormalizedType{}", +} diff --git a/tools/codegen/config.yml b/tools/codegen/config.yml index d9b9ba1423..5c062a144b 100644 --- a/tools/codegen/config.yml +++ b/tools/codegen/config.yml @@ -336,3 +336,41 @@ resources: ignores: ["_id", "region", "cloud_provider"] # region and cloud_provider are incorrectly declared in PATCH as root attributes, _id can't be used in Terraform schema aliases: tenant_name: name # path param name does not match the API request property + + # id should be Computed but is ignored because it's defined as _id which is not a legal name for a Terraform attribute as they can't start with an underscore. + # Custom methods :startWith and :stop are not called so state attribute is not supported as Optional like in the curated resource, only as Computed. + stream_processor_api: + read: + path: /api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{processorName} + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor + method: POST + wait: &stream-processor-create-wait + state_property: "state" + pending_states: ["INIT", "CREATING"] + target_states: ["CREATED"] + timeout_seconds: 300 # 5 minutes + min_timeout_seconds: 10 + delay_seconds: 10 + update: + path: /api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{processorName} + method: PATCH + wait: *stream-processor-create-wait + delete: + path: /api/atlas/v2/groups/{groupId}/streams/{tenantName}/processor/{processorName} + method: DELETE + wait: + <<: *stream-processor-create-wait + pending_states: ["INIT", "CREATING", "CREATED", "STARTED", "STOPPED"] + target_states: ["DELETED"] # DELETED is a special state value when API returns 404 or empty object + version_header: application/vnd.atlas.2024-05-30+json + schema: + ignores: ["links", "options.links", "options.dlq.links", "_id"] + aliases: + processor_name: name # path param name does not match the API request property. + overrides: + options: # optional but when not configured, Atlas returns a default value. + computability: + optional: true + computed: true diff --git a/tools/codegen/gofilegen/schema/element_type_mapping.go b/tools/codegen/gofilegen/schema/element_type_mapping.go index 34685b2381..036ce1682c 100644 --- a/tools/codegen/gofilegen/schema/element_type_mapping.go +++ b/tools/codegen/gofilegen/schema/element_type_mapping.go @@ -7,11 +7,12 @@ import ( ) var elementTypeToString = map[codespec.ElemType]string{ - codespec.Bool: "types.BoolType", - codespec.Float64: "types.Float64Type", - codespec.Int64: "types.Int64Type", - codespec.Number: "types.NumberType", - codespec.String: "types.StringType", + codespec.Bool: "types.BoolType", + codespec.Float64: "types.Float64Type", + codespec.Int64: "types.Int64Type", + codespec.Number: "types.NumberType", + codespec.String: "types.StringType", + codespec.CustomTypeJSON: codespec.CustomTypeJSONVar.Schema, } const typesImportStatement = "github.com/hashicorp/terraform-plugin-framework/types" diff --git a/tools/codegen/gofilegen/schema/schema_attribute.go b/tools/codegen/gofilegen/schema/schema_attribute.go index 4a3f509213..496f9c6a80 100644 --- a/tools/codegen/gofilegen/schema/schema_attribute.go +++ b/tools/codegen/gofilegen/schema/schema_attribute.go @@ -108,6 +108,11 @@ func generator(attr *codespec.Attribute) attributeGenerator { func commonAttrStructure(attr *codespec.Attribute, typeDef string, specificProperties []CodeStatement) CodeStatement { properties := commonProperties(attr) imports := []string{} + + if attr.CustomType != nil { + imports = append(imports, "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes") + } + for i := range specificProperties { properties = append(properties, specificProperties[i].Code) imports = append(imports, specificProperties[i].Imports...) @@ -141,5 +146,8 @@ func commonProperties(attr *codespec.Attribute) []string { if attr.Sensitive { result = append(result, "Sensitive: true") } + if attr.CustomType != nil { + result = append(result, fmt.Sprintf("CustomType: %s", attr.CustomType.Schema)) + } return result } diff --git a/tools/codegen/gofilegen/schema/schema_file_test.go b/tools/codegen/gofilegen/schema/schema_file_test.go index 579bf3a9db..af215ae873 100644 --- a/tools/codegen/gofilegen/schema/schema_file_test.go +++ b/tools/codegen/gofilegen/schema/schema_file_test.go @@ -117,6 +117,13 @@ func TestSchemaGenerationFromCodeSpec(t *testing.T) { ComputedOptionalRequired: codespec.Required, ReqBodyUsage: codespec.OmitInUpdateBody, }, + { + Name: "json_attr", + String: &codespec.StringAttribute{}, + CustomType: &codespec.CustomTypeJSONVar, + Description: admin.PtrString("json description"), + ComputedOptionalRequired: codespec.Required, + }, }, }, }, diff --git a/tools/codegen/gofilegen/schema/testdata/primitive-attributes.golden.go b/tools/codegen/gofilegen/schema/testdata/primitive-attributes.golden.go index f9a2545f1a..4494531edc 100644 --- a/tools/codegen/gofilegen/schema/testdata/primitive-attributes.golden.go +++ b/tools/codegen/gofilegen/schema/testdata/primitive-attributes.golden.go @@ -5,6 +5,7 @@ package testname import ( "context" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -56,19 +57,25 @@ func ResourceSchema(ctx context.Context) schema.Schema { Required: true, MarkdownDescription: "string description", }, + "json_attr": schema.StringAttribute{ + Required: true, + MarkdownDescription: "json description", + CustomType: jsontypes.NormalizedType{}, + }, }, } } type TFModel struct { - StringAttr types.String `tfsdk:"string_attr"` - BoolAttr types.Bool `tfsdk:"bool_attr"` - IntAttr types.Int64 `tfsdk:"int_attr"` - FloatAttr types.Float64 `tfsdk:"float_attr"` - NumberAttr types.Number `tfsdk:"number_attr"` - SimpleListAttr types.List `tfsdk:"simple_list_attr"` - SimpleSetAttr types.Set `tfsdk:"simple_set_attr"` - SimpleMapAttr types.Map `tfsdk:"simple_map_attr"` - AttrNotIncludedInReqBodies types.String `tfsdk:"attr_not_included_in_req_bodies" autogen:"omitjson"` - AttrOnlyInPostReqBodies types.String `tfsdk:"attr_only_in_post_req_bodies" autogen:"omitjsonupdate"` + StringAttr types.String `tfsdk:"string_attr"` + BoolAttr types.Bool `tfsdk:"bool_attr"` + IntAttr types.Int64 `tfsdk:"int_attr"` + FloatAttr types.Float64 `tfsdk:"float_attr"` + NumberAttr types.Number `tfsdk:"number_attr"` + SimpleListAttr types.List `tfsdk:"simple_list_attr"` + SimpleSetAttr types.Set `tfsdk:"simple_set_attr"` + SimpleMapAttr types.Map `tfsdk:"simple_map_attr"` + AttrNotIncludedInReqBodies types.String `tfsdk:"attr_not_included_in_req_bodies" autogen:"omitjson"` + AttrOnlyInPostReqBodies types.String `tfsdk:"attr_only_in_post_req_bodies" autogen:"omitjsonupdate"` + JsonAttr jsontypes.Normalized `tfsdk:"json_attr"` } diff --git a/tools/codegen/gofilegen/schema/typed_model.go b/tools/codegen/gofilegen/schema/typed_model.go index 219d152260..404e1f36d8 100644 --- a/tools/codegen/gofilegen/schema/typed_model.go +++ b/tools/codegen/gofilegen/schema/typed_model.go @@ -99,6 +99,8 @@ func typedModelProperty(attr *codespec.Attribute) string { func attrModelType(attr *codespec.Attribute) string { switch { + case attr.CustomType != nil: + return attr.CustomType.Model case attr.Float64 != nil: return "types.Float64" case attr.Bool != nil: diff --git a/tools/codegen/main.go b/tools/codegen/main.go index 4d313e9529..464d996ede 100644 --- a/tools/codegen/main.go +++ b/tools/codegen/main.go @@ -38,15 +38,11 @@ func main() { if err := writeToFile(schemaFilePath, schemaCode); err != nil { log.Fatalf("an error occurred when writing content to file: %v", err) } - - // Run fieldalignment on the generated schema file - cmd := exec.Command("fieldalignment", "-fix", schemaFilePath) - if output, err := cmd.CombinedOutput(); err != nil { - log.Printf("warning: fieldalignment failed for %s: %v\nOutput: %s", schemaFilePath, err, output) - } + formatGoFile(schemaFilePath) resourceCode := resource.GenerateGoCode(&resourceModel) - if err := writeToFile(fmt.Sprintf("internal/serviceapi/%s/resource.go", resourceModel.Name.LowerCaseNoUnderscore()), resourceCode); err != nil { + resourceFilePath := fmt.Sprintf("internal/serviceapi/%s/resource.go", resourceModel.Name.LowerCaseNoUnderscore()) + if err := writeToFile(resourceFilePath, resourceCode); err != nil { log.Fatalf("an error occurred when writing content to file: %v", err) } } @@ -76,3 +72,16 @@ func writeToFile(fileName, content string) error { } return nil } + +// formatGoFile runs goimports and fieldalignment on the specified Go file +func formatGoFile(filePath string) { + goimportsCmd := exec.Command("goimports", "-w", filePath) + if output, err := goimportsCmd.CombinedOutput(); err != nil { + log.Printf("warning: goimports failed for %s: %v\nOutput: %s", filePath, err, output) + } + + fieldalignmentCmd := exec.Command("fieldalignment", "-fix", filePath) + if output, err := fieldalignmentCmd.CombinedOutput(); err != nil { + log.Printf("warning: fieldalignment failed for %s: %v\nOutput: %s", filePath, err, output) + } +} diff --git a/tools/scaffold/template/resource.tmpl b/tools/scaffold/template/resource.tmpl index c2c0d2a342..8d2b9f120c 100644 --- a/tools/scaffold/template/resource.tmpl +++ b/tools/scaffold/template/resource.tmpl @@ -151,3 +151,4 @@ func (r *rs) ImportState(ctx context.Context, req resource.ImportStateRequest, r // resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectID)...) } +