From 3866839b198caaa652e0955b2c9e4884eb09fe7f Mon Sep 17 00:00:00 2001 From: Stephen SORRIAUX Date: Thu, 2 May 2019 15:30:37 +0200 Subject: [PATCH] Datasources: add support for POST HTTP verb for InfluxDB (#16690) A new parameter `queryMode` is added to the InfluxDB datasource to provide a way to use POST instead of GET when querying the database. This prevents to get any error when querying the database with a heavy request. Default configuration is kept to GET for backward compatibility. Tests and documentation have been added for this new behaviour. --- docs/sources/features/datasources/influxdb.md | 3 + pkg/tsdb/influxdb/influxdb.go | 33 +++++++- pkg/tsdb/influxdb/influxdb_test.go | 77 +++++++++++++++++++ .../plugins/datasource/influxdb/datasource.ts | 15 +++- .../app/plugins/datasource/influxdb/module.ts | 3 + .../datasource/influxdb/partials/config.html | 13 ++++ .../influxdb/specs/datasource.test.ts | 70 ++++++++++++++++- 7 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 pkg/tsdb/influxdb/influxdb_test.go diff --git a/docs/sources/features/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md index 94ae448f16d..e75fb2c5511 100644 --- a/docs/sources/features/datasources/influxdb.md +++ b/docs/sources/features/datasources/influxdb.md @@ -32,6 +32,7 @@ Name | Description *Database* | Name of your influxdb database *User* | Name of your database user *Password* | Database user's password +*HTTP mode* | How to query the database (`GET` or `POST` HTTP verb). The `POST` verb allows heavy queries that would return an error using the `GET` verb. Default is `GET`. Access mode controls how requests to the data source will be handled. Server should be the preferred way if nothing else stated. @@ -212,4 +213,6 @@ datasources: user: grafana password: grafana url: http://localhost:8086 + jsonData: + httpMode: GET ``` diff --git a/pkg/tsdb/influxdb/influxdb.go b/pkg/tsdb/influxdb/influxdb.go index d199211575a..6bff961f981 100644 --- a/pkg/tsdb/influxdb/influxdb.go +++ b/pkg/tsdb/influxdb/influxdb.go @@ -3,10 +3,12 @@ package influxdb import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" "path" + "strings" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" @@ -33,6 +35,8 @@ var ( glog log.Logger ) +var ErrInvalidHttpMode error = errors.New("'httpMode' should be either 'GET' or 'POST'") + func init() { glog = log.New("tsdb.influxdb") tsdb.RegisterTsdbQueryEndpoint("influxdb", NewInfluxDBExecutor) @@ -108,21 +112,42 @@ func (e *InfluxDBExecutor) getQuery(dsInfo *models.DataSource, queries []*tsdb.Q } func (e *InfluxDBExecutor) createRequest(dsInfo *models.DataSource, query string) (*http.Request, error) { + u, _ := url.Parse(dsInfo.Url) u.Path = path.Join(u.Path, "query") + httpMode := dsInfo.JsonData.Get("httpMode").MustString("GET") + + req, err := func() (*http.Request, error) { + switch httpMode { + case "GET": + return http.NewRequest(http.MethodGet, u.String(), nil) + case "POST": + bodyValues := url.Values{} + bodyValues.Add("q", query) + body := bodyValues.Encode() + return http.NewRequest(http.MethodPost, u.String(), strings.NewReader(body)) + default: + return nil, ErrInvalidHttpMode + } + }() - req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, err } + req.Header.Set("User-Agent", "Grafana") + params := req.URL.Query() - params.Set("q", query) params.Set("db", dsInfo.Database) params.Set("epoch", "s") - req.URL.RawQuery = params.Encode() - req.Header.Set("User-Agent", "Grafana") + if httpMode == "GET" { + params.Set("q", query) + } else if httpMode == "POST" { + req.Header.Set("Content-type", "application/x-www-form-urlencoded") + } + + req.URL.RawQuery = params.Encode() if dsInfo.BasicAuth { req.SetBasicAuth(dsInfo.BasicAuthUser, dsInfo.DecryptedBasicAuthPassword()) diff --git a/pkg/tsdb/influxdb/influxdb_test.go b/pkg/tsdb/influxdb/influxdb_test.go new file mode 100644 index 00000000000..335335477b3 --- /dev/null +++ b/pkg/tsdb/influxdb/influxdb_test.go @@ -0,0 +1,77 @@ +package influxdb + +import ( + "io/ioutil" + "net/url" + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestInfluxDB(t *testing.T) { + Convey("InfluxDB", t, func() { + datasource := &models.DataSource{ + Url: "http://awesome-influxdb:1337", + Database: "awesome-db", + JsonData: simplejson.New(), + } + query := "SELECT awesomeness FROM somewhere" + e := &InfluxDBExecutor{ + QueryParser: &InfluxdbQueryParser{}, + ResponseParser: &ResponseParser{}, + } + Convey("createRequest with GET httpMode", func() { + req, _ := e.createRequest(datasource, query) + + Convey("as default", func() { + So(req.Method, ShouldEqual, "GET") + }) + + Convey("has a 'q' GET param that equals to query", func() { + q := req.URL.Query().Get("q") + So(q, ShouldEqual, query) + }) + + Convey("has an empty body", func() { + So(req.Body, ShouldEqual, nil) + }) + + }) + + Convey("createRequest with POST httpMode", func() { + datasource.JsonData.Set("httpMode", "POST") + req, _ := e.createRequest(datasource, query) + + Convey("method should be POST", func() { + So(req.Method, ShouldEqual, "POST") + }) + + Convey("has no 'q' GET param", func() { + q := req.URL.Query().Get("q") + So(q, ShouldEqual, "") + }) + + Convey("has the request as GET param in body", func() { + body, _ := ioutil.ReadAll(req.Body) + testBodyValues := url.Values{} + testBodyValues.Add("q", query) + testBody := testBodyValues.Encode() + So(string(body[:]), ShouldEqual, testBody) + }) + + }) + + Convey("createRequest with PUT httpMode", func() { + datasource.JsonData.Set("httpMode", "PUT") + _, err := e.createRequest(datasource, query) + + Convey("should miserably fail", func() { + So(err, ShouldEqual, ErrInvalidHttpMode) + }) + + }) + + }) +} diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 494bda2dcd0..71ed01776e8 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -17,6 +17,7 @@ export default class InfluxDatasource { withCredentials: any; interval: any; responseParser: any; + httpMode: string; /** @ngInject */ constructor(instanceSettings, private $q, private backendSrv, private templateSrv) { @@ -33,6 +34,7 @@ export default class InfluxDatasource { this.withCredentials = instanceSettings.withCredentials; this.interval = (instanceSettings.jsonData || {}).timeInterval; this.responseParser = new ResponseParser(); + this.httpMode = instanceSettings.jsonData.httpMode; } query(options) { @@ -190,7 +192,7 @@ export default class InfluxDatasource { query = query.replace('$timeFilter', timeFilter); } - return this._influxRequest('GET', '/query', { q: query, epoch: 'ms' }, options); + return this._influxRequest(this.httpMode, '/query', { q: query, epoch: 'ms' }, options); } serializeParams(params) { @@ -245,7 +247,12 @@ export default class InfluxDatasource { params.db = this.database; } - if (method === 'GET') { + if (method === 'POST' && _.has(data, 'q')) { + // verb is POST and 'q' param is defined + _.extend(params, _.omit(data, ['q'])); + data = this.serializeParams(_.pick(data, ['q'])); + } else if (method === 'GET' || method === 'POST') { + // verb is GET, or POST without 'q' param _.extend(params, data); data = null; } @@ -268,6 +275,10 @@ export default class InfluxDatasource { req.headers.Authorization = this.basicAuth; } + if (method === 'POST') { + req.headers['Content-type'] = 'application/x-www-form-urlencoded'; + } + return this.backendSrv.datasourceRequest(req).then( result => { return result.data; diff --git a/public/app/plugins/datasource/influxdb/module.ts b/public/app/plugins/datasource/influxdb/module.ts index b25c15101c1..a3da6ea57d0 100644 --- a/public/app/plugins/datasource/influxdb/module.ts +++ b/public/app/plugins/datasource/influxdb/module.ts @@ -15,7 +15,10 @@ class InfluxConfigCtrl { constructor() { this.onPasswordReset = createResetHandler(this, PasswordFieldEnum.Password); this.onPasswordChange = createChangeHandler(this, PasswordFieldEnum.Password); + this.current.jsonData.httpMode = this.current.jsonData.httpMode || 'GET'; } + + httpMode = [{ name: 'GET', value: 'GET' }, { name: 'POST', value: 'POST' }]; } class InfluxAnnotationsQueryCtrl { diff --git a/public/app/plugins/datasource/influxdb/partials/config.html b/public/app/plugins/datasource/influxdb/partials/config.html index 01431639690..977b0a0fddc 100644 --- a/public/app/plugins/datasource/influxdb/partials/config.html +++ b/public/app/plugins/datasource/influxdb/partials/config.html @@ -26,6 +26,19 @@ /> + +
+ +
+ + + You can use either GET or POST HTTP method to query your InfluxDB database. The POST + method allows you to perform heavy requests (with a lots of WHERE clause) while the GET method + will restrict you and return an error if the query is too large. + +
+
+ diff --git a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts index 62049535e3e..09b438f4aaf 100644 --- a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts +++ b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts @@ -7,7 +7,7 @@ describe('InfluxDataSource', () => { backendSrv: {}, $q: $q, templateSrv: new TemplateSrvStub(), - instanceSettings: { url: 'url', name: 'influxDb', jsonData: {} }, + instanceSettings: { url: 'url', name: 'influxDb', jsonData: { httpMode: 'GET' } }, }; beforeEach(() => { @@ -23,11 +23,13 @@ describe('InfluxDataSource', () => { to: '2018-01-02T00:00:00Z', }, }; - let requestQuery; + let requestQuery, requestMethod, requestData; beforeEach(async () => { ctx.backendSrv.datasourceRequest = req => { + requestMethod = req.method; requestQuery = req.params.q; + requestData = req.data; return ctx.$q.when({ results: [ { @@ -49,5 +51,69 @@ describe('InfluxDataSource', () => { it('should replace $timefilter', () => { expect(requestQuery).toMatch('time >= 1514764800000ms and time <= 1514851200000ms'); }); + + it('should use the HTTP GET method', () => { + expect(requestMethod).toBe('GET'); + }); + + it('should not have any data in request body', () => { + expect(requestData).toBeNull(); + }); + }); +}); + +describe('InfluxDataSource in POST query mode', () => { + const ctx: any = { + backendSrv: {}, + $q: $q, + templateSrv: new TemplateSrvStub(), + instanceSettings: { url: 'url', name: 'influxDb', jsonData: { httpMode: 'POST' } }, + }; + + beforeEach(() => { + ctx.instanceSettings.url = '/api/datasources/proxy/1'; + ctx.ds = new InfluxDatasource(ctx.instanceSettings, ctx.$q, ctx.backendSrv, ctx.templateSrv); + }); + + describe('When issuing metricFindQuery', () => { + const query = 'SELECT max(value) FROM measurement'; + const queryOptions: any = {}; + let requestMethod, requestQueryParameter, queryEncoded, requestQuery; + + beforeEach(async () => { + ctx.backendSrv.datasourceRequest = req => { + requestMethod = req.method; + requestQueryParameter = req.params; + requestQuery = req.data; + return ctx.$q.when({ + results: [ + { + series: [ + { + name: 'measurement', + columns: ['max'], + values: [[1]], + }, + ], + }, + ], + }); + }; + + queryEncoded = await ctx.ds.serializeParams({ q: query }); + await ctx.ds.metricFindQuery(query, queryOptions).then(_ => {}); + }); + + it('should have the query form urlencoded', () => { + expect(requestQuery).toBe(queryEncoded); + }); + + it('should use the HTTP POST method', () => { + expect(requestMethod).toBe('POST'); + }); + + it('should not have q as a query parameter', () => { + expect(requestQueryParameter).not.toHaveProperty('q'); + }); }); });