diff --git a/client/endpoint/view.go b/client/endpoint/view.go new file mode 100644 index 00000000..a5cfe848 --- /dev/null +++ b/client/endpoint/view.go @@ -0,0 +1,16 @@ +package endpoint + +// Views returns a View API's endpoint url. +func (ep *Endpoints) Views() string { + return ep.views +} + +// View returns a View API's endpoint url. +func (ep *Endpoints) View(id string) string { + return ep.views + "/" + id +} + +// ViewDefault returns a View API's endpoint url. +func (ep *Endpoints) ViewDefault(id string) string { + return ep.views + "/" + id + "/default" +} diff --git a/client/view.go b/client/view.go new file mode 100644 index 00000000..c9c77b2a --- /dev/null +++ b/client/view.go @@ -0,0 +1,76 @@ +package client + +import ( + "context" + "errors" + + "github.com/suzuki-shunsuke/go-graylog" +) + +// GetViews returns all views. +func (client *Client) GetViews( + ctx context.Context, +) (*graylog.Views, *ErrorInfo, error) { + viewsBody := &graylog.Views{} + ei, err := client.callGet( + ctx, client.Endpoints().Views(), nil, viewsBody) + return viewsBody, ei, err +} + +// GetView returns a given view. +func (client *Client) GetView( + ctx context.Context, id string, +) (*graylog.View, *ErrorInfo, error) { + if id == "" { + return nil, nil, errors.New("id is empty") + } + view := &graylog.View{} + ei, err := client.callGet(ctx, client.Endpoints().View(id), nil, view) + return view, ei, err +} + +// CreateView creates a view. +func (client *Client) CreateView( + ctx context.Context, view *graylog.View, +) (*ErrorInfo, error) { + // required: title search_id state + // allowed: state, search_id, owner, summary, title, created_at, id, description, requires, properties, dashboard_state + if view == nil { + return nil, errors.New("view is nil") + } + ret := map[string]string{} + ei, err := client.callPost(ctx, client.Endpoints().Views(), view, &ret) + if err != nil { + return ei, err + } + if id, ok := ret["view_id"]; ok { + view.ID = id + return ei, nil + } + return ei, errors.New(`response doesn't have the field "view_id"`) +} + +// UpdateView updates a view. +func (client *Client) UpdateView( + ctx context.Context, view *graylog.View, +) (*ErrorInfo, error) { + if view == nil { + return nil, errors.New("view is nil") + } + if view.ID == "" { + return nil, errors.New("id is empty") + } + body := *view + body.ID = "" + return client.callPut(ctx, client.Endpoints().View(view.ID), &body, view) +} + +// DeleteView deletes a view. +func (client *Client) DeleteView( + ctx context.Context, id string, +) (*ErrorInfo, error) { + if id == "" { + return nil, errors.New("id is empty") + } + return client.callDelete(ctx, client.Endpoints().View(id), nil, nil) +} diff --git a/client/view_test.go b/client/view_test.go new file mode 100644 index 00000000..e659f400 --- /dev/null +++ b/client/view_test.go @@ -0,0 +1,58 @@ +package client + +import ( + "context" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/suzuki-shunsuke/flute/flute" + + "github.com/suzuki-shunsuke/go-graylog/testdata" +) + +func TestClient_GetViews(t *testing.T) { + ctx := context.Background() + + cl, err := NewClient("http://example.com/api", "admin", "admin") + require.Nil(t, err) + + buf, err := ioutil.ReadFile("../testdata/views.json") + require.Nil(t, err) + bodyStr := string(buf) + + cl.SetHTTPClient(&http.Client{ + Transport: &flute.Transport{ + T: t, + Services: []flute.Service{ + { + Endpoint: "http://example.com", + Routes: []flute.Route{ + { + Tester: &flute.Tester{ + Method: "GET", + Path: "/api/views", + PartOfHeader: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Requested-By": []string{"go-graylog"}, + "Authorization": nil, + }, + }, + Response: &flute.Response{ + Base: http.Response{ + StatusCode: 200, + }, + BodyString: bodyStr, + }, + }, + }, + }, + }, + }, + }) + + views, _, err := cl.GetViews(ctx) + require.Nil(t, err) + require.Equal(t, testdata.Views, views) +} diff --git a/cmd/generate-testdata/main.go b/cmd/generate-testdata/main.go index 913145b4..bb2adf01 100644 --- a/cmd/generate-testdata/main.go +++ b/cmd/generate-testdata/main.go @@ -104,6 +104,7 @@ var ( "stream_alert_conditions": StreamAlertConditions{}, "outputs": Outputs{}, "stdout_output": Output{}, + "views": Views{}, } ) @@ -199,6 +200,10 @@ type ( EventDefinition struct { data graylog.EventDefinition } + + Views struct { + data graylog.Views + } ) func (users Users) dump(input string) error { @@ -292,3 +297,7 @@ func (definitions EventDefinitions) dump(input string) error { func (definition EventDefinition) dump(input string) error { return dump(input, &definition.data) } + +func (v Views) dump(input string) error { + return dump(input, &v.data) +} diff --git a/graylog/client/endpoint/endpoint.go b/graylog/client/endpoint/endpoint.go index 5eca596c..7230f937 100644 --- a/graylog/client/endpoint/endpoint.go +++ b/graylog/client/endpoint/endpoint.go @@ -33,6 +33,7 @@ type Endpoints struct { ldapGroupRoleMapping string connectStreamsToPipeline string connectPipelinesToStream string + views string apiVersion string } @@ -96,6 +97,7 @@ func newEndpoints(endpoint, version string) (*Endpoints, error) { users: endpoint + "/users", grokPatterns: endpoint + "/system/grok", grokPatternsTest: endpoint + "/system/grok/test", + views: endpoint + "/views", apiVersion: version, }, nil } diff --git a/testdata/views.go b/testdata/views.go new file mode 100644 index 00000000..97a8e72f --- /dev/null +++ b/testdata/views.go @@ -0,0 +1,121 @@ +package testdata + +import ( + "github.com/suzuki-shunsuke/go-graylog" +) + +var ( + Views = &graylog.Views{ + Total: 1, + Page: 1, + PerPage: 50, + Count: 0, + Views: []graylog.View{ + { + ID: "5d9529c175d97f58f953927a", + Title: "test", + Summary: "", + Description: "", + SearchID: "5d9529b275d97f58f9539275", + State: map[string]graylog.ViewState{ + "6971d00a-e605-43fb-b873-e4bca773d286": { + SelectedFields: []string{ + "source", + "message", + }, + Titles: map[string]map[string]string{ + "widget": { + "038b9bca-4884-496f-b1ba-bc345ad4069e": "Message Count", + "c8986792-07e0-41fa-aded-cd19c96f2789": "All Messages", + }, + }, + Widgets: []graylog.ViewWidget{ + { + ID: "038b9bca-4884-496f-b1ba-bc345ad4069e", + Config: graylog.AggregationViewWidgetConfig{ + RowPivots: []graylog.ViewWidgetRowPivot{ + { + Field: "timestamp", + Type: "time", + Config: graylog.ViewWidgetRowPivotConfig{ + Interval: graylog.ViewWidgetRowPivotInterval{ + Type: "auto", + }, + }, + }, + }, + Series: []graylog.ViewWidgetSeries{ + { + Config: graylog.ViewWidgetSeriesConfig{}, + Function: "count()", + }, + }, + Visualization: "bar", + Rollup: true, + }, + }, + { + ID: "c8986792-07e0-41fa-aded-cd19c96f2789", + Config: graylog.MessagesViewWidgetConfig{ + Fields: []string{ + "timestamp", + "source", + }, + ShowMessageRow: true, + }, + }, + { + ID: "41c694c8-093c-4d67-be42-06390e1c61ba", + Config: graylog.AggregationViewWidgetConfig{ + RowPivots: []graylog.ViewWidgetRowPivot{}, + Series: []graylog.ViewWidgetSeries{ + { + Config: graylog.ViewWidgetSeriesConfig{}, + Function: "count()", + }, + }, + Visualization: "numeric", + Rollup: true, + }, + }, + }, + WidgetMapping: map[string][]string{ + "038b9bca-4884-496f-b1ba-bc345ad4069e": { + "9b8a4a7f-9f6e-4032-afa6-e25fe24bab40", + }, + "41c694c8-093c-4d67-be42-06390e1c61ba": { + "81419b6e-4d6d-4739-8883-5d34e5267091", + }, + "c8986792-07e0-41fa-aded-cd19c96f2789": { + "07599874-f01e-46ae-84c4-cf724e7b0524", + }, + }, + Positions: map[string]graylog.ViewWidgetPosition{ + "038b9bca-4884-496f-b1ba-bc345ad4069e": { + Width: "Infinity", + Col: 1, + Row: 5, + Height: 2, + }, + "41c694c8-093c-4d67-be42-06390e1c61ba": { + Width: 4, + Col: 1, + Row: 1, + Height: 4, + }, + "c8986792-07e0-41fa-aded-cd19c96f2789": { + Width: "Infinity", + Col: 1, + Row: 7, + Height: 6, + }, + }, + }, + }, + DashboardState: graylog.DashboardState{}, + Owner: "admin", + CreatedAt: "2019-10-02T22:49:53.181Z", + }, + }, + } +) diff --git a/testdata/views.json b/testdata/views.json new file mode 100644 index 00000000..abbd87f9 --- /dev/null +++ b/testdata/views.json @@ -0,0 +1,139 @@ +{ + "total": 1, + "page": 1, + "per_page": 50, + "count": 0, + "views": [ + { + "id": "5d9529c175d97f58f953927a", + "title": "test", + "summary": "", + "description": "", + "search_id": "5d9529b275d97f58f9539275", + "properties": [], + "requires": {}, + "state": { + "6971d00a-e605-43fb-b873-e4bca773d286": { + "selected_fields": [ + "source", + "message" + ], + "static_message_list_id": null, + "titles": { + "widget": { + "038b9bca-4884-496f-b1ba-bc345ad4069e": "Message Count", + "c8986792-07e0-41fa-aded-cd19c96f2789": "All Messages" + } + }, + "widgets": [ + { + "id": "038b9bca-4884-496f-b1ba-bc345ad4069e", + "type": "aggregation", + "filter": null, + "config": { + "row_pivots": [ + { + "field": "timestamp", + "type": "time", + "config": { + "interval": { + "type": "auto", + "scaling": null + } + } + } + ], + "column_pivots": [], + "series": [ + { + "config": { + "name": null + }, + "function": "count()" + } + ], + "sort": [], + "visualization": "bar", + "visualization_config": null, + "formatting_settings": null, + "rollup": true + } + }, + { + "id": "c8986792-07e0-41fa-aded-cd19c96f2789", + "type": "messages", + "filter": null, + "config": { + "fields": [ + "timestamp", + "source" + ], + "show_message_row": true + } + }, + { + "id": "41c694c8-093c-4d67-be42-06390e1c61ba", + "type": "aggregation", + "filter": null, + "config": { + "row_pivots": [], + "column_pivots": [], + "series": [ + { + "config": { + "name": "Message Count" + }, + "function": "count()" + } + ], + "sort": [], + "visualization": "numeric", + "visualization_config": null, + "formatting_settings": null, + "rollup": true + } + } + ], + "widget_mapping": { + "038b9bca-4884-496f-b1ba-bc345ad4069e": [ + "9b8a4a7f-9f6e-4032-afa6-e25fe24bab40" + ], + "c8986792-07e0-41fa-aded-cd19c96f2789": [ + "07599874-f01e-46ae-84c4-cf724e7b0524" + ], + "41c694c8-093c-4d67-be42-06390e1c61ba": [ + "81419b6e-4d6d-4739-8883-5d34e5267091" + ] + }, + "positions": { + "038b9bca-4884-496f-b1ba-bc345ad4069e": { + "col": 1, + "row": 5, + "height": 2, + "width": "Infinity" + }, + "c8986792-07e0-41fa-aded-cd19c96f2789": { + "col": 1, + "row": 7, + "height": 6, + "width": "Infinity" + }, + "41c694c8-093c-4d67-be42-06390e1c61ba": { + "col": 1, + "row": 1, + "height": 4, + "width": 4 + } + }, + "formatting": null + } + }, + "dashboard_state": { + "widgets": {}, + "positions": {} + }, + "owner": "admin", + "created_at": "2019-10-02T22:49:53.181Z" + } + ] +} diff --git a/view.go b/view.go new file mode 100644 index 00000000..9a7438da --- /dev/null +++ b/view.go @@ -0,0 +1,197 @@ +package graylog + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +type ( + // Stream represents a steram. + View struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + Summary string `json:"summary"` + Description string `json:"description"` + SearchID string `json:"search_id"` + // Properties []interface{} `json:"properties"` + // Requires map[string]interface{} `json:"requires"` + State map[string]ViewState `json:"state"` + DashboardState DashboardState `json:"dashboard_state"` + Owner string `json:"owner"` + + // ex. "2018-02-20T11:37:19.371Z" + CreatedAt string `json:"created_at,omitempty"` + } + + ViewState struct { + SelectedFields []string `json:"selected_fields"` + // StatecMessageListID interface{} `json:"state_message_list_id"` + Titles map[string]map[string]string `json:"titles"` + Widgets []ViewWidget `json:"widgets"` + WidgetMapping map[string][]string `json:"widget_mapping"` + Positions map[string]ViewWidgetPosition `json:"positions"` + // Formatting interface{} `json:"formatting"` + } + + ViewWidget struct { + ID string `json:"id"` + // Filter interface{} `json:"filter"` + Config ViewWidgetConfig + } + + ViewWidgetConfig interface { + Type() string + } + + AggregationViewWidgetConfig struct { + RowPivots []ViewWidgetRowPivot `json:"row_pivots"` + // ColumnPivots []interface{} `json:"column_pivots"` + Series []ViewWidgetSeries `json:"series"` + // Sort []interface{} `json:"sort"` + Visualization string `json:"visualization"` + // VisualizationConfig interface{} `json:"visualization_config"` + // FormattingSettings interface{} `json:"formatting_settings"` + Rollup bool `json:"rollup"` + } + + MessagesViewWidgetConfig struct { + Fields []string `json:"fields"` + ShowMessageRow bool `json:"show_message_row"` + } + + ViewWidgetSeries struct { + Config ViewWidgetSeriesConfig `json:"config"` + Function string `json:"function"` + } + + ViewWidgetSeriesConfig struct { + // Name interface{} + } + + ViewWidgetRowPivot struct { + Field string `json:"field"` + Type string `json:"type"` + Config ViewWidgetRowPivotConfig `json:"config"` + } + + ViewWidgetRowPivotConfig struct { + Interval ViewWidgetRowPivotInterval + } + + ViewWidgetRowPivotInterval struct { + Type string `json:"type"` + // Scaling interface{} `json:"scalling" + } + + ViewWidgetPosition struct { + Width interface{} `json:"width"` // int or "Infinity" + Col int `json:"col"` + Row int `json:"row"` + Height int `json:"height"` + } + + DashboardState struct { + // Widgets interface{} `json:"widgets"` + // Positions interface{} `json:"positions"` + } + + // Views represents Get View API's response body. + Views struct { + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Count int `json:"count"` + Views []View `json:"views"` + } +) + +func (widget ViewWidget) Type() string { + return widget.Config.Type() +} + +func (widget AggregationViewWidgetConfig) Type() string { + return "aggregation" +} + +func (widget MessagesViewWidgetConfig) Type() string { + return "messages" +} + +// UnmarshalJSON unmarshals JSON into an alert condition. +func (widget *ViewWidget) UnmarshalJSON(b []byte) error { + errMsg := "failed to unmarshal JSON to view widget" + if widget == nil { + return fmt.Errorf("%s: view widget is nil", errMsg) + } + type alias ViewWidget + a := struct { + Type string `json:"type"` + Config json.RawMessage `json:"config"` + *alias + }{ + alias: (*alias)(widget), + } + if err := json.Unmarshal(b, &a); err != nil { + return errors.Wrap(err, errMsg) + } + switch a.Type { + case "aggregation": + p := AggregationViewWidgetConfig{} + if err := json.Unmarshal(a.Config, &p); err != nil { + return errors.Wrap(err, errMsg) + } + widget.Config = p + return nil + case "messages": + p := MessagesViewWidgetConfig{} + if err := json.Unmarshal(a.Config, &p); err != nil { + return errors.Wrap(err, errMsg) + } + widget.Config = p + return nil + } + // TODO + return nil +} + +// UnmarshalJSON unmarshals JSON into an alert condition. +func (position *ViewWidgetPosition) UnmarshalJSON(b []byte) error { + errMsg := "failed to unmarshal JSON to view widget position" + if position == nil { + return fmt.Errorf("%s: view widget position is nil", errMsg) + } + type alias ViewWidgetPosition + + a := struct { + *alias + Width string `json:"width"` + }{ + alias: (*alias)(position), + } + if err := json.Unmarshal(b, &a); err == nil { + position.Width = a.Width + return nil + } + + c := struct { + *alias + Width json.Number `json:"width"` + }{ + alias: (*alias)(position), + } + if err := json.Unmarshal(b, &c); err != nil { + return errors.Wrap(err, errMsg) + } + if i, err := c.Width.Int64(); err == nil { + position.Width = int(i) + return nil + } + if f, err := c.Width.Float64(); err == nil { + position.Width = f + return nil + } + // TODO + return nil +}