Ver código fonte

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.
Stephen SORRIAUX 6 anos atrás
pai
commit
3866839b19

+ 3 - 0
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
 ```

+ 29 - 4
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())

+ 77 - 0
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)
+			})
+
+		})
+
+	})
+}

+ 13 - 2
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;

+ 3 - 0
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 {

+ 13 - 0
public/app/plugins/datasource/influxdb/partials/config.html

@@ -26,6 +26,19 @@
       />
 		</div>
 	</div>
+
+	<div class="gf-form">
+		<label class="gf-form-label width-8">HTTP Method</label>
+		<div class="gf-form-select-wrapper width-8 gf-form-select-wrapper--has-help-icon">
+			<select class="gf-form-input" ng-model="ctrl.current.jsonData.httpMode" ng-options="f.value as f.name for f in ctrl.httpMode"></select>
+      		<info-popover mode="right-absolute">
+        		You can use either <code>GET</code> or <code>POST</code> HTTP method to query your InfluxDB database. The <code>POST</code>
+				method allows you to perform heavy requests (with a lots of <code>WHERE</code> clause) while the <code>GET</code> method
+				will restrict you and return an error if the query is too large.
+      		</info-popover>
+		</div>
+	</div>
+
 </div>
 
 

+ 68 - 2
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');
+    });
   });
 });