Skip to content

Commit

Permalink
support for PATCH/PUT/DELETE HTTP methods
Browse files Browse the repository at this point in the history
  • Loading branch information
yesoreyeram committed Jan 16, 2025
1 parent 9e9d208 commit c51421c
Show file tree
Hide file tree
Showing 30 changed files with 259 additions and 99 deletions.
5 changes: 5 additions & 0 deletions .changeset/gold-shoes-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grafana-infinity-datasource': minor
---

Support for additional HTTP methods (`PATCH`, `PUT` and `DELETE`) via data source config `allowNonGetPostMethods`
12 changes: 11 additions & 1 deletion docs/sources/setup/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ After the plugin is installed, you need to create an instance of the data source

This data source can work out of the box without any additional configuration. If you need the URL to be authenticated or pass additional headers/query/tls/timeout settings, configure the corresponding section.

- Configuration will be applied to all the queries. If you need different configuration for different queries, create separate instances of the data source.
- Configuration will be applied to all the queries. If you need different configuration for different queries, create separate instances of the data source.

- If you configure the URL in the settings, the same will be prefixed along with all your queries.

Expand All @@ -58,3 +58,13 @@ If you want your data source to connect via proxy, set the environment appropria
If you want to setup specific proxy URL for the datasource, you can configure in the datasource config network section.

> Proxy URL specification in data source config is available from v2.2.0
## Allowing non HTTP GET / POST methods

By default infinity only allow GET/POST HTTP methods to reduce the risk of destructive/accidental payloads. But through configuration, you can allow other methods (`PATCH`,`POST` and `DELETE`) for any unconventional use cases. If you need to make use of this feature, Enable the `Allow non GET / POST HTTP verbs` setting under URL section of the datasource config

