Correlations: Add DeleteCorrelation HTTP API (#51801)

* Correlations: add DeleteCorrelation HTTP API

* fix error message copy

* add readonly check

* add source_uid in delete condition

* make path singular

* Revert "make path singular"

This reverts commit d15be89578e202e5cb64a3e964ee09521b72d87c.

* add tests

* fix lint errors

* fix lint errors

* change casing

* update spec

* Remove transaction

* change casing in param name in docs
This commit is contained in:
Giordano Ricci 2022-07-27 09:07:58 +01:00 committed by GitHub
parent 52989c2144
commit 9a06b00e92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 505 additions and 14 deletions

View File

@ -68,3 +68,36 @@ Status codes:
- **403** Forbidden, source data source is read-only - **403** Forbidden, source data source is read-only
- **404** Not found, either source or target data source could not be found - **404** Not found, either source or target data source could not be found
- **500** Internal error - **500** Internal error
## Delete correlations
`DELETE /api/datasources/uid/:sourceUID/correlations/:correlationUID`
Deletes a correlation.
**Example request:**
```http
DELETE /api/datasources/uid/uyBf2637k/correlations/J6gn7d31L HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example response:**
```http
HTTP/1.1 200
Content-Type: application/json
{
"message": "Correlation deleted"
}
```
Status codes:
- **200** OK
- **401** Unauthorized
- **403** Forbidden, data source is read-only
- **404** Correlation not found
- **500** Internal error

View File

@ -31,3 +31,30 @@ type CreateCorrelationResponse struct {
// in: body // in: body
Body correlations.CreateCorrelationResponse `json:"body"` Body correlations.CreateCorrelationResponse `json:"body"`
} }
// swagger:route DELETE /datasources/uid/{uid}/correlations/{correlationUID} correlations deleteCorrelation
//
// Delete a correlation.
//
// Responses:
// 200: deleteCorrelationResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:parameters deleteCorrelation
type DeleteCorrelationParams struct {
// in:path
// required:true
DatasourceUID string `json:"uid"`
// in:path
// required:true
CorrelationUID string `json:"correlationUID"`
}
//swagger:response deleteCorrelationResponse
type DeleteCorrelationResponse struct {
// in: body
Body correlations.DeleteCorrelationResponse `json:"body"`
}

View File

@ -20,6 +20,7 @@ func (s *CorrelationsService) registerAPIEndpoints() {
s.RouteRegister.Group("/api/datasources/uid/:uid/correlations", func(entities routing.RouteRegister) { s.RouteRegister.Group("/api/datasources/uid/:uid/correlations", func(entities routing.RouteRegister) {
entities.Post("/", middleware.ReqSignedIn, authorize(ac.ReqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(s.createHandler)) entities.Post("/", middleware.ReqSignedIn, authorize(ac.ReqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(s.createHandler))
entities.Delete("/:correlationUID", middleware.ReqSignedIn, authorize(ac.ReqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(s.deleteHandler))
}) })
} }
@ -47,3 +48,31 @@ func (s *CorrelationsService) createHandler(c *models.ReqContext) response.Respo
return response.JSON(http.StatusOK, CreateCorrelationResponse{Result: correlation, Message: "Correlation created"}) return response.JSON(http.StatusOK, CreateCorrelationResponse{Result: correlation, Message: "Correlation created"})
} }
// deleteHandler handles DELETE /datasources/uid/:uid/correlations/:correlationUID
func (s *CorrelationsService) deleteHandler(c *models.ReqContext) response.Response {
cmd := DeleteCorrelationCommand{
UID: web.Params(c.Req)[":correlationUID"],
SourceUID: web.Params(c.Req)[":uid"],
OrgId: c.OrgId,
}
err := s.DeleteCorrelation(c.Req.Context(), cmd)
if err != nil {
if errors.Is(err, ErrSourceDataSourceDoesNotExists) {
return response.Error(http.StatusNotFound, "Data source not found", err)
}
if errors.Is(err, ErrCorrelationNotFound) {
return response.Error(http.StatusNotFound, "Correlation not found", err)
}
if errors.Is(err, ErrSourceDataSourceReadOnly) {
return response.Error(http.StatusForbidden, "Data source is read only", err)
}
return response.Error(http.StatusInternalServerError, "Failed to delete correlation", err)
}
return response.JSON(http.StatusOK, DeleteCorrelationResponse{Message: "Correlation deleted"})
}

View File

@ -31,6 +31,7 @@ func ProvideService(sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegi
type Service interface { type Service interface {
CreateCorrelation(ctx context.Context, cmd CreateCorrelationCommand) (Correlation, error) CreateCorrelation(ctx context.Context, cmd CreateCorrelationCommand) (Correlation, error)
DeleteCorrelation(ctx context.Context, cmd DeleteCorrelationCommand) error
DeleteCorrelationsBySourceUID(ctx context.Context, cmd DeleteCorrelationsBySourceUIDCommand) error DeleteCorrelationsBySourceUID(ctx context.Context, cmd DeleteCorrelationsBySourceUIDCommand) error
DeleteCorrelationsByTargetUID(ctx context.Context, cmd DeleteCorrelationsByTargetUIDCommand) error DeleteCorrelationsByTargetUID(ctx context.Context, cmd DeleteCorrelationsByTargetUIDCommand) error
} }
@ -47,6 +48,10 @@ func (s CorrelationsService) CreateCorrelation(ctx context.Context, cmd CreateCo
return s.createCorrelation(ctx, cmd) return s.createCorrelation(ctx, cmd)
} }
func (s CorrelationsService) DeleteCorrelation(ctx context.Context, cmd DeleteCorrelationCommand) error {
return s.deleteCorrelation(ctx, cmd)
}
func (s CorrelationsService) DeleteCorrelationsBySourceUID(ctx context.Context, cmd DeleteCorrelationsBySourceUIDCommand) error { func (s CorrelationsService) DeleteCorrelationsBySourceUID(ctx context.Context, cmd DeleteCorrelationsBySourceUIDCommand) error {
return s.deleteCorrelationsBySourceUID(ctx, cmd) return s.deleteCorrelationsBySourceUID(ctx, cmd)
} }

View File

@ -55,6 +55,28 @@ func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCo
return correlation, nil return correlation, nil
} }
func (s CorrelationsService) deleteCorrelation(ctx context.Context, cmd DeleteCorrelationCommand) error {
return s.SQLStore.WithDbSession(ctx, func(session *sqlstore.DBSession) error {
query := &datasources.GetDataSourceQuery{
OrgId: cmd.OrgId,
Uid: cmd.SourceUID,
}
if err := s.DataSourceService.GetDataSource(ctx, query); err != nil {
return ErrSourceDataSourceDoesNotExists
}
if query.Result.ReadOnly {
return ErrSourceDataSourceReadOnly
}
deletedCount, err := session.Delete(&Correlation{UID: cmd.UID, SourceUID: cmd.SourceUID})
if deletedCount == 0 {
return ErrCorrelationNotFound
}
return err
})
}
func (s CorrelationsService) deleteCorrelationsBySourceUID(ctx context.Context, cmd DeleteCorrelationsBySourceUIDCommand) error { func (s CorrelationsService) deleteCorrelationsBySourceUID(ctx context.Context, cmd DeleteCorrelationsBySourceUIDCommand) error {
return s.SQLStore.WithDbSession(ctx, func(session *sqlstore.DBSession) error { return s.SQLStore.WithDbSession(ctx, func(session *sqlstore.DBSession) error {
_, err := session.Delete(&Correlation{SourceUID: cmd.SourceUID}) _, err := session.Delete(&Correlation{SourceUID: cmd.SourceUID})

View File

@ -10,6 +10,7 @@ var (
ErrTargetDataSourceDoesNotExists = errors.New("target data source does not exist") ErrTargetDataSourceDoesNotExists = errors.New("target data source does not exist")
ErrCorrelationFailedGenerateUniqueUid = errors.New("failed to generate unique correlation UID") ErrCorrelationFailedGenerateUniqueUid = errors.New("failed to generate unique correlation UID")
ErrCorrelationIdentifierNotSet = errors.New("source identifier and org id are needed to be able to edit correlations") ErrCorrelationIdentifierNotSet = errors.New("source identifier and org id are needed to be able to edit correlations")
ErrCorrelationNotFound = errors.New("correlation not found")
) )
// Correlation is the model for correlations definitions // Correlation is the model for correlations definitions
@ -57,6 +58,20 @@ type CreateCorrelationCommand struct {
Description string `json:"description"` Description string `json:"description"`
} }
// swagger:model
type DeleteCorrelationResponse struct {
// example: Correlation deleted
Message string `json:"message"`
}
// DeleteCorrelationCommand is the command for deleting a correlation
type DeleteCorrelationCommand struct {
// UID of the correlation to be deleted.
UID string
SourceUID string
OrgId int64
}
type DeleteCorrelationsBySourceUIDCommand struct { type DeleteCorrelationsBySourceUIDCommand struct {
SourceUID string SourceUID string
} }

View File

@ -8,12 +8,18 @@ import (
"testing" "testing"
"github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/server"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type errorResponseBody struct {
Message string `json:"message"`
Error string `json:"error"`
}
type TestContext struct { type TestContext struct {
env server.TestEnv env server.TestEnv
t *testing.T t *testing.T
@ -46,18 +52,10 @@ type PostParams struct {
func (c TestContext) Post(params PostParams) *http.Response { func (c TestContext) Post(params PostParams) *http.Response {
c.t.Helper() c.t.Helper()
buf := bytes.NewReader([]byte(params.body)) buf := bytes.NewReader([]byte(params.body))
baseUrl := fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr())
if params.user.username != "" && params.user.password != "" {
baseUrl = fmt.Sprintf("http://%s:%s@%s", params.user.username, params.user.password, c.env.Server.HTTPServer.Listener.Addr())
}
// nolint:gosec // nolint:gosec
resp, err := http.Post( resp, err := http.Post(
fmt.Sprintf( c.getURL(params.url, params.user),
"%s%s",
baseUrl,
params.url,
),
"application/json", "application/json",
buf, buf,
) )
@ -66,6 +64,37 @@ func (c TestContext) Post(params PostParams) *http.Response {
return resp return resp
} }
type DeleteParams struct {
url string
user User
}
func (c TestContext) Delete(params DeleteParams) *http.Response {
c.t.Helper()
req, err := http.NewRequest("DELETE", c.getURL(params.url, params.user), nil)
require.NoError(c.t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(c.t, err)
return resp
}
func (c TestContext) getURL(url string, user User) string {
c.t.Helper()
baseUrl := fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr())
if user.username != "" && user.password != "" {
baseUrl = fmt.Sprintf("http://%s:%s@%s", user.username, user.password, c.env.Server.HTTPServer.Listener.Addr())
}
return fmt.Sprintf(
"%s%s",
baseUrl,
url,
)
}
func (c TestContext) createUser(cmd user.CreateUserCommand) { func (c TestContext) createUser(cmd user.CreateUserCommand) {
c.t.Helper() c.t.Helper()
@ -82,3 +111,11 @@ func (c TestContext) createDs(cmd *datasources.AddDataSourceCommand) {
err := c.env.SQLStore.AddDataSource(context.Background(), cmd) err := c.env.SQLStore.AddDataSource(context.Background(), cmd)
require.NoError(c.t, err) require.NoError(c.t, err)
} }
func (c TestContext) createCorrelation(cmd correlations.CreateCorrelationCommand) correlations.Correlation {
c.t.Helper()
correlation, err := c.env.Server.HTTPServer.CorrelationsService.CreateCorrelation(context.Background(), cmd)
require.NoError(c.t, err)
return correlation
}

View File

@ -14,11 +14,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type errorResponseBody struct {
Message string `json:"message"`
Error string `json:"error"`
}
func TestIntegrationCreateCorrelation(t *testing.T) { func TestIntegrationCreateCorrelation(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")

View File

@ -0,0 +1,222 @@
package correlations
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/require"
)
func TestIntegrationDeleteCorrelation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := NewTestEnv(t)
adminUser := User{
username: "admin",
password: "admin",
}
editorUser := User{
username: "editor",
password: "editor",
}
ctx.createUser(user.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: editorUser.password,
Login: editorUser.username,
})
ctx.createUser(user.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_ADMIN),
Password: adminUser.password,
Login: adminUser.username,
})
createDsCommand := &datasources.AddDataSourceCommand{
Name: "read-only",
Type: "loki",
ReadOnly: true,
OrgId: 1,
}
ctx.createDs(createDsCommand)
readOnlyDS := createDsCommand.Result.Uid
createDsCommand = &datasources.AddDataSourceCommand{
Name: "writable",
Type: "loki",
OrgId: 1,
}
ctx.createDs(createDsCommand)
writableDs := createDsCommand.Result.Uid
writableDsOrgId := createDsCommand.Result.OrgId
t.Run("Unauthenticated users shouldn't be able to delete correlations", func(t *testing.T) {
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", "some-ds-uid", "some-correlation-uid"),
})
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response errorResponseBody
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Equal(t, "Unauthorized", response.Message)
require.NoError(t, res.Body.Close())
})
t.Run("non org admin shouldn't be able to delete correlations", func(t *testing.T) {
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", "some-ds-uid", "some-correlation-uid"),
user: editorUser,
})
require.Equal(t, http.StatusForbidden, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response errorResponseBody
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Contains(t, response.Message, "Permissions needed: datasources:write")
require.NoError(t, res.Body.Close())
})
t.Run("inexistent source data source should result in a 404", func(t *testing.T) {
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", "nonexistent-ds-uid", "some-correlation-uid"),
user: adminUser,
})
require.Equal(t, http.StatusNotFound, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response errorResponseBody
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Equal(t, "Data source not found", response.Message)
require.Equal(t, correlations.ErrSourceDataSourceDoesNotExists.Error(), response.Error)
require.NoError(t, res.Body.Close())
})
t.Run("inexistent correlation should result in a 404", func(t *testing.T) {
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", writableDs, "nonexistent-correlation-uid"),
user: adminUser,
})
require.Equal(t, http.StatusNotFound, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response errorResponseBody
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Equal(t, "Correlation not found", response.Message)
require.Equal(t, correlations.ErrCorrelationNotFound.Error(), response.Error)
require.NoError(t, res.Body.Close())
})
t.Run("deleting a correlation originating from a read-only data source should result in a 403", func(t *testing.T) {
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", readOnlyDS, "nonexistent-correlation-uid"),
user: adminUser,
})
require.Equal(t, http.StatusForbidden, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response errorResponseBody
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Equal(t, "Data source is read only", response.Message)
require.Equal(t, correlations.ErrSourceDataSourceReadOnly.Error(), response.Error)
require.NoError(t, res.Body.Close())
})
t.Run("deleting a correlation pointing to a read-only data source should work", func(t *testing.T) {
correlation := ctx.createCorrelation(correlations.CreateCorrelationCommand{
SourceUID: writableDs,
TargetUID: writableDs,
OrgId: writableDsOrgId,
})
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", correlation.SourceUID, correlation.UID),
user: adminUser,
})
require.Equal(t, http.StatusOK, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response correlations.CreateCorrelationResponse
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Equal(t, "Correlation deleted", response.Message)
require.NoError(t, res.Body.Close())
// trying to delete the same correlation a second time should result in a 404
res = ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", correlation.SourceUID, correlation.UID),
user: adminUser,
})
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("should correctly delete a correlation", func(t *testing.T) {
correlation := ctx.createCorrelation(correlations.CreateCorrelationCommand{
SourceUID: writableDs,
TargetUID: readOnlyDS,
OrgId: writableDsOrgId,
})
res := ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", correlation.SourceUID, correlation.UID),
user: adminUser,
})
require.Equal(t, http.StatusOK, res.StatusCode)
responseBody, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
var response correlations.CreateCorrelationResponse
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Equal(t, "Correlation deleted", response.Message)
require.NoError(t, res.Body.Close())
// trying to delete the same correlation a second time should result in a 404
res = ctx.Delete(DeleteParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", correlation.SourceUID, correlation.UID),
user: adminUser,
})
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
}

View File

@ -4286,6 +4286,44 @@
} }
} }
}, },
"/datasources/uid/{uid}/correlations/{correlationUID}": {
"delete": {
"tags": ["correlations"],
"summary": "Delete a correlation.",
"operationId": "deleteCorrelation",
"parameters": [
{
"type": "string",
"name": "uid",
"in": "path",
"required": true
},
{
"type": "string",
"name": "correlationUID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/deleteCorrelationResponse"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/datasources/uid/{uid}/health": { "/datasources/uid/{uid}/health": {
"get": { "get": {
"tags": ["datasources"], "tags": ["datasources"],
@ -11588,6 +11626,15 @@
} }
} }
}, },
"DeleteCorrelationResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Correlation deleted"
}
}
},
"DeleteTokenCommand": { "DeleteTokenCommand": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -17859,6 +17906,12 @@
} }
} }
}, },
"deleteCorrelationResponse": {
"description": "(empty)",
"schema": {
"$ref": "#/definitions/DeleteCorrelationResponse"
}
},
"deleteDashboardResponse": { "deleteDashboardResponse": {
"description": "(empty)", "description": "(empty)",
"schema": { "schema": {

View File

@ -3705,6 +3705,44 @@
} }
} }
}, },
"/datasources/uid/{uid}/correlations/{correlationUID}": {
"delete": {
"tags": ["correlations"],
"summary": "Delete a correlation.",
"operationId": "deleteCorrelation",
"parameters": [
{
"type": "string",
"name": "uid",
"in": "path",
"required": true
},
{
"type": "string",
"name": "correlationUID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/deleteCorrelationResponse"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/datasources/uid/{uid}/health": { "/datasources/uid/{uid}/health": {
"get": { "get": {
"tags": ["datasources"], "tags": ["datasources"],
@ -10601,6 +10639,15 @@
"type": "string", "type": "string",
"title": "DataTopic is used to identify which topic the frame should be assigned to." "title": "DataTopic is used to identify which topic the frame should be assigned to."
}, },
"DeleteCorrelationResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Correlation deleted"
}
}
},
"DeleteTokenCommand": { "DeleteTokenCommand": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -14079,6 +14126,12 @@
} }
} }
}, },
"deleteCorrelationResponse": {
"description": "",
"schema": {
"$ref": "#/definitions/DeleteCorrelationResponse"
}
},
"deleteDashboardResponse": { "deleteDashboardResponse": {
"description": "", "description": "",
"schema": { "schema": {