Skip to content

Commit f316bb1

Browse files
pieternnfx
authored andcommitted
Implement widget resource
1 parent f836392 commit f316bb1

File tree

5 files changed

+371
-0
lines changed

5 files changed

+371
-0
lines changed

provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func DatabricksProvider() *schema.Provider {
7878
"databricks_sql_endpoint": sqlanalytics.ResourceSQLEndpoint(),
7979
"databricks_sql_query": sqlanalytics.ResourceQuery(),
8080
"databricks_sql_visualization": sqlanalytics.ResourceVisualization(),
81+
"databricks_sql_widget": sqlanalytics.ResourceWidget(),
8182

8283
"databricks_global_init_script": workspace.ResourceGlobalInitScript(),
8384
"databricks_notebook": workspace.ResourceNotebook(),

sqlanalytics/api/widget.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package api
2+
3+
import "encoding/json"
4+
5+
// Widget ...
6+
type Widget struct {
7+
ID int `json:"id,omitempty"`
8+
9+
// Widgets are part of a dashboard.
10+
DashboardID string `json:"dashboard_id"`
11+
12+
// They are either linked to a query visualization or embed a piece of Markdown text.
13+
// These fields are mutually exclusive and must be `null` if they don't apply.
14+
VisualizationID *int `json:"visualization_id"`
15+
Text *string `json:"text"`
16+
17+
// This field is no longer in use, but is still required as part of the schema.
18+
// It's OK that the field value is 0 everywhere.
19+
Width int `json:"width"`
20+
21+
Options struct {
22+
ParameterMapping map[string]WidgetParameterMapping `json:"parameterMappings"`
23+
Position *WidgetPosition `json:"position,omitempty"`
24+
} `json:"options"`
25+
26+
// Fields below are set only when retrieving an existing widget.
27+
Visualization json.RawMessage `json:"visualization,omitempty"`
28+
}
29+
30+
// WidgetPosition ...
31+
type WidgetPosition struct {
32+
AutoHeight bool `json:"autoHeight"`
33+
SizeX int `json:"sizeX,omitempty"`
34+
SizeY int `json:"sizeY,omitempty"`
35+
PosX int `json:"col"`
36+
PosY int `json:"row"`
37+
}
38+
39+
// WidgetParameterMapping ...
40+
type WidgetParameterMapping struct {
41+
Name string `json:"name"`
42+
Type string `json:"type"`
43+
MapTo string `json:"mapTo,omitempty"`
44+
45+
// The type of the value depends on the type of the parameter referred to by `name`.
46+
Value interface{} `json:"value"`
47+
48+
// This title overrides the title given to this parameter by the query, if specified.
49+
Title string `json:"title,omitempty"`
50+
}

sqlanalytics/api/wrapper.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,61 @@ func (a Wrapper) UpdateDashboard(d *Dashboard) (*Dashboard, error) {
163163
func (a Wrapper) DeleteDashboard(d *Dashboard) error {
164164
return a.client.Delete(a.context, fmt.Sprintf("%s/dashboards/%s", sqlaBasePath, d.ID), nil)
165165
}
166+
167+
// CreateWidget ...
168+
func (a Wrapper) CreateWidget(w *Widget) (*Widget, error) {
169+
var wout Widget
170+
err := a.client.Post(a.context, fmt.Sprintf("%s/widgets", sqlaBasePath), w, &wout)
171+
if err != nil {
172+
return nil, err
173+
}
174+
175+
return &wout, err
176+
}
177+
178+
// ReadWidget ...
179+
func (a Wrapper) ReadWidget(w *Widget) (*Widget, error) {
180+
if w.DashboardID == "" {
181+
return nil, errors.Errorf("Cannot read widget without dashboard ID")
182+
}
183+
184+
var d Dashboard
185+
err := a.client.Get(a.context, fmt.Sprintf("%s/dashboards/%s", sqlaBasePath, w.DashboardID), nil, &d)
186+
if err != nil {
187+
return nil, err
188+
}
189+
190+
// Look for matching widget ID.
191+
for _, wp := range d.Widgets {
192+
var wnew Widget
193+
err = json.Unmarshal(wp, &wnew)
194+
if err != nil {
195+
return nil, err
196+
}
197+
198+
if wnew.ID == w.ID {
199+
// Include dashboard ID in returned object.
200+
// It's not part of the API response.
201+
wnew.DashboardID = w.DashboardID
202+
return &wnew, nil
203+
}
204+
}
205+
206+
return nil, errors.Errorf("Cannot find widget %d attached to dashboard %s", w.ID, w.DashboardID)
207+
}
208+
209+
// UpdateWidget ...
210+
func (a Wrapper) UpdateWidget(w *Widget) (*Widget, error) {
211+
var wout Widget
212+
err := a.client.Post(a.context, fmt.Sprintf("%s/widgets/%d", sqlaBasePath, w.ID), w, &wout)
213+
if err != nil {
214+
return nil, err
215+
}
216+
217+
return &wout, nil
218+
}
219+
220+
// DeleteWidget ...
221+
func (a Wrapper) DeleteWidget(w *Widget) error {
222+
return a.client.Delete(a.context, fmt.Sprintf("%s/widgets/%d", sqlaBasePath, w.ID), nil)
223+
}

sqlanalytics/resource_widget.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package sqlanalytics
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
8+
"github.com/databrickslabs/terraform-provider-databricks/common"
9+
"github.com/databrickslabs/terraform-provider-databricks/sqlanalytics/api"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
11+
)
12+
13+
// WidgetEntity defines the parameters that can be set in the resource.
14+
type WidgetEntity struct {
15+
DashboardID string `json:"dashboard_id"`
16+
Text string `json:"text,omitempty"`
17+
VisualizationID string `json:"visualization_id,omitempty"`
18+
19+
Position *WidgetPosition `json:"position,omitempty"`
20+
Parameter []WidgetParameter `json:"parameter,omitempty"`
21+
}
22+
23+
// WidgetPosition ...
24+
type WidgetPosition struct {
25+
SizeX int `json:"size_x"`
26+
SizeY int `json:"size_y"`
27+
PosX int `json:"pos_x"`
28+
PosY int `json:"pos_y"`
29+
}
30+
31+
// WidgetParameter ...
32+
type WidgetParameter struct {
33+
Name string `json:"name"`
34+
Type string `json:"type"`
35+
MapTo string `json:"map_to,omitempty"`
36+
Title string `json:"title,omitempty"`
37+
Value string `json:"value,omitempty"`
38+
}
39+
40+
func (w *WidgetEntity) toAPIObject(schema map[string]*schema.Schema, data *schema.ResourceData) (*api.Widget, error) {
41+
var aw api.Widget
42+
43+
// Extract from ResourceData.
44+
if err := common.DataToStructPointer(data, schema, w); err != nil {
45+
return nil, err
46+
}
47+
48+
// Only copy over the ID if this is an existing resource.
49+
if data.Id() != "" {
50+
id, err := strconv.Atoi(data.Id())
51+
if err != nil {
52+
return nil, err
53+
}
54+
aw.ID = id
55+
}
56+
57+
aw.DashboardID = w.DashboardID
58+
59+
// The visualization ID is a string for the Terraform resource and an integer in the API.
60+
if w.VisualizationID != "" {
61+
visualizationID, err := strconv.Atoi(w.VisualizationID)
62+
if err != nil {
63+
return nil, err
64+
}
65+
aw.VisualizationID = &visualizationID
66+
}
67+
68+
if w.Text != "" {
69+
aw.Text = &w.Text
70+
}
71+
72+
if w.Position != nil {
73+
aw.Options.Position = &api.WidgetPosition{
74+
AutoHeight: false,
75+
SizeX: w.Position.SizeX,
76+
SizeY: w.Position.SizeY,
77+
PosX: w.Position.PosX,
78+
PosY: w.Position.PosY,
79+
}
80+
}
81+
82+
aw.Options.ParameterMapping = make(map[string]api.WidgetParameterMapping)
83+
for _, wp := range w.Parameter {
84+
aw.Options.ParameterMapping[wp.Name] = api.WidgetParameterMapping{
85+
Name: wp.Name,
86+
Type: wp.Type,
87+
MapTo: wp.MapTo,
88+
Title: wp.Title,
89+
Value: wp.Value,
90+
}
91+
}
92+
93+
return &aw, nil
94+
}
95+
96+
func (w *WidgetEntity) fromAPIObject(aw *api.Widget, schema map[string]*schema.Schema, data *schema.ResourceData) error {
97+
// Copy from API object.
98+
w.DashboardID = aw.DashboardID
99+
100+
if aw.VisualizationID != nil {
101+
w.VisualizationID = fmt.Sprint(*aw.VisualizationID)
102+
}
103+
104+
if aw.Text != nil {
105+
w.Text = *aw.Text
106+
}
107+
108+
if pos := aw.Options.Position; pos != nil {
109+
w.Position = &WidgetPosition{
110+
SizeX: pos.SizeX,
111+
SizeY: pos.SizeY,
112+
PosX: pos.PosX,
113+
PosY: pos.PosY,
114+
}
115+
}
116+
117+
// Pass to ResourceData.
118+
if err := common.StructToData(*w, schema, data); err != nil {
119+
return err
120+
}
121+
122+
return nil
123+
}
124+
125+
// ResourceWidget ...
126+
func ResourceWidget() *schema.Resource {
127+
s := common.StructToSchema(
128+
WidgetEntity{},
129+
func(m map[string]*schema.Schema) map[string]*schema.Schema {
130+
m["text"].ConflictsWith = []string{"visualization_id"}
131+
return m
132+
})
133+
134+
return common.Resource{
135+
Create: func(ctx context.Context, data *schema.ResourceData, c *common.DatabricksClient) error {
136+
var w WidgetEntity
137+
aw, err := w.toAPIObject(s, data)
138+
if err != nil {
139+
return err
140+
}
141+
142+
awp, err := api.NewWrapper(ctx, c).CreateWidget(aw)
143+
if err != nil {
144+
return err
145+
}
146+
147+
// No need to set anything because the resource is going to be
148+
// read immediately after being created.
149+
data.SetId(fmt.Sprint(awp.ID))
150+
return nil
151+
},
152+
Read: func(ctx context.Context, data *schema.ResourceData, c *common.DatabricksClient) error {
153+
var w WidgetEntity
154+
aw, err := w.toAPIObject(s, data)
155+
if err != nil {
156+
return err
157+
}
158+
159+
awNew, err := api.NewWrapper(ctx, c).ReadWidget(aw)
160+
if err != nil {
161+
return err
162+
}
163+
164+
return w.fromAPIObject(awNew, s, data)
165+
},
166+
Update: func(ctx context.Context, data *schema.ResourceData, c *common.DatabricksClient) error {
167+
var d WidgetEntity
168+
ad, err := d.toAPIObject(s, data)
169+
if err != nil {
170+
return err
171+
}
172+
173+
_, err = api.NewWrapper(ctx, c).UpdateWidget(ad)
174+
if err != nil {
175+
return err
176+
}
177+
178+
// No need to set anything because the resource is going to be
179+
// read immediately after being created.
180+
return nil
181+
},
182+
Delete: func(ctx context.Context, data *schema.ResourceData, c *common.DatabricksClient) error {
183+
var w WidgetEntity
184+
aw, err := w.toAPIObject(s, data)
185+
if err != nil {
186+
return err
187+
}
188+
189+
return api.NewWrapper(ctx, c).DeleteWidget(aw)
190+
},
191+
Schema: s,
192+
}.ToResource()
193+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package sqlanalytics
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/databrickslabs/terraform-provider-databricks/qa"
8+
"github.com/databrickslabs/terraform-provider-databricks/sqlanalytics/api"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestWidgetCreate(t *testing.T) {
13+
i678 := 678
14+
15+
d, err := qa.ResourceFixture{
16+
Fixtures: []qa.HTTPFixture{
17+
{
18+
Method: "POST",
19+
Resource: "/api/2.0/preview/sql/widgets",
20+
ExpectedRequest: api.Widget{
21+
DashboardID: "some-uuid",
22+
VisualizationID: &i678,
23+
},
24+
Response: api.Widget{
25+
ID: 12345,
26+
DashboardID: "some-uuid",
27+
VisualizationID: &i678,
28+
},
29+
},
30+
{
31+
Method: "GET",
32+
Resource: "/api/2.0/preview/sql/dashboards/some-uuid",
33+
Response: api.Dashboard{
34+
ID: "some-uuid",
35+
Widgets: []json.RawMessage{
36+
json.RawMessage(`
37+
{
38+
"id": 12344,
39+
"visualization_id": null
40+
}
41+
`),
42+
json.RawMessage(`
43+
{
44+
"id": 12345,
45+
"visualization_id": 678
46+
}
47+
`),
48+
json.RawMessage(`
49+
{
50+
"id": 12345,
51+
"visualization_id": null
52+
}
53+
`),
54+
},
55+
},
56+
},
57+
},
58+
Resource: ResourceWidget(),
59+
Create: true,
60+
State: map[string]interface{}{
61+
"dashboard_id": "some-uuid",
62+
"visualization_id": "678",
63+
},
64+
}.Apply(t)
65+
66+
assert.NoError(t, err, err)
67+
assert.Equal(t, "12345", d.Id(), "Resource ID should not be empty")
68+
assert.Equal(t, "678", d.Get("visualization_id"))
69+
}

0 commit comments

Comments
 (0)