From eb3a457c6dc95ed1ceba0d9ea48eada5d6a5fed7 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Fri, 1 Aug 2025 21:15:21 +0200 Subject: [PATCH] Alerting: Support JSON responses in the Prometheus conversion API (#109070) --- .../ngalert/api/api_convert_prometheus.go | 36 +++- .../api/api_convert_prometheus_test.go | 185 +++++++++++++++--- 2 files changed, 188 insertions(+), 33 deletions(-) diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 345569be3c8..97afcf561d1 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "mime" "net/http" "path/filepath" "strconv" @@ -155,7 +156,7 @@ func NewConvertPrometheusSrv( // RouteConvertPrometheusGetRules returns all Grafana-managed alert rules in all namespaces (folders) // that were imported from a Prometheus-compatible source. -// It responds with a YAML containing a mapping of folders to arrays of Prometheus rule groups. +// It responds with JSON or YAML containing a mapping of folders to arrays of Prometheus rule groups. func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel.ReqContext) response.Response { logger := srv.logger.FromContext(c.Req.Context()) @@ -166,7 +167,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel. if len(folders) == 0 || errors.Is(err, dashboards.ErrFolderNotFound) { // If there is no such folder or no children, return empty response // because mimirtool expects 200 OK response in this case. - return response.YAML(http.StatusOK, map[string][]apimodels.PrometheusRuleGroup{}) + return convertPrometheusResponse(c, http.StatusOK, map[string][]apimodels.PrometheusRuleGroup{}) } if err != nil { logger.Error("Failed to get folders", "error", err) @@ -193,7 +194,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel. return errorToResponse(err) } - return response.YAML(http.StatusOK, namespaces) + return convertPrometheusResponse(c, http.StatusOK, namespaces) } // RouteConvertPrometheusDeleteNamespace deletes all rule groups that were imported from a Prometheus-compatible source @@ -256,7 +257,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contex } // RouteConvertPrometheusGetNamespace returns the Grafana-managed alert rules for a specified namespace (folder). -// It responds with a YAML containing a mapping of a single folder to an array of Prometheus rule groups. +// It responds with JSON or YAML containing a mapping of a single folder to an array of Prometheus rule groups. func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response { logger := srv.logger.FromContext(c.Req.Context()) @@ -286,11 +287,11 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmo return errorToResponse(err) } - return response.YAML(http.StatusOK, ns) + return convertPrometheusResponse(c, http.StatusOK, ns) } // RouteConvertPrometheusGetRuleGroup retrieves a single rule group for a given namespace (folder) -// in Prometheus-compatible YAML format if it was imported from a Prometheus-compatible source. +// in Prometheus-compatible JSON or YAML format if it was imported from a Prometheus-compatible source. func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response { logger := srv.logger.FromContext(c.Req.Context()) @@ -332,7 +333,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmo return errorToResponse(err) } - return response.YAML(http.StatusOK, promGroup) + return convertPrometheusResponse(c, http.StatusOK, promGroup) } // RouteConvertPrometheusPostRuleGroup converts a Prometheus rule group into a Grafana rule group @@ -608,7 +609,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetAlertmanagerConfig(c * TemplateFiles: extraCfg.TemplateFiles, } - resp := response.YAML(http.StatusOK, respBody) + resp := convertPrometheusResponse(c, http.StatusOK, respBody) resp.SetHeader(configIdentifierHeader, extraCfg.Identifier) resp.SetHeader(mergeMatchersHeader, formatMergeMatchers(extraCfg.MergeMatchers)) @@ -808,3 +809,22 @@ func parseConfigIdentifierHeader(c *contextmodel.ReqContext) string { } return identifier } + +// convertPrometheusResponse returns a JSON or YAML response based on the Accept header. +// Default is YAML for backward compatibility with mimirtool. +func convertPrometheusResponse(c *contextmodel.ReqContext, status int, body interface{}) *response.NormalResponse { + acceptHeader := c.Req.Header.Get("Accept") + + for _, accept := range strings.Split(acceptHeader, ",") { + mediaType, _, err := mime.ParseMediaType(accept) + if err != nil { + continue + } + + if mediaType == "application/json" { + return response.JSON(status, body) + } + } + + return response.YAML(status, body) +} diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index ea3a9a16a13..ca7833361c2 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -597,17 +598,34 @@ func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) { GenerateRef() ruleStore.PutRule(context.Background(), ruleInOtherFolder) - getResp := srv.RouteConvertPrometheusGetRuleGroup(rc, fldr.Title, groupKey.RuleGroup) - require.Equal(t, http.StatusOK, getResp.Status()) + t.Run("YAML response", func(t *testing.T) { + getResp := srv.RouteConvertPrometheusGetRuleGroup(rc, fldr.Title, groupKey.RuleGroup) + require.Equal(t, http.StatusOK, getResp.Status()) - var respGroup apimodels.PrometheusRuleGroup - err := yaml.Unmarshal(getResp.Body(), &respGroup) - require.NoError(t, err) + var respGroup apimodels.PrometheusRuleGroup + err := yaml.Unmarshal(getResp.Body(), &respGroup) + require.NoError(t, err) - require.Equal(t, groupKey.RuleGroup, respGroup.Name) - require.Equal(t, prommodel.Duration(time.Duration(rule.IntervalSeconds)*time.Second), respGroup.Interval) - require.Len(t, respGroup.Rules, 1) - require.Equal(t, promRule.Alert, respGroup.Rules[0].Alert) + require.Equal(t, groupKey.RuleGroup, respGroup.Name) + require.Equal(t, prommodel.Duration(time.Duration(rule.IntervalSeconds)*time.Second), respGroup.Interval) + require.Len(t, respGroup.Rules, 1) + require.Equal(t, promRule.Alert, respGroup.Rules[0].Alert) + }) + + t.Run("JSON response", func(t *testing.T) { + rc.Req.Header.Set("Accept", "application/json") + getResp := srv.RouteConvertPrometheusGetRuleGroup(rc, fldr.Title, groupKey.RuleGroup) + require.Equal(t, http.StatusOK, getResp.Status()) + + var jsonGroup apimodels.PrometheusRuleGroup + err := json.Unmarshal(getResp.Body(), &jsonGroup) + require.NoError(t, err) + + require.Equal(t, groupKey.RuleGroup, jsonGroup.Name) + require.Equal(t, prommodel.Duration(time.Duration(rule.IntervalSeconds)*time.Second), jsonGroup.Interval) + require.Len(t, jsonGroup.Rules, 1) + require.Equal(t, promRule.Alert, jsonGroup.Rules[0].Alert) + }) }) } @@ -690,16 +708,32 @@ func TestRouteConvertPrometheusGetNamespace(t *testing.T) { ruleStore.PutRule(context.Background(), rule) } - response := srv.RouteConvertPrometheusGetNamespace(rc, fldr.Title) - require.Equal(t, http.StatusOK, response.Status()) + t.Run("YAML response", func(t *testing.T) { + response := srv.RouteConvertPrometheusGetNamespace(rc, fldr.Title) + require.Equal(t, http.StatusOK, response.Status()) - var respNamespaces map[string][]apimodels.PrometheusRuleGroup - err := yaml.Unmarshal(response.Body(), &respNamespaces) - require.NoError(t, err) + var respNamespaces map[string][]apimodels.PrometheusRuleGroup + err := yaml.Unmarshal(response.Body(), &respNamespaces) + require.NoError(t, err) - require.Len(t, respNamespaces, 1) - require.Contains(t, respNamespaces, fldr.Title) - require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + require.Len(t, respNamespaces, 1) + require.Contains(t, respNamespaces, fldr.Title) + require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + }) + + t.Run("JSON response", func(t *testing.T) { + rc.Req.Header.Set("Accept", "application/json") + response := srv.RouteConvertPrometheusGetNamespace(rc, fldr.Title) + require.Equal(t, http.StatusOK, response.Status()) + + var jsonNamespaces map[string][]apimodels.PrometheusRuleGroup + err := json.Unmarshal(response.Body(), &jsonNamespaces) + require.NoError(t, err) + + require.Len(t, jsonNamespaces, 1) + require.Contains(t, jsonNamespaces, fldr.Title) + require.ElementsMatch(t, jsonNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + }) }) } @@ -821,19 +855,120 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) { ruleStore.PutRule(context.Background(), rule) } - response := srv.RouteConvertPrometheusGetRules(rc) - require.Equal(t, http.StatusOK, response.Status()) + t.Run("YAML response", func(t *testing.T) { + response := srv.RouteConvertPrometheusGetRules(rc) + require.Equal(t, http.StatusOK, response.Status()) - var respNamespaces map[string][]apimodels.PrometheusRuleGroup - err := yaml.Unmarshal(response.Body(), &respNamespaces) - require.NoError(t, err) + var respNamespaces map[string][]apimodels.PrometheusRuleGroup + err := yaml.Unmarshal(response.Body(), &respNamespaces) + require.NoError(t, err) - require.Len(t, respNamespaces, 1) - require.Contains(t, respNamespaces, fldr.Title) - require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + require.Len(t, respNamespaces, 1) + require.Contains(t, respNamespaces, fldr.Title) + require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + }) + + t.Run("JSON response", func(t *testing.T) { + rc.Req.Header.Set("Accept", "application/json") + response := srv.RouteConvertPrometheusGetRules(rc) + require.Equal(t, http.StatusOK, response.Status()) + + var jsonNamespaces map[string][]apimodels.PrometheusRuleGroup + err := json.Unmarshal(response.Body(), &jsonNamespaces) + require.NoError(t, err) + + require.Len(t, jsonNamespaces, 1) + require.Contains(t, jsonNamespaces, fldr.Title) + require.ElementsMatch(t, jsonNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + }) }) } +func TestConvertPrometheusResponse(t *testing.T) { + testData := map[string][]apimodels.PrometheusRuleGroup{ + "test": { + { + Name: "test-group", + Rules: []apimodels.PrometheusRule{ + { + Alert: "TestAlert", + Expr: "up == 0", + }, + }, + }, + }, + } + + testCases := []struct { + name string + acceptHeader string + expectedType string + checkResponse func(t *testing.T, body []byte) + }{ + { + name: "by default returns YAML", + expectedType: "text/yaml", + checkResponse: func(t *testing.T, body []byte) { + require.True(t, strings.Contains(string(body), "test-group")) + require.True(t, strings.Contains(string(body), "TestAlert")) + var result map[string][]apimodels.PrometheusRuleGroup + err := yaml.Unmarshal(body, &result) + require.NoError(t, err) + }, + }, + { + name: "with application/json Accept header returns JSON", + acceptHeader: "application/json", + expectedType: "application/json", + checkResponse: func(t *testing.T, body []byte) { + require.True(t, strings.Contains(string(body), "test-group")) + require.True(t, strings.Contains(string(body), "TestAlert")) + var result map[string][]apimodels.PrometheusRuleGroup + err := json.Unmarshal(body, &result) + require.NoError(t, err) + }, + }, + { + name: "with application/yaml accept header returns YAML", + acceptHeader: "application/yaml", + expectedType: "text/yaml", + checkResponse: func(t *testing.T, body []byte) { + require.True(t, strings.Contains(string(body), "test-group")) + require.True(t, strings.Contains(string(body), "TestAlert")) + var result map[string][]apimodels.PrometheusRuleGroup + err := yaml.Unmarshal(body, &result) + require.NoError(t, err) + }, + }, + { + name: "with a header with both json and yaml returns JSON", + acceptHeader: "application/yaml, application/json", + expectedType: "application/json", + checkResponse: func(t *testing.T, body []byte) { + require.True(t, strings.Contains(string(body), "test-group")) + require.True(t, strings.Contains(string(body), "TestAlert")) + var result map[string][]apimodels.PrometheusRuleGroup + err := json.Unmarshal(body, &result) + require.NoError(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rc := createRequestCtx() + if tc.acceptHeader != "" { + rc.Req.Header.Set("Accept", tc.acceptHeader) + } + + response := convertPrometheusResponse(rc, http.StatusOK, testData) + + require.Equal(t, http.StatusOK, response.Status()) + tc.checkResponse(t, response.Body()) + }) + } +} + func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) { t.Run("for non-existent folder should return 404", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t)