Expose folder UID in dashboards API response (#33991)

* expose folder UID in dashboards API response, import dashboards into folders by folder UID

* handle bad folder UID as 400 error

* 12591:Add tests for request with folderUid

* Use more descriptive error status for missing folders

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>

* return 400 when folder id is missing

* put error checking in the right place this time

* mention folderUid in the docs

* Clarify usage of folderUid and folderId when both present

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>

* Capitalise UID

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* mention folder UID in the metadata for a GET response

Co-authored-by: Ida Furjesova <ida.furjesova@grafana.com>
Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
This commit is contained in:
Serge Zaitsev 2021-05-26 16:20:13 +02:00 committed by GitHub
parent 9016d20c4c
commit ef0fab9aa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 125 additions and 5 deletions

View File

@ -44,6 +44,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"refresh": "25s"
},
"folderId": 0,
"folderUid": "l3KqBxCMz",
"message": "Made changes to xyz",
"overwrite": false
}
@ -55,6 +56,7 @@ JSON Body schema:
- **dashboard.id** id = null to create a new dashboard.
- **dashboard.uid** Optional unique identifier when creating a dashboard. uid = null will generate a new uid.
- **folderId** The id of the folder to save the dashboard in.
- **folderUid** The UID of the folder to save the dashboard in. Overrides the `folderId`.
- **overwrite** Set to true if you want to overwrite existing dashboard with newer version, same dashboard title in folder or same dashboard uid.
- **message** - Set a commit message for the version history.
- **refresh** - Set the dashboard refresh interval. If this is lower than [the minimum refresh interval]({{< relref "../administration/configuration.md#min_refresh_interval">}}), then Grafana will ignore it and will enforce the minimum refresh interval.
@ -268,7 +270,7 @@ In case of title already exists the `status` property will be `name-exists`.
`GET /api/dashboards/uid/:uid`
Will return the dashboard given the dashboard unique identifier (uid).
Will return the dashboard given the dashboard unique identifier (uid). Information about the unique identifier of a folder containing the requested dashboard might be found in the metadata.
**Example Request**:
@ -298,6 +300,8 @@ Content-Type: application/json
"meta": {
"isStarred": false,
"url": "/d/cIBgcSjkk/production-overview",
"folderId": 2,
"folderUid": "l3KqBxCMz",
"slug": "production-overview" //deprecated in Grafana v5.0
}
}

View File

