diff --git a/pkg/tsdb/jaeger/callresource.go b/pkg/tsdb/jaeger/callresource.go new file mode 100644 index 00000000000..0d83be284d8 --- /dev/null +++ b/pkg/tsdb/jaeger/callresource.go @@ -0,0 +1,69 @@ +package jaeger + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func (s *Service) registerResourceRoutes() *http.ServeMux { + router := http.NewServeMux() + router.HandleFunc("GET /services", s.withDatasourceHandlerFunc(getServicesHandler)) + router.HandleFunc("GET /services/{service}/operations", s.withDatasourceHandlerFunc(getOperationsHandler)) + return router +} + +func (s *Service) withDatasourceHandlerFunc(getHandler func(d *datasourceInfo) http.HandlerFunc) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + client, err := s.getDSInfo(r.Context(), backend.PluginConfigFromContext(r.Context())) + if err != nil { + writeResponse(nil, errors.New("error getting data source information from context"), rw, client.JaegerClient.logger) + return + } + h := getHandler(client) + h.ServeHTTP(rw, r) + } +} + +func getServicesHandler(ds *datasourceInfo) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + services, err := ds.JaegerClient.Services() + writeResponse(services, err, rw, ds.JaegerClient.logger) + } +} + +func getOperationsHandler(ds *datasourceInfo) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + service := strings.TrimSpace(r.PathValue("service")) + operations, err := ds.JaegerClient.Operations(service) + writeResponse(operations, err, rw, ds.JaegerClient.logger) + } +} + +func writeResponse(res interface{}, err error, rw http.ResponseWriter, logger log.Logger) { + if err != nil { + // This is used for resource calls, we don't need to add actual error message, but we should log it + logger.Warn("An error occurred while doing a resource call", "error", err) + http.Error(rw, "An error occurred within the plugin", http.StatusInternalServerError) + return + } + // Response should not be string, but just in case, handle it + if str, ok := res.(string); ok { + rw.Header().Set("Content-Type", "text/plain") + _, _ = rw.Write([]byte(str)) + return + } + b, err := json.Marshal(res) + if err != nil { + // This is used for resource calls, we don't need to add actual error message, but we should log it + logger.Warn("An error occurred while processing response from resource call", "error", err) + http.Error(rw, "An error occurred within the plugin", http.StatusInternalServerError) + return + } + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write(b) +} diff --git a/pkg/tsdb/jaeger/client.go b/pkg/tsdb/jaeger/client.go index f1b14823e53..51c3ceb9d42 100644 --- a/pkg/tsdb/jaeger/client.go +++ b/pkg/tsdb/jaeger/client.go @@ -60,3 +60,31 @@ func (j *JaegerClient) Services() ([]string, error) { services = response.Data return services, err } + +func (j *JaegerClient) Operations(s string) ([]string, error) { + var response ServicesResponse + operations := []string{} + + u, err := url.JoinPath(j.url, "/api/services/", s, "/operations") + if err != nil { + return operations, backend.DownstreamError(fmt.Errorf("failed to join url: %w", err)) + } + + res, err := j.httpClient.Get(u) + if err != nil { + return operations, err + } + + defer func() { + if err = res.Body.Close(); err != nil { + j.logger.Error("Failed to close response body", "error", err) + } + }() + + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return operations, err + } + + operations = response.Data + return operations, err +} diff --git a/pkg/tsdb/jaeger/client_test.go b/pkg/tsdb/jaeger/client_test.go new file mode 100644 index 00000000000..9351cd07dfa --- /dev/null +++ b/pkg/tsdb/jaeger/client_test.go @@ -0,0 +1,166 @@ +package jaeger + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/stretchr/testify/assert" +) + +func TestJaegerClient_Services(t *testing.T) { + tests := []struct { + name string + mockResponse string + mockStatusCode int + mockStatus string + expectedResult []string + expectError bool + expectedError error + }{ + { + name: "Successful response", + mockResponse: `{"data": ["service1", "service2"], "total": 2, "limit": 0, "offset": 0}`, + mockStatusCode: http.StatusOK, + mockStatus: "OK", + expectedResult: []string{"service1", "service2"}, + expectError: false, + expectedError: nil, + }, + { + name: "Non-200 response", + mockResponse: "", + mockStatusCode: http.StatusInternalServerError, + mockStatus: "Internal Server Error", + expectedResult: []string{}, + expectError: true, + expectedError: errors.New("Internal Server Error"), + }, + { + name: "Invalid JSON response", + mockResponse: `{invalid json`, + mockStatusCode: http.StatusOK, + mockStatus: "OK", + expectedResult: []string{}, + expectError: true, + expectedError: &json.SyntaxError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.mockStatusCode) + _, _ = w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + client, err := New(server.URL, server.Client(), log.NewNullLogger()) + assert.NoError(t, err) + + services, err := client.Services() + + if tt.expectError { + assert.Error(t, err) + if tt.expectedError != nil { + assert.IsType(t, tt.expectedError, err) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, services) + } + }) + } +} + +func TestJaegerClient_Operations(t *testing.T) { + tests := []struct { + name string + service string + mockResponse string + mockStatusCode int + mockStatus string + expectedResult []string + expectError bool + expectedError error + }{ + { + name: "Successful response", + service: "test-service", + mockResponse: `{"data": ["operation1", "operation2"], "total": 2, "limit": 0, "offset": 0}`, + mockStatusCode: http.StatusOK, + mockStatus: "OK", + expectedResult: []string{"operation1", "operation2"}, + expectError: false, + expectedError: nil, + }, + { + name: "Non-200 response", + service: "test-service", + mockResponse: "", + mockStatusCode: http.StatusInternalServerError, + mockStatus: "Internal Server Error", + expectedResult: []string{}, + expectError: true, + expectedError: errors.New("Internal Server Error"), + }, + { + name: "Invalid JSON response", + service: "test-service", + mockResponse: `{invalid json`, + mockStatusCode: http.StatusOK, + mockStatus: "OK", + expectedResult: []string{}, + expectError: true, + expectedError: &json.SyntaxError{}, + }, + { + name: "Service with special characters", + service: "test/service:1", + mockResponse: `{"data": ["operation1"], "total": 1, "limit": 0, "offset": 0}`, + mockStatusCode: http.StatusOK, + mockStatus: "OK", + expectedResult: []string{"operation1"}, + expectError: false, + expectedError: nil, + }, + { + name: "Empty service", + service: "", + mockResponse: `{"data": [], "total": 0, "limit": 0, "offset": 0}`, + mockStatusCode: http.StatusOK, + mockStatus: "OK", + expectedResult: []string{}, + expectError: false, + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.mockStatusCode) + _, _ = w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + client, err := New(server.URL, server.Client(), log.NewNullLogger()) + assert.NoError(t, err) + + operations, err := client.Operations(tt.service) + + if tt.expectError { + assert.Error(t, err) + if tt.expectedError != nil { + assert.IsType(t, tt.expectedError, err) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, operations) + } + }) + } +} diff --git a/pkg/tsdb/jaeger/jaeger.go b/pkg/tsdb/jaeger/jaeger.go index 24244c69e8e..1128192db1d 100644 --- a/pkg/tsdb/jaeger/jaeger.go +++ b/pkg/tsdb/jaeger/jaeger.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" "github.com/grafana/grafana/pkg/infra/httpclient" ) @@ -85,3 +86,8 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque Message: "Data source is working", }, nil } + +func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + handler := httpadapter.New(s.registerResourceRoutes()) + return handler.CallResource(ctx, req, sender) +} diff --git a/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx b/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx index 533f5b900eb..719b2ca49d9 100644 --- a/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx +++ b/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx @@ -34,7 +34,7 @@ describe('SearchForm', () => { }; const ds = { async metadataRequest(url) { - if (url === '/api/services') { + if (url === 'services') { return Promise.resolve(['jaeger-query', 'service2', 'service3']); } return undefined; diff --git a/public/app/plugins/datasource/jaeger/components/SearchForm.tsx b/public/app/plugins/datasource/jaeger/components/SearchForm.tsx index f0eee728f38..c5840a60bab 100644 --- a/public/app/plugins/datasource/jaeger/components/SearchForm.tsx +++ b/public/app/plugins/datasource/jaeger/components/SearchForm.tsx @@ -68,7 +68,7 @@ export function SearchForm({ datasource, query, onChange }: Props) { useEffect(() => { const getServices = async () => { - const services = await loadOptions('/api/services', 'services'); + const services = await loadOptions('services', 'services'); if (query.service && getTemplateSrv().containsTemplate(query.service)) { services.push(toOption(query.service)); } @@ -80,7 +80,7 @@ export function SearchForm({ datasource, query, onChange }: Props) { useEffect(() => { const getOperations = async () => { const operations = await loadOptions( - `/api/services/${encodeURIComponent(getTemplateSrv().replace(query.service!))}/operations`, + `services/${encodeURIComponent(getTemplateSrv().replace(query.service!))}/operations`, 'operations' ); if (query.operation && getTemplateSrv().containsTemplate(query.operation)) { @@ -101,7 +101,7 @@ export function SearchForm({ datasource, query, onChange }: Props) {