> This feature is only available from infinity plugin version v3.0.0
{{< admonition type="warning" >}}
Infinity doesn't validate any permissions against the underlying API. Enable this setting with caution as this can potentially perform any destructive action in the underlying API.
{{< /admonition >}}
1 change: 1 addition & 0 deletions docs/sources/setup/provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ If you need an advanced version of the datasource, use the following format:
tlsAuthWithCACert: <<true or false>> -- false by default
serverName: <<server name that matches in certificate for tlsAuthWithCACert>>
timeoutInSeconds: <<60>> -- or whatever the timeout you want set. If not set defaults to 60.
allowNonGetPostMethods: false
secureJsonData:
basicAuthPassword: <<YOUR PASSWORD. Example -- MY_Github_PAT_Token>>
tlsCACert: <<Your TLS cert>>
Expand Down
76 changes: 39 additions & 37 deletions pkg/infinity/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func (client *Client) GetResults(ctx context.Context, query models.Query, reques
return string(bodyBytes), http.StatusOK, 0, nil
}
switch strings.ToUpper(query.URLOptions.Method) {
case http.MethodPost:
case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete:
body := GetQueryBody(ctx, query)
return client.req(ctx, query.URL, body, client.Settings, query, requestHeaders)
default:
Expand Down Expand Up @@ -238,44 +238,46 @@ func CanAllowURL(url string, allowedHosts []string) bool {
func GetQueryBody(ctx context.Context, query models.Query) io.Reader {
logger := backend.Logger.FromContext(ctx)
var body io.Reader
if strings.EqualFold(query.URLOptions.Method, http.MethodPost) {
switch query.URLOptions.BodyType {
case "raw":
body = strings.NewReader(query.URLOptions.Body)
case "form-data":
payload := &bytes.Buffer{}
writer := multipart.NewWriter(payload)
for _, f := range query.URLOptions.BodyForm {
_ = writer.WriteField(f.Key, f.Value)
}
if err := writer.Close(); err != nil {
logger.Error("error closing the query body reader")
return nil
}
body = payload
case "x-www-form-urlencoded":
form := url.Values{}
for _, f := range query.URLOptions.BodyForm {
form.Set(f.Key, f.Value)
}
body = strings.NewReader(form.Encode())
case "graphql":
var variables map[string]interface{}
if query.URLOptions.BodyGraphQLVariables != "" {
err := json.Unmarshal([]byte(query.URLOptions.BodyGraphQLVariables), &variables)
if err != nil {
logger.Error("Error parsing graphql variable json", err)
}
}
jsonData := map[string]interface{}{
"query": query.URLOptions.BodyGraphQLQuery,
"variables": variables,
// according to https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods, GET method should not contain request body
if strings.EqualFold(query.URLOptions.Method, http.MethodGet) || strings.TrimSpace(query.URLOptions.Method) == "" {
return body
}
switch query.URLOptions.BodyType {
case "raw":
body = strings.NewReader(query.URLOptions.Body)
case "form-data":
payload := &bytes.Buffer{}
writer := multipart.NewWriter(payload)
for _, f := range query.URLOptions.BodyForm {
_ = writer.WriteField(f.Key, f.Value)
}
if err := writer.Close(); err != nil {
logger.Error("error closing the query body reader")
return nil
}
body = payload
case "x-www-form-urlencoded":
form := url.Values{}
for _, f := range query.URLOptions.BodyForm {
form.Set(f.Key, f.Value)
}
body = strings.NewReader(form.Encode())
case "graphql":
var variables map[string]interface{}
if query.URLOptions.BodyGraphQLVariables != "" {
err := json.Unmarshal([]byte(query.URLOptions.BodyGraphQLVariables), &variables)
if err != nil {
logger.Error("Error parsing graphql variable json", err)
}
jsonValue, _ := json.Marshal(jsonData)
body = strings.NewReader(string(jsonValue))
default:
body = strings.NewReader(query.URLOptions.Body)
}
jsonData := map[string]interface{}{
"query": query.URLOptions.BodyGraphQLQuery,
"variables": variables,
}
jsonValue, _ := json.Marshal(jsonData)
body = strings.NewReader(string(jsonValue))
default:
body = strings.NewReader(query.URLOptions.Body)
}
return body
}
38 changes: 38 additions & 0 deletions pkg/infinity/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package infinity_test
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"

"github.com/grafana/grafana-infinity-datasource/pkg/infinity"
"github.com/grafana/grafana-infinity-datasource/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInfinityClient_GetResults(t *testing.T) {
Expand Down Expand Up @@ -77,6 +80,41 @@ func TestInfinityClient_GetResults(t *testing.T) {
}
}

func TestGetQueryBody(t *testing.T) {
tests := []struct {
name string
query models.Query
want io.Reader
}{
{
name: "should not include body for urls without method",
query: models.Query{URLOptions: models.URLOptions{Body: "foo"}},
},
{
name: "should not include body for GET method",
query: models.Query{URLOptions: models.URLOptions{Method: "get", Body: "foo"}},
},
{
name: "should include body for PATCH method if provided",
query: models.Query{URLOptions: models.URLOptions{Method: "patch", Body: "foo"}},
want: strings.NewReader("foo"),
},
{
name: "should not include body for DELETE method if not provided",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := infinity.GetQueryBody(context.TODO(), tt.query)
if tt.want == nil {
require.Nil(t, got)
return
}
require.NotNil(t, got)
})
}
}

func TestCanAllowURL(t *testing.T) {
tests := []struct {
name string
Expand Down
10 changes: 10 additions & 0 deletions pkg/infinity/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/grafana/grafana-infinity-datasource/pkg/models"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
"github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource"
"moul.io/http2curl"
)

Expand All @@ -20,9 +21,18 @@ func GetRequest(ctx context.Context, settings models.InfinitySettings, body io.R
if err != nil {
return nil, err
}
if (strings.EqualFold(query.URLOptions.Method, "PUT") || strings.EqualFold(query.URLOptions.Method, "PATCH") || strings.EqualFold(query.URLOptions.Method, "DELETE")) && !settings.AllowNonGetPostMethods {
return nil, errorsource.DownstreamError(models.ErrNonHTTPGetPostRestricted, false)
}
switch strings.ToUpper(query.URLOptions.Method) {
case http.MethodPost:
req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, body)
case http.MethodPatch:
req, err = http.NewRequestWithContext(ctx, http.MethodPatch, url, body)
case http.MethodPut:
req, err = http.NewRequestWithContext(ctx, http.MethodPut, url, body)
case http.MethodDelete:
req, err = http.NewRequestWithContext(ctx, http.MethodDelete, url, body)
default:
req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
}
Expand Down
65 changes: 65 additions & 0 deletions pkg/infinity/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,83 @@ package infinity_test
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"

"github.com/grafana/grafana-infinity-datasource/pkg/infinity"
"github.com/grafana/grafana-infinity-datasource/pkg/models"
"github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)

func TestGetRequest(t *testing.T) {
tests := []struct {
name string
settings models.InfinitySettings
body io.Reader
query models.Query
requestHeaders map[string]string
wantReq *http.Request
wantReqBody bool
wantErr error
}{
{
name: "should error if DELETE method requested without enabled in the settings",
query: models.Query{
URLOptions: models.URLOptions{Method: "delete"},
},
wantErr: errorsource.DownstreamError(models.ErrNonHTTPGetPostRestricted, false),
},
{
name: "should allow if DELETE method requested with enabled in the settings",
query: models.Query{URLOptions: models.URLOptions{Method: "delete"}},
settings: models.InfinitySettings{AllowNonGetPostMethods: true},
wantReq: &http.Request{URL: &url.URL{}, Method: http.MethodDelete},
},
{
name: "should not include body when not supplied in the query for non GET/POST methods",
query: models.Query{URLOptions: models.URLOptions{Method: "delete"}},
settings: models.InfinitySettings{AllowNonGetPostMethods: true},
wantReq: &http.Request{URL: &url.URL{}, Method: http.MethodDelete},
wantReqBody: false,
},
{
name: "should include body when supplied in the query for non GET/POST methods",
query: models.Query{URLOptions: models.URLOptions{Method: "patch", Body: "foo"}},
body: io.NopCloser(strings.NewReader("foo")),
settings: models.InfinitySettings{AllowNonGetPostMethods: true},
wantReq: &http.Request{URL: &url.URL{}, Method: http.MethodPatch},
wantReqBody: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotReq, err := infinity.GetRequest(context.TODO(), tt.settings, tt.body, tt.query, tt.requestHeaders, true)
if tt.wantErr != nil {
require.NotNil(t, err)
assert.Equal(t, tt.wantErr, err)
return
}
require.NotNil(t, gotReq)
assert.Equal(t, tt.wantReq.URL.String(), gotReq.URL.String())
assert.Equal(t, tt.wantReq.Method, gotReq.Method)
if !tt.wantReqBody {
require.Nil(t, gotReq.Body)
}
if tt.wantReqBody {
require.NotNil(t, gotReq.Body)
}
})
}
}

func Test_getQueryURL(t *testing.T) {
tests := []struct {
name string
Expand Down
1 change: 1 addition & 0 deletions pkg/models/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ var (
ErrUnsuccessfulHTTPResponseStatus error = errors.New("unsuccessful HTTP response")
ErrParsingResponseBodyAsJson error = errors.New("unable to parse response body as JSON")
ErrCreatingHTTPClient error = errors.New("error creating HTTP client")
ErrNonHTTPGetPostRestricted error = errors.New(`only HTTP verbs GET/POST are allowed for this data source. To make use other methods, enable the "Allow non GET / POST HTTP verbs" in the data source config URL section`)
)
9 changes: 7 additions & 2 deletions pkg/models/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"

"github.com/grafana/grafana-plugin-sdk-go/backend"
Expand Down Expand Up @@ -144,7 +145,7 @@ type URLOptionKeyValuePair struct {
}

type URLOptions struct {
Method string `json:"method"` // 'GET' | 'POST'
Method string `json:"method"` // 'GET' | 'POST' | 'PATCH' | 'PUT | 'DELETE'
Params []URLOptionKeyValuePair `json:"params"`
Headers []URLOptionKeyValuePair `json:"headers"`
Body string `json:"data"`
Expand Down Expand Up @@ -206,10 +207,14 @@ func ApplyDefaultsToQuery(ctx context.Context, query Query, settings InfinitySet
query.URL = "https://raw.githubusercontent.com/grafana/grafana-infinity-datasource/main/testdata/users.json"
}
}
if query.Source == "url" && strings.ToUpper(query.URLOptions.Method) == "POST" {
if query.Source == "url" && strings.TrimSpace(query.URLOptions.Method) == "" {
query.URLOptions.Method = http.MethodGet
}
if query.Source == "url" && (!strings.EqualFold(query.URLOptions.Method, http.MethodGet)) {
if query.URLOptions.BodyType == "" {
query.URLOptions.BodyType = "raw"
if query.Type == QueryTypeGraphQL {
query.URLOptions.Method = http.MethodPost
query.URLOptions.BodyType = "graphql"
query.URLOptions.BodyContentType = "application/json"
if query.URLOptions.BodyGraphQLQuery == "" {
Expand Down
3 changes: 3 additions & 0 deletions pkg/models/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ type InfinitySettings struct {
AzureBlobAccountKey string
UnsecuredQueryHandling UnsecuredQueryHandlingMode
PathEncodedURLsEnabled bool
AllowNonGetPostMethods bool
// ProxyOpts is used for Secure Socks Proxy configuration
ProxyOpts httpclient.Options
}
Expand Down Expand Up @@ -200,6 +201,7 @@ type InfinitySettingsJson struct {
AzureBlobAccountUrl string `json:"azureBlobAccountUrl,omitempty"`
AzureBlobAccountName string `json:"azureBlobAccountName,omitempty"`
PathEncodedURLsEnabled bool `json:"pathEncodedUrlsEnabled,omitempty"`
AllowNonGetPostMethods bool `json:"allowNonGetPostMethods,omitempty"`
// Security
AllowedHosts []string `json:"allowedHosts,omitempty"`
UnsecuredQueryHandling UnsecuredQueryHandlingMode `json:"unsecuredQueryHandling,omitempty"`
Expand Down Expand Up @@ -243,6 +245,7 @@ func LoadSettings(ctx context.Context, config backend.DataSourceInstanceSettings
settings.ProxyType = infJson.ProxyType
settings.ProxyUrl = infJson.ProxyUrl
settings.PathEncodedURLsEnabled = infJson.PathEncodedURLsEnabled
settings.AllowNonGetPostMethods = infJson.AllowNonGetPostMethods
if settings.ProxyType == "" {
settings.ProxyType = ProxyTypeEnv
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/models/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ func TestAllSettingsAgainstFrontEnd(t *testing.T) {
"allowedHosts": ["host1","host2"],
"customHealthCheckEnabled" : true,
"customHealthCheckUrl" : "https://foo-check/",
"allowNonGetPostMethods": true,
"unsecuredQueryHandling" : "deny",
"aws" : {
"authType" : "keys",
Expand Down Expand Up @@ -244,6 +245,7 @@ func TestAllSettingsAgainstFrontEnd(t *testing.T) {
CustomHealthCheckEnabled: true,
CustomHealthCheckUrl: "https://foo-check/",
UnsecuredQueryHandling: models.UnsecuredQueryHandlingDeny,
AllowNonGetPostMethods: true,
CustomHeaders: map[string]string{
"header1": "headervalue1",
},
Expand Down
4 changes: 2 additions & 2 deletions pkg/testsuite/golden/csv_backend_url_default.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// "source": "url",
// "url": "http://127.0.0.1:8080",
// "url_options": {
// "method": "",
// "method": "GET",
// "params": null,
// "headers": null,
// "data": "",
Expand Down Expand Up @@ -93,7 +93,7 @@
"source": "url",
"url": "http://127.0.0.1:8080",
"url_options": {
"method": "",
"method": "GET",
"params": null,
"headers": null,
"data": "",
Expand Down
Loading

0 comments on commit c51421c

Please sign in to comment.