@ -137,8 +137,12 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
if dash.FolderId > 0 {
query := models.GetDashboardQuery{Id: dash.FolderId, OrgId: c.OrgId}
if err := bus.Dispatch(&query); err != nil {
if errors.Is(err, models.ErrFolderNotFound) {
return response.Error(404, "Folder not found", err)
}
return response.Error(500, "Dashboard folder could not be read", err)
}
meta.FolderUid = query.Result.Uid
meta.FolderTitle = query.Result.Title
meta.FolderUrl = query.Result.GetUrl()
}
@ -275,8 +279,27 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext, cmd models.SaveDashboa
var err error
cmd.OrgId = c.OrgId
cmd.UserId = c.UserId
trimDefaults := c.QueryBoolWithDefault("trimdefaults", false)
if trimDefaults && !hs.LoadSchemaService.IsDisabled() {
cmd.Dashboard, err = hs.LoadSchemaService.DashboardApplyDefaults(cmd.Dashboard)
if err != nil {
return response.Error(500, "Error while applying default value to the dashboard json", err)
}
}
if cmd.FolderUid != "" {
folders := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
folder, err := folders.GetFolderByUID(cmd.FolderUid)
if err != nil {
if errors.Is(err, models.ErrFolderNotFound) {
return response.Error(400, "Folder not found", err)
}
return response.Error(500, "Error while checking folder ID", err)
}
cmd.FolderId = folder.Id
}
dash := cmd.GetDashboardModel()
newDashboard := dash.Id == 0 && dash.Uid == ""
newDashboard := dash.Id == 0
if newDashboard {
limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
if err != nil {

View File

@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"path/filepath"
@ -799,7 +800,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
},
}
postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, cmd, func(sc *scenarioContext) {
postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, nil, cmd, func(sc *scenarioContext) {
callPostDashboardShouldReturnSuccess(sc)
dto := mock.SavedDashboards[0]
@ -819,6 +820,91 @@ func TestDashboardAPIEndpoint(t *testing.T) {
})
})
t.Run("Given a correct request for creating a dashboard with folder uid", func(t *testing.T) {
const folderUid string = "folderUID"
const dashID int64 = 2
cmd := models.SaveDashboardCommand{
OrgId: 1,
UserId: 5,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"title": "Dash",
}),
Overwrite: true,
FolderUid: folderUid,
IsFolder: false,
Message: "msg",
}
mock := &dashboards.FakeDashboardService{
SaveDashboardResult: &models.Dashboard{
Id: dashID,
Uid: "uid",
Title: "Dash",
Slug: "dash",
Version: 2,
},
}
mockFolder := &fakeFolderService{
GetFolderByUIDResult: &models.Folder{Id: 1, Uid: "folderUID", Title: "Folder"},
}
postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, mockFolder, cmd, func(sc *scenarioContext) {
callPostDashboardShouldReturnSuccess(sc)
dto := mock.SavedDashboards[0]
assert.Equal(t, cmd.OrgId, dto.OrgId)
assert.Equal(t, cmd.UserId, dto.User.UserId)
assert.Equal(t, "Dash", dto.Dashboard.Title)
assert.True(t, dto.Overwrite)
assert.Equal(t, "msg", dto.Message)
result := sc.ToJSON()
assert.Equal(t, "success", result.Get("status").MustString())
assert.Equal(t, dashID, result.Get("id").MustInt64())
assert.Equal(t, "uid", result.Get("uid").MustString())
assert.Equal(t, "dash", result.Get("slug").MustString())
assert.Equal(t, "/d/uid/dash", result.Get("url").MustString())
})
})
t.Run("Given a request with incorrect folder uid for creating a dashboard with", func(t *testing.T) {
const folderUid string = "folderUID"
const dashID int64 = 2
cmd := models.SaveDashboardCommand{
OrgId: 1,
UserId: 5,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"title": "Dash",
}),
Overwrite: true,
FolderUid: folderUid,
IsFolder: false,
Message: "msg",
}
mock := &dashboards.FakeDashboardService{
SaveDashboardResult: &models.Dashboard{
Id: dashID,
Uid: "uid",
Title: "Dash",
Slug: "dash",
Version: 2,
},
}
mockFolder := &fakeFolderService{
GetFolderByUIDError: errors.New("Error while searching Folder ID"),
}
postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, mockFolder, cmd, func(sc *scenarioContext) {
callPostDashboard(sc)
assert.Equal(t, 500, sc.resp.Code)
})
})
// This tests that invalid requests returns expected error responses
t.Run("Given incorrect requests for creating a dashboard", func(t *testing.T) {
testCases := []struct {
@ -858,7 +944,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
}
postDashboardScenario(t, fmt.Sprintf("Expect '%s' error when calling POST on", tc.SaveError.Error()),
"/api/dashboards", "/api/dashboards", mock, cmd, func(sc *scenarioContext) {
"/api/dashboards", "/api/dashboards", mock, nil, cmd, func(sc *scenarioContext) {
callPostDashboard(sc)
assert.Equal(t, tc.ExpectedStatusCode, sc.resp.Code)
})
@ -1207,7 +1293,8 @@ func callPostDashboardShouldReturnSuccess(sc *scenarioContext) {
}
func postDashboardScenario(t *testing.T, desc string, url string, routePattern string,
mock *dashboards.FakeDashboardService, cmd models.SaveDashboardCommand, fn scenarioFunc) {
mock *dashboards.FakeDashboardService, mockFolder *fakeFolderService, cmd models.SaveDashboardCommand,
fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
t.Cleanup(bus.ClearBusHandlers)
@ -1235,14 +1322,17 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
origNewDashboardService := dashboards.NewService
origProvisioningService := dashboards.NewProvisioningService
origNewFolderService := dashboards.NewFolderService
t.Cleanup(func() {
dashboards.NewService = origNewDashboardService
dashboards.NewProvisioningService = origProvisioningService
dashboards.NewFolderService = origNewFolderService
})
dashboards.MockDashboardService(mock)
dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService {
return mockDashboardProvisioningService{}
}
mockFolderService(mockFolder)
sc.m.Post(routePattern, sc.defaultHandler)

View File

@ -26,6 +26,7 @@ type DashboardMeta struct {
HasAcl bool `json:"hasAcl"`
IsFolder bool `json:"isFolder"`
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
FolderTitle string `json:"folderTitle"`
FolderUrl string `json:"folderUrl"`
Provisioned bool `json:"provisioned"`

View File

@ -65,6 +65,7 @@ type ImportDashboardCommand struct {
Dashboard *simplejson.Json `json:"dashboard"`
Inputs []plugins.ImportDashboardInput `json:"inputs"`
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
}
type InstallPluginCommand struct {

View File

@ -339,6 +339,7 @@ type SaveDashboardCommand struct {
RestoredFrom int `json:"-"`
PluginId string `json:"-"`
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
IsFolder bool `json:"isFolder"`
UpdatedAt time.Time