diff --git a/DEVELOPING.md b/DEVELOPING.md index 8cdd585d..0b6f64fb 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -85,6 +85,47 @@ Run all backend test by executing the following command: mage test ``` +### Integration Tests + +Integration tests run against real OData endpoints to verify that filter expressions, metadata parsing, and response +handling work correctly across OData versions (V2, V3, V4). + +| File | Description | +|-----------------------------------------|-------------------------------------------------------------------------| +| `pkg/plugin/integration_test.go` | Shared infrastructure: types, helpers, test runner | +| `pkg/plugin/integration_public_test.go` | Public internet services (Northwind, OData.svc, Swiss Parliament, USGS) | +| `pkg/plugin/integration_local_test.go` | Local test server (V4 and V2 via OData V2 adapter) | + +#### Run all integration tests + +Requires network access for the public services. + +```bash +mage testIntegration +``` + +Or directly via `go test`: + +```bash +go test -tags integration -v -timeout 120s -run TestIntegration ./pkg/plugin/... +``` + +#### Run only local tests + +Local tests exercise the test server running at `localhost:4004`. Start it first: + +```bash +cd test-server && pnpm start +``` + +Then run only the local integration tests: + +```bash +go test -tags integration -v -timeout 60s -run "TestIntegration/Local" ./pkg/plugin/... +``` + +If the server is not running, the local tests are skipped automatically with a hint on how to start it. + ### Coverage To evaluate the backend test coverage execute the following command: diff --git a/Magefile.go b/Magefile.go index 4710a8a0..f2e8ed6a 100644 --- a/Magefile.go +++ b/Magefile.go @@ -4,9 +4,27 @@ package main import ( + "os" + + "github.com/magefile/mage/sh" + // mage:import build "github.com/grafana/grafana-plugin-sdk-go/build" ) // Default configures the default target. var Default = build.BuildAll + +func TestIntegration() error { + timeout := "120s" + if t := os.Getenv("TEST_INTEGRATION_TIMEOUT"); t != "" { + timeout = t + } + return sh.RunV("go", "test", + "-tags", "integration", + "-v", + "-timeout", timeout, + "-run", "TestIntegration", + "./pkg/plugin/...", + ) +} diff --git a/README.md b/README.md index 3cd095b5..7592bdfe 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Visualize data from OData data sources with Grafana. ## About -This is a Grafana data source for showing data from OData V4 compliant data sources. +This is a Grafana data source for showing data from OData V2, V3 and V4 compliant data sources. The plugin currently +only supports XML format for metadata (`$metadata`) and JSON format for payload data. It was originally developed for internal purposes and is now made available to the open source community. diff --git a/package.json b/package.json index 7a5a7605..e0782728 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "grafana-odata-datasource", - "version": "1.2.1", - "description": "Loads data from OData (V4) compliant data sources to Grafana", + "version": "1.3.0", + "description": "Loads data from OData (V2, V3 and V4) compliant data sources to Grafana", "bugs": { "url": "https://github.com/d-velop/grafana-odata-datasource/issues" }, diff --git a/pkg/plugin/client.go b/pkg/plugin/client.go index 8b198faf..7c241ac1 100644 --- a/pkg/plugin/client.go +++ b/pkg/plugin/client.go @@ -7,12 +7,14 @@ import ( "net/url" "path" "strings" + "time" "github.com/d-velop/grafana-odata-datasource/pkg/plugin/odata" "github.com/grafana/grafana-plugin-sdk-go/backend/log" ) type ODataClient interface { + ODataVersion() string GetServiceRoot(ctx context.Context) (*http.Response, error) GetMetadata(ctx context.Context) (*http.Response, error) Get(ctx context.Context, entitySet string, properties []property, @@ -23,6 +25,11 @@ type ODataClientImpl struct { httpClient *http.Client baseUrl string urlSpaceEncoding string + odataVersion string +} + +func (client *ODataClientImpl) ODataVersion() string { + return client.odataVersion } func (client *ODataClientImpl) get(ctx context.Context, url string, mimeType string) (*http.Response, error) { @@ -49,7 +56,7 @@ func (client *ODataClientImpl) GetMetadata(ctx context.Context) (*http.Response, func (client *ODataClientImpl) Get(ctx context.Context, entitySet string, properties []property, filterConditions []filterCondition) (*http.Response, error) { requestUrl, err := buildQueryUrl(client.baseUrl, entitySet, properties, - filterConditions, client.urlSpaceEncoding) + filterConditions, client.urlSpaceEncoding, client.odataVersion) if err != nil { return nil, err } @@ -58,7 +65,7 @@ func (client *ODataClientImpl) Get(ctx context.Context, entitySet string, proper return client.get(ctx, urlString, "application/json") } -func buildQueryUrl(baseUrl string, entitySet string, properties []property, filterConditions []filterCondition, urlSpaceEncoding string) (*url.URL, error) { +func buildQueryUrl(baseUrl string, entitySet string, properties []property, filterConditions []filterCondition, urlSpaceEncoding string, version string) (*url.URL, error) { requestUrl, err := url.Parse(baseUrl) if err != nil { return nil, err @@ -68,7 +75,7 @@ func buildQueryUrl(baseUrl string, entitySet string, properties []property, filt if err != nil { return nil, fmt.Errorf("error parsing query: %w", err) } - filterParam := mapFilter(filterConditions) + filterParam := mapFilter(filterConditions, version) if len(filterParam) > 0 { params.Add(odata.Filter, filterParam) } @@ -94,18 +101,69 @@ func mapSelect(properties []property) string { return strings.Join(result[:], ",") } -func mapFilter(filterConditions []filterCondition) string { +func mapFilter(filterConditions []filterCondition, version string) string { + isV2V3 := version == "V2" || version == "V3" var filter = "" for index, element := range filterConditions { - if element.Property.Type == odata.EdmString { - filter += fmt.Sprintf("%s %s '%s'", element.Property.Name, element.Operator, element.Value) - } else { - filter += fmt.Sprintf("%s %s %s", element.Property.Name, element.Operator, element.Value) + name, op, val := element.Property.Name, element.Operator, element.Value + switch element.Property.Type { + case odata.EdmString: + filter += fmt.Sprintf("%s %s '%s'", name, op, val) + case odata.EdmDateTime: + if isV2V3 { + filter += fmt.Sprintf("%s %s datetime'%s'", name, op, stripTimezoneForV2(val)) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmDateTimeOffset: + if isV2V3 { + filter += fmt.Sprintf("%s %s datetimeoffset'%s'", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmInt64: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sL", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmDecimal: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sM", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmSingle: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sf", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmDouble: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sd", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmGuid: + if isV2V3 { + filter += fmt.Sprintf("%s %s guid'%s'", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + default: + filter += fmt.Sprintf("%s %s %s", name, op, val) } if index < (len(filterConditions) - 1) { filter += " and " } } - return filter } + +func stripTimezoneForV2(value string) string { + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t.UTC().Format("2006-01-02T15:04:05") + } + return value +} diff --git a/pkg/plugin/client_query_test.go b/pkg/plugin/client_query_test.go index c3517d53..0bc6015f 100644 --- a/pkg/plugin/client_query_test.go +++ b/pkg/plugin/client_query_test.go @@ -14,11 +14,13 @@ import ( func TestMapFilter(t *testing.T) { tables := []struct { name string + version string filterConditions []filterCondition expected string }{ + // --- V4 / default behavior --- { - name: "Time filter only", + name: "V4: DateTimeOffset filter", filterConditions: someFilterConditions( withFilterCondition(timeProp, "ge", aOneDayTimeRange().From.Format(time.RFC3339)), withFilterCondition(timeProp, "le", aOneDayTimeRange().To.Format(time.RFC3339)), @@ -26,7 +28,7 @@ func TestMapFilter(t *testing.T) { expected: "time ge 2022-04-21T12:30:50Z and time le 2022-04-21T12:30:50Z and int32 eq 5", }, { - name: "Time filter and int and string filter", + name: "V4: DateTimeOffset and string filter", filterConditions: someFilterConditions( withFilterCondition(timeProp, "ge", aOneDayTimeRange().From.Format(time.RFC3339)), withFilterCondition(timeProp, "le", aOneDayTimeRange().To.Format(time.RFC3339)), @@ -35,7 +37,7 @@ func TestMapFilter(t *testing.T) { expected: "time ge 2022-04-21T12:30:50Z and time le 2022-04-21T12:30:50Z and int32 eq 5 and string eq 'Hello'", }, { - name: "Time filter and string filter", + name: "V4: DateTimeOffset and empty string filter", filterConditions: someFilterConditions( withFilterCondition(timeProp, "ge", aOneDayTimeRange().From.Format(time.RFC3339)), withFilterCondition(timeProp, "le", aOneDayTimeRange().To.Format(time.RFC3339)), @@ -43,16 +45,67 @@ func TestMapFilter(t *testing.T) { expected: "time ge 2022-04-21T12:30:50Z and time le 2022-04-21T12:30:50Z and string eq ''", }, { - name: "String filter only", + name: "V4: String filter only", filterConditions: someFilterConditions(withFilterCondition(stringProp, "eq", "")), expected: "string eq ''", }, + // --- V2 behavior --- + { + name: "V2: DateTimeOffset wraps with datetimeoffset prefix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(timeProp, "ge", "2022-04-21T12:30:50Z"), + withFilterCondition(timeProp, "le", "2022-04-21T12:30:50Z")), + expected: "time ge datetimeoffset'2022-04-21T12:30:50Z' and time le datetimeoffset'2022-04-21T12:30:50Z'", + }, + { + name: "V2: DateTime wraps with datetime prefix and strips timezone", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "ts"; p.Type = odata.EdmDateTime }, "ge", "2022-04-21T12:30:50Z")), + expected: "ts ge datetime'2022-04-21T12:30:50'", + }, + { + name: "V2: Int64 gets L suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "count"; p.Type = odata.EdmInt64 }, "eq", "42")), + expected: "count eq 42L", + }, + { + name: "V2: Decimal gets M suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "amount"; p.Type = odata.EdmDecimal }, "eq", "12.34")), + expected: "amount eq 12.34M", + }, + { + name: "V2: Single gets f suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "discount"; p.Type = odata.EdmSingle }, "gt", "0")), + expected: "discount gt 0f", + }, + { + name: "V2: Double gets d suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "ratio"; p.Type = odata.EdmDouble }, "gt", "1.5")), + expected: "ratio gt 1.5d", + }, + { + name: "V2: Guid gets guid prefix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "id"; p.Type = odata.EdmGuid }, "eq", "12345678-1234-1234-1234-123456789abc")), + expected: "id eq guid'12345678-1234-1234-1234-123456789abc'", + }, } for _, table := range tables { t.Run(table.name, func(t *testing.T) { // Act - var filterString = mapFilter(table.filterConditions) + var filterString = mapFilter(table.filterConditions, table.version) // Assert assert.Equal(t, table.expected, filterString) @@ -87,7 +140,7 @@ func TestBuildQueryUrl(t *testing.T) { for _, table := range tables { t.Run(table.name, func(t *testing.T) { // Act - var builtUrl, err = buildQueryUrl(table.baseUrl, table.entitySet, table.properties, table.filterConditions, "+") + var builtUrl, err = buildQueryUrl(table.baseUrl, table.entitySet, table.properties, table.filterConditions, "+", "") // Assert assert.NoError(t, err) diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 7ec090bc..aecf7877 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -31,6 +31,7 @@ type ODataSource struct { type DatasourceSettings struct { URLSpaceEncoding string `json:"urlSpaceEncoding"` OauthPassThru bool `json:"oauthPassThru"` + ODataVersion string `json:"odataVersion"` } func newDatasourceInstance(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { @@ -56,7 +57,7 @@ func newDatasourceInstance(ctx context.Context, settings backend.DataSourceInsta } return &ODataSourceInstance{ - &ODataClientImpl{client, settings.URL, dsSettings.URLSpaceEncoding}, + &ODataClientImpl{client, strings.TrimSuffix(settings.URL, "/"), dsSettings.URLSpaceEncoding, dsSettings.ODataVersion}, }, nil } @@ -204,7 +205,13 @@ func (ds *ODataSource) query(ctx context.Context, clientInstance ODataClient, qu log.DefaultLogger.Debug("request response status", "status", resp.Status) if resp.StatusCode != http.StatusOK { - response.Error = fmt.Errorf("get failed with status code %d", resp.StatusCode) + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + log.DefaultLogger.Error("error reading response body", "err", err) + response.Error = fmt.Errorf("get failed - code %d", resp.StatusCode) + } else { + response.Error = fmt.Errorf("get failed - code %d: %s", resp.StatusCode, string(bodyBytes)) + } return response } bodyBytes, err := io.ReadAll(resp.Body) @@ -212,21 +219,39 @@ func (ds *ODataSource) query(ctx context.Context, clientInstance ODataClient, qu response.Error = err return response } - var result odata.Response - err = json.Unmarshal(bodyBytes, &result) + version := clientInstance.ODataVersion() + if version == "Auto" || version == "" { + odataVersion := resp.Header.Get("DataServiceVersion") + if strings.HasPrefix(odataVersion, "2") { + version = "V2" + } else if strings.HasPrefix(odataVersion, "3") { + version = "V3" + } else { + odataVersion = resp.Header.Get("OData-Version") + if strings.HasPrefix(odataVersion, "4") { + version = "V4" + } + } + } + log.DefaultLogger.Debug("using odata version", "version", version) + entries, err := odata.MapToResponse(bodyBytes) if err != nil { response.Error = err return response } - log.DefaultLogger.Debug("query complete", "noOfEntities", len(result.Value)) + log.DefaultLogger.Debug("query complete", "noOfEntities", len(entries)) - for _, entry := range result.Value { + for _, entry := range entries { var values []interface{} - + object, ok := entry.(map[string]interface{}) + if !ok { + // TODO: error handling + continue + } if qm.TimeProperty != nil { values = make([]interface{}, len(qm.Properties)+1) - values[0] = odata.MapValue(entry[qm.TimeProperty.Name], qm.TimeProperty.Type) + values[0] = odata.MapValue(object[qm.TimeProperty.Name], qm.TimeProperty.Type) } else { values = make([]interface{}, len(qm.Properties)) } @@ -237,7 +262,7 @@ func (ds *ODataSource) query(ctx context.Context, clientInstance ODataClient, qu index++ } - if value, ok := entry[prop.Name]; ok { + if value, ok := object[prop.Name]; ok { values[index] = odata.MapValue(value, prop.Type) } else { values[index] = nil diff --git a/pkg/plugin/integration_local_test.go b/pkg/plugin/integration_local_test.go new file mode 100644 index 00000000..3b2bfb93 --- /dev/null +++ b/pkg/plugin/integration_local_test.go @@ -0,0 +1,124 @@ +//go:build integration + +package plugin + +var testPrimitivesFilterTests = []filterTestCase{ + { + name: "boolean equality", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "boolean"}, + {Name: "int32"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "boolean"}, Operator: "eq", Value: "true"}, + }, + expectedResults: 50, + }, + { + name: "int32 range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "int32"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "int32"}, Operator: "ge", Value: "50"}, + }, + expectedResults: 50, + }, + { + name: "int64 range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "int64"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "int64"}, Operator: "ge", Value: "50000000000"}, + }, + expectedResults: 50, + }, + { + name: "int16 range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "int16"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "int16"}, Operator: "ge", Value: "50"}, + }, + expectedResults: 50, + }, + { + name: "decimal range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "decimal"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "decimal"}, Operator: "ge", Value: "25"}, + }, + expectedResults: 50, + }, + { + name: "double range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "double"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "double"}, Operator: "gt", Value: "0"}, + }, + expectedResults: 50, + }, + { + name: "string equality", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "string"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "string"}, Operator: "eq", Value: "item-050"}, + }, + expectedResults: 1, + }, + { + name: "dateTimeOffset range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "dateTimeOffset"}, + {Name: "date"}, + {Name: "int16"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "dateTimeOffset"}, Operator: "ge", Value: "2024-01-01T00:30:00Z"}, + }, + expectedResults: 70, + }, +} + +func init() { + referenceSystems = append(referenceSystems, + referenceSystem{ + name: "Local test server V4 (localhost:4004)", + baseURL: "http://localhost:4004/odata/v4/test", + version: "V4", + requiresLocalServer: true, + filterTests: testPrimitivesFilterTests, + }, + referenceSystem{ + name: "Local test server V2 (localhost:4004)", + baseURL: "http://localhost:4004/odata/v2/test", + version: "V2", + requiresLocalServer: true, + filterTests: testPrimitivesFilterTests, + }, + ) +} diff --git a/pkg/plugin/integration_public_test.go b/pkg/plugin/integration_public_test.go new file mode 100644 index 00000000..b7fc30f7 --- /dev/null +++ b/pkg/plugin/integration_public_test.go @@ -0,0 +1,187 @@ +//go:build integration + +package plugin + +var northwindFilterTests = []filterTestCase{ + { + name: "date range on Orders.OrderDate", + entitySet: "Orders", + properties: []property{ + {Name: "OrderID"}, + {Name: "CustomerID"}, + {Name: "OrderDate"}, + {Name: "Freight"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "OrderDate"}, Operator: "ge", Value: "1996-07-01T00:00:00Z"}, + {Property: property{Name: "OrderDate"}, Operator: "le", Value: "1996-07-31T23:59:59Z"}, + }, + expectedResults: 22, + }, + { + name: "decimal range on Products.UnitPrice", + entitySet: "Products", + properties: []property{ + {Name: "ProductID"}, + {Name: "ProductName"}, + {Name: "UnitPrice"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "UnitPrice"}, Operator: "ge", Value: "10"}, + {Property: property{Name: "UnitPrice"}, Operator: "le", Value: "50"}, + }, + expectedResults: 20, + }, + { + name: "single equality on Order_Details.Discount", + entitySet: "Order_Details", + properties: []property{ + {Name: "OrderID"}, + {Name: "ProductID"}, + {Name: "Discount"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Discount"}, Operator: "eq", Value: "0.03"}, + }, + expectedResults: 3, + }, +} + +var parliFilterTests = []filterTestCase{ + { + name: "int64 range on Meeting.ID", + entitySet: "Meeting", + properties: []property{ + {Name: "ID"}, + {Name: "Language"}, + {Name: "MeetingNumber"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ID"}, Operator: "ge", Value: "1000"}, + {Property: property{Name: "ID"}, Operator: "le", Value: "1010"}, + }, + expectedResults: 55, + }, +} + +var usgsFilterTests = []filterTestCase{ + { + name: "int64 range on Sites.SiteID", + entitySet: "Sites", + properties: []property{ + {Name: "SiteID"}, + {Name: "SiteName"}, + {Name: "SiteTypeCode"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "SiteID"}, Operator: "ge", Value: "1"}, + {Property: property{Name: "SiteID"}, Operator: "le", Value: "100"}, + }, + expectedResults: 89, + }, +} + +var odataSvcFilterTests = []filterTestCase{ + { + name: "date range on Products.ReleaseDate", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "ReleaseDate"}, + {Name: "Price"}, + {Name: "Rating"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ReleaseDate"}, Operator: "ge", Value: "2000-01-01T00:00:00Z"}, + {Property: property{Name: "ReleaseDate"}, Operator: "le", Value: "2006-12-31T23:59:59Z"}, + }, + expectedResults: 6, + }, + { + name: "double range on Products.Price", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "Price"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Price"}, Operator: "ge", Value: "18"}, + {Property: property{Name: "Price"}, Operator: "le", Value: "25"}, + }, + expectedResults: 5, + }, + { + name: "int16 filter on Products.Rating", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "Rating"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Rating"}, Operator: "ge", Value: "4"}, + }, + expectedResults: 3, + }, + { + name: "guid equality on Advertisements.ID", + entitySet: "Advertisements", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ID"}, Operator: "eq", Value: "f89dee73-af9f-4cd4-b330-db93c25ff3c7"}, + }, + expectedResults: 1, + }, +} + +func init() { + referenceSystems = append(referenceSystems, + referenceSystem{ + name: "Northwind V2 (services.odata.org)", + baseURL: "https://services.odata.org/V2/Northwind/Northwind.svc", + version: "V2", + filterTests: northwindFilterTests, + }, + referenceSystem{ + name: "Northwind V3 (services.odata.org)", + baseURL: "https://services.odata.org/V3/Northwind/Northwind.svc", + version: "V3", + filterTests: northwindFilterTests, + }, + referenceSystem{ + name: "Northwind V4 (services.odata.org)", + baseURL: "https://services.odata.org/V4/Northwind/Northwind.svc", + version: "V4", + filterTests: northwindFilterTests, + }, + referenceSystem{ + name: "OData V3 demo (services.odata.org)", + baseURL: "https://services.odata.org/V3/OData/OData.svc", + version: "V3", + filterTests: odataSvcFilterTests, + }, + referenceSystem{ + name: "OData V4 demo (services.odata.org)", + baseURL: "https://services.odata.org/V4/OData/OData.svc", + version: "V4", + filterTests: odataSvcFilterTests, + }, + referenceSystem{ + name: "Swiss Parliament V2 (ws.parlament.ch)", + baseURL: "https://ws.parlament.ch/odata.svc", + version: "V2", + filterTests: parliFilterTests, + }, + referenceSystem{ + name: "USGS Water Data V4 (waterdata.usgs.gov)", + baseURL: "https://dashboard.waterdata.usgs.gov/service/cwis/1.0/odata", + version: "V4", + filterTests: usgsFilterTests, + }, + ) +} diff --git a/pkg/plugin/integration_test.go b/pkg/plugin/integration_test.go new file mode 100644 index 00000000..7772e27e --- /dev/null +++ b/pkg/plugin/integration_test.go @@ -0,0 +1,249 @@ +//go:build integration + +package plugin + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/d-velop/grafana-odata-datasource/pkg/plugin/odata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type filterTestCase struct { + name string + entitySet string + properties []property + filterConditions []filterCondition + expectedResults int // exact number of entries the server must return +} + +type referenceSystem struct { + name string + baseURL string + version string // "V2", "V3", "V4" + requiresLocalServer bool // if true: skip when localhost:4004 is not reachable + filterTests []filterTestCase +} + +var referenceSystems []referenceSystem + +type propertyTypeMap map[string]map[string]string + +func fetchPropertyTypes(t *testing.T, c *ODataClientImpl) propertyTypeMap { + t.Helper() + resp, err := c.GetMetadata(context.Background()) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var edmx odata.Edmx + require.NoError(t, xml.Unmarshal(body, &edmx)) + + etProps := map[string]map[string]string{} + for _, ds := range edmx.DataServices { + for _, schema := range ds.Schemas { + for _, et := range schema.EntityTypes { + props := map[string]string{} + for _, p := range et.Properties { + props[p.Name] = p.Type + } + etProps[schema.Namespace+"."+et.Name] = props + } + } + } + + result := propertyTypeMap{} + for _, ds := range edmx.DataServices { + for _, schema := range ds.Schemas { + for _, ec := range schema.EntityContainers { + for _, es := range ec.EntitySet { + if props, ok := etProps[es.EntityType]; ok { + result[es.Name] = props + } + } + } + } + } + return result +} + +func withResolvedTypes(types propertyTypeMap, entitySet string, properties []property, filterConditions []filterCondition) ([]property, []filterCondition) { + propTypes := types[entitySet] + + resolvedProps := make([]property, len(properties)) + for i, p := range properties { + p.Type = propTypes[p.Name] + resolvedProps[i] = p + } + + resolvedConds := make([]filterCondition, len(filterConditions)) + for i, c := range filterConditions { + c.Property.Type = propTypes[c.Property.Name] + resolvedConds[i] = c + } + + return resolvedProps, resolvedConds +} + +func fetchUnfilteredCount(t *testing.T, rs referenceSystem, entitySet string) int { + t.Helper() + c := newIntegrationClient(rs) + + if rs.version == "V4" { + rawURL := fmt.Sprintf("%s/%s/$count", c.baseUrl, entitySet) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + req.Header.Set("Accept", "text/plain") + resp, err := c.httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + count, err := strconv.Atoi(strings.Trim(string(body), "\ufeff\r\n\t ")) + require.NoError(t, err) + return count + } + + rawURL := fmt.Sprintf("%s/%s?$inlinecount=allpages&$top=1", c.baseUrl, entitySet) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var v2Result struct { + D struct { + Count string `json:"__count"` + } `json:"d"` + } + if json.Unmarshal(body, &v2Result) == nil && v2Result.D.Count != "" { + count, err := strconv.Atoi(v2Result.D.Count) + require.NoError(t, err) + return count + } + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &raw)) + var countStr string + require.NoError(t, json.Unmarshal(raw["odata.count"], &countStr)) + count, err := strconv.Atoi(countStr) + require.NoError(t, err) + return count +} + +func newIntegrationClient(rs referenceSystem) *ODataClientImpl { + return &ODataClientImpl{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseUrl: rs.baseURL, + odataVersion: rs.version, + urlSpaceEncoding: "%20", + } +} + +func isPortOpen(host, port string) bool { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 2*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +func TestIntegration_ServiceRoot(t *testing.T) { + for _, rs := range referenceSystems { + rs := rs + t.Run(rs.name, func(t *testing.T) { + if rs.requiresLocalServer && !isPortOpen("localhost", "4004") { + t.Skip("local test server not running — start with: cd test-server && pnpm start") + } + client := newIntegrationClient(rs) + resp, err := client.GetServiceRoot(context.Background()) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + } +} + +func TestIntegration_Metadata(t *testing.T) { + for _, rs := range referenceSystems { + rs := rs + t.Run(rs.name, func(t *testing.T) { + if rs.requiresLocalServer && !isPortOpen("localhost", "4004") { + t.Skip("local test server not running — start with: cd test-server && pnpm start") + } + client := newIntegrationClient(rs) + resp, err := client.GetMetadata(context.Background()) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + var edmx odata.Edmx + err = xml.Unmarshal(body, &edmx) + require.NoError(t, err, "EDMX must be parseable") + + var totalEntityTypes int + for _, ds := range edmx.DataServices { + for _, schema := range ds.Schemas { + totalEntityTypes += len(schema.EntityTypes) + } + } + assert.Greater(t, totalEntityTypes, 0, "expected at least one entity type in metadata") + }) + } +} + +func TestIntegration_QueryWithFilter(t *testing.T) { + for _, rs := range referenceSystems { + rs := rs + t.Run(rs.name, func(t *testing.T) { + if rs.requiresLocalServer && !isPortOpen("localhost", "4004") { + t.Skip("local test server not running — start with: cd test-server && pnpm start") + } + client := newIntegrationClient(rs) + types := fetchPropertyTypes(t, client) + + for _, tc := range rs.filterTests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + props, conds := withResolvedTypes(types, tc.entitySet, tc.properties, tc.filterConditions) + + resp, err := client.Get(context.Background(), tc.entitySet, props, conds) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, + "server rejected filter — check filter syntax for version %s", rs.version) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + entries, err := odata.MapToResponse(body) + require.NoError(t, err) + assert.Equal(t, tc.expectedResults, len(entries)) + + unfilteredTotal := fetchUnfilteredCount(t, rs, tc.entitySet) + assert.Greater(t, unfilteredTotal, len(entries), + "unfiltered total (%d) must exceed filtered result count (%d)", + unfilteredTotal, len(entries)) + }) + } + }) + } +} diff --git a/pkg/plugin/mocks_test.go b/pkg/plugin/mocks_test.go index a23b888d..5111a677 100644 --- a/pkg/plugin/mocks_test.go +++ b/pkg/plugin/mocks_test.go @@ -26,6 +26,10 @@ type callResourceResponseSenderMock struct { csr *backend.CallResourceResponse } +func (client *clientMock) ODataVersion() string { + return "Auto" +} + func (client *clientMock) GetServiceRoot(_ context.Context) (*http.Response, error) { return &http.Response{StatusCode: client.statusCode, Body: io.NopCloser(bytes.NewReader(client.body))}, client.err diff --git a/pkg/plugin/odata/functions.go b/pkg/plugin/odata/functions.go index f9b36abb..c899cbd6 100644 --- a/pkg/plugin/odata/functions.go +++ b/pkg/plugin/odata/functions.go @@ -1,10 +1,15 @@ package odata import ( + "encoding/json" "fmt" + "strconv" + "strings" "time" ) +const DateTimeWithoutTZ = "2006-01-02T15:04:05" + // ToArray maps OData property types to Grafana Field type func ToArray(propertyType string) interface{} { switch propertyType { @@ -26,15 +31,82 @@ func ToArray(propertyType string) interface{} { return []*int32{} case EdmInt64: return []*int64{} - case EdmDateTimeOffset: - return []*time.Time{} - case EdmDate: + case EdmDateTimeOffset, EdmDateTime, EdmDate: return []*time.Time{} default: return []*string{} } } +func parseOffset(s string, sign int) (int, error) { + offset, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + return sign * offset * 60, nil +} + +func localOffset() int { + currentTime := time.Now() + _, offset := currentTime.Zone() + return offset +} + +func parseV2Time(timeString string) (time.Time, error) { + trimmed := strings.TrimSuffix(strings.TrimPrefix(timeString, "/Date("), ")/") + var err error + var parts []string + var offset int + if strings.Contains(trimmed, "+") { + parts = strings.Split(trimmed, "+") + offset, err = parseOffset(parts[1], 1) + } else if strings.Contains(trimmed, "-") { + parts = strings.Split(trimmed, "-") + offset, err = parseOffset(parts[1], -1) + } else { + parts = []string{trimmed} + } + if err != nil { + return time.Time{}, err + } + ms, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return time.Time{}, err + } + seconds := ms / 1000 + nanoseconds := (ms % 1000) * 1000000 + result := time.Unix(seconds, nanoseconds).Add(time.Duration(offset) * time.Second) + var loc *time.Location + if localOffset() == offset { + loc = time.Local + } else { + loc = time.FixedZone("", offset) + } + return result.In(loc), nil +} + +func parseTime(timeString string) (time.Time, error) { + if strings.HasPrefix(timeString, "/") { + ts, err := parseV2Time(timeString) + if err == nil && !ts.IsZero() { + return ts, nil + } + } + formats := []string{ + time.RFC3339Nano, + time.DateOnly, + } + var ts time.Time + var err error + for _, format := range formats { + ts, err = time.Parse(format, timeString) + if err == nil && !ts.IsZero() { + return ts, nil + } + } + return time.ParseInLocation(DateTimeWithoutTZ, timeString, time.UTC) +} + // MapValue maps OData values to Grafana (Go) values func MapValue(value interface{}, propertyType string) interface{} { if value == nil { @@ -45,13 +117,18 @@ func MapValue(value interface{}, propertyType string) interface{} { boolValue := value.(bool) return &boolValue case EdmSingle, EdmDecimal, EdmDouble, EdmSByte, EdmByte, EdmInt16, EdmInt32, EdmInt64: - result, err := mapNumber(value.(float64), propertyType) + floatValue, err := toFloat64(value) + if err != nil { + fmt.Printf("ERROR: Expected a numeric type but got %T with value %v\n", value, value) + return nil + } + result, err := mapNumber(floatValue, propertyType) if err != nil { return nil } return result - case EdmDateTimeOffset, EdmDate: - if timeValue, err := time.Parse(time.RFC3339Nano, fmt.Sprint(value)); err == nil { + case EdmDateTimeOffset, EdmDateTime, EdmDate: + if timeValue, err := parseTime(fmt.Sprint(value)); err == nil { return &timeValue } else { return nil @@ -62,6 +139,29 @@ func MapValue(value interface{}, propertyType string) interface{} { } } +func toFloat64(value interface{}) (float64, error) { + switch v := value.(type) { + case float64: + return v, nil + case float32: + return float64(v), nil + case int: + return float64(v), nil + case int64: + return float64(v), nil + case int32: + return float64(v), nil + case int16: + return float64(v), nil + case int8: + return float64(v), nil + case string: + return strconv.ParseFloat(v, 64) + default: + return 0, fmt.Errorf("cannot convert %T to float64", value) + } +} + func mapNumber(value float64, propertyType string) (interface{}, error) { switch propertyType { case EdmSingle: @@ -88,3 +188,26 @@ func mapNumber(value float64, propertyType string) (interface{}, error) { return nil, fmt.Errorf("unexpected property type: %s", propertyType) } } + +func MapToResponse(bodyBytes []byte) ([]interface{}, error) { + var response Response + err := json.Unmarshal(bodyBytes, &response) + if err != nil { + return nil, err + } + if response.Value != nil { + return response.Value, nil + } else if response.D != nil { + switch d := response.D.(type) { + case map[string]interface{}: + if results, ok := d["results"].([]interface{}); ok { + return results, nil + } + case []interface{}: + return d, nil + } + } else if response.Results != nil { + return response.Results, nil + } + return nil, nil +} diff --git a/pkg/plugin/odata/functions_test.go b/pkg/plugin/odata/functions_test.go new file mode 100644 index 00000000..85fc7620 --- /dev/null +++ b/pkg/plugin/odata/functions_test.go @@ -0,0 +1,123 @@ +package odata + +import ( + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestParseTime(t *testing.T) { + defaultTime := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC) + testCases := []struct { + name string + input string + expectedError error + expectedTime time.Time + }{ + { + name: "OData V2", + input: "/Date(1672531200000)/", + expectedTime: defaultTime, + }, + { + name: "OData V2 with positive offset minutes", + input: "/Date(1672531200000+0060)/", + expectedTime: defaultTime.Add(1 * time.Hour), + }, + { + name: "OData V2 with negative offset minutes", + input: "/Date(1672531200000-0060)/", + expectedTime: defaultTime.Add(-1 * time.Hour), + }, + { + name: "RFC3389 base", + input: "2023-01-01T00:00:00Z", + expectedTime: defaultTime, + }, + { + name: "RFC3389 with fractional second", + input: "2023-01-01T00:00:00.000Z", + expectedTime: defaultTime, + }, + { + name: "RFC3389 with offset +01:00", + input: "2023-01-01T00:00:00+01:00", + expectedTime: defaultTime.Add(-1 * time.Hour), + }, + { + name: "RFC3389 with offset -01:00", + input: "2023-01-01T00:00:00-01:00", + expectedTime: defaultTime.Add(1 * time.Hour), + }, + { + name: "Datetime without time zone", + input: "2023-01-01T00:00:00", + expectedTime: defaultTime, + }, + { + name: "Date only", + input: "2023-01-01", + expectedTime: defaultTime, + }, + } + for _, tc := range testCases { + t.Run(tc.name+": "+tc.input, func(t *testing.T) { + ts, err := parseTime(tc.input) + require.NoError(t, err) + require.Equal(t, tc.expectedTime.UTC(), ts.UTC()) + }) + } +} + +func TestResponseMapping(t *testing.T) { + testCases := []struct { + name string + input string + }{ + { + name: "", + input: `{ + "value": [ + {"test": "test"} + ] + }`, + }, + { + name: "", + input: `{ + "d": { + "results": [ + {"test": "test"} + ] + } + }`, + }, + { + name: "", + input: `{ + "d": [ + {"test": "test"} + ] + }`, + }, + { + name: "", + input: `{ + "results": [ + {"test": "test"} + ] + }`, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + entries, err := MapToResponse([]byte(tc.input)) + require.NoError(t, err) + for _, entry := range entries { + object, ok := entry.(map[string]interface{}) + require.Equal(t, true, ok) + require.Equal(t, "test", object["test"]) + } + }) + } +} diff --git a/pkg/plugin/odata/models.go b/pkg/plugin/odata/models.go index efb51d6f..04a207a1 100644 --- a/pkg/plugin/odata/models.go +++ b/pkg/plugin/odata/models.go @@ -17,14 +17,19 @@ const ( EdmGuid = "Edm.Guid" EdmTime = "Edm.Time" EdmDate = "Edm.Date" + EdmDateTime = "Edm.DateTime" Metadata = "$metadata" Filter = "$filter" Select = "$select" ) +// Response represents different response formats from OData V2 to V4 and different implementations. It is used to +// autodetect and extract the right payload. type Response struct { - Value []map[string]interface{} `json:"value"` + D interface{} `json:"d"` + Results []interface{} `json:"results"` + Value []interface{} `json:"value"` } type Edmx struct { diff --git a/provisioning/dashboards/ref_time_dashboard.json b/provisioning/dashboards/ref_time_dashboard.json new file mode 100644 index 00000000..b4b9fa8c --- /dev/null +++ b/provisioning/dashboards/ref_time_dashboard.json @@ -0,0 +1,511 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B12" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B12" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTimeOffset" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "ReleaseDate", + "type": "Edm.DateTimeOffset" + } + } + ], + "title": "Products table (Reference-V4)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B11" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B11" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTime" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "ReleaseDate", + "type": "Edm.DateTime" + } + } + ], + "title": "Products table (Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "entitySet": { + "entityType": "NorthwindModel.Order", + "name": "Orders" + }, + "properties": [ + { + "name": "OrderID", + "type": "Edm.Int32" + }, + { + "name": "CustomerID", + "type": "Edm.String" + }, + { + "name": "EmployeeID", + "type": "Edm.Int32" + }, + { + "name": "RequiredDate", + "type": "Edm.DateTime" + }, + { + "name": "ShippedDate", + "type": "Edm.DateTime" + }, + { + "name": "ShipVia", + "type": "Edm.Int32" + }, + { + "name": "Freight", + "type": "Edm.Decimal" + }, + { + "name": "ShipName", + "type": "Edm.String" + }, + { + "name": "ShipAddress", + "type": "Edm.String" + }, + { + "name": "ShipCity", + "type": "Edm.String" + }, + { + "name": "ShipRegion", + "type": "Edm.String" + }, + { + "name": "ShipPostalCode", + "type": "Edm.String" + }, + { + "name": "ShipCountry", + "type": "Edm.String" + } + ], + "refId": "A", + "timeProperty": { + "name": "OrderDate", + "type": "Edm.DateTime" + } + } + ], + "title": "Orders table (Northwind-Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "entitySet": { + "entityType": "NorthwindModel.Order", + "name": "Orders" + }, + "properties": [ + { + "name": "OrderID", + "type": "Edm.Int32" + }, + { + "name": "CustomerID", + "type": "Edm.String" + }, + { + "name": "EmployeeID", + "type": "Edm.Int32" + }, + { + "name": "RequiredDate", + "type": "Edm.DateTime" + }, + { + "name": "ShippedDate", + "type": "Edm.DateTime" + }, + { + "name": "ShipVia", + "type": "Edm.Int32" + }, + { + "name": "Freight", + "type": "Edm.Decimal" + }, + { + "name": "ShipName", + "type": "Edm.String" + }, + { + "name": "ShipAddress", + "type": "Edm.String" + }, + { + "name": "ShipCity", + "type": "Edm.String" + }, + { + "name": "ShipRegion", + "type": "Edm.String" + }, + { + "name": "ShipPostalCode", + "type": "Edm.String" + }, + { + "name": "ShipCountry", + "type": "Edm.String" + } + ], + "refId": "A", + "timeProperty": { + "name": "OrderDate", + "type": "Edm.DateTime" + } + } + ], + "title": "Orders table (Northwind-Reference-V2)", + "type": "table" + } + ], + "refresh": "", + "revision": 1, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "1991-01-01T00:00:00.000Z", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [], + "time_options": [] + }, + "timezone": "browser", + "title": "OData-Reference-Time", + "uid": "zCA1lZbNx", + "version": 1, + "weekStart": "" +} diff --git a/provisioning/dashboards/reference_dashboard.json b/provisioning/dashboards/reference_dashboard.json new file mode 100644 index 00000000..b495e67a --- /dev/null +++ b/provisioning/dashboards/reference_dashboard.json @@ -0,0 +1,478 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B12" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B12" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "ReleaseDate", + "type": "Edm.DateTimeOffset" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTimeOffset" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ] + } + ], + "title": "Products table (Reference-V4)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B11" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B11" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "ReleaseDate", + "type": "Edm.DateTime" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTime" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ], + "refId": "A" + } + ], + "title": "Products table (Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "entitySet": { + "entityType": "NorthwindModel.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ProductID", + "type": "Edm.Int32" + }, + { + "name": "ProductName", + "type": "Edm.String" + }, + { + "name": "SupplierID", + "type": "Edm.Int32" + }, + { + "name": "CategoryID", + "type": "Edm.Int32" + }, + { + "name": "QuantityPerUnit", + "type": "Edm.String" + }, + { + "name": "UnitPrice", + "type": "Edm.Decimal" + }, + { + "name": "UnitsInStock", + "type": "Edm.Int16" + }, + { + "name": "UnitsOnOrder", + "type": "Edm.Int16" + }, + { + "name": "ReorderLevel", + "type": "Edm.Int16" + }, + { + "name": "Discontinued", + "type": "Edm.Boolean" + } + ], + "refId": "A" + } + ], + "title": "Products table (Northwind-Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "entitySet": { + "entityType": "NorthwindModel.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ProductID", + "type": "Edm.Int32" + }, + { + "name": "ProductName", + "type": "Edm.String" + }, + { + "name": "SupplierID", + "type": "Edm.Int32" + }, + { + "name": "CategoryID", + "type": "Edm.Int32" + }, + { + "name": "QuantityPerUnit", + "type": "Edm.String" + }, + { + "name": "UnitPrice", + "type": "Edm.Decimal" + }, + { + "name": "UnitsInStock", + "type": "Edm.Int16" + }, + { + "name": "UnitsOnOrder", + "type": "Edm.Int16" + }, + { + "name": "ReorderLevel", + "type": "Edm.Int16" + }, + { + "name": "Discontinued", + "type": "Edm.Boolean" + } + ], + "refId": "A" + } + ], + "title": "Products table (Northwind-Reference-V2)", + "type": "table" + } + ], + "refresh": "", + "revision": 1, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-12h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [], + "time_options": [] + }, + "timezone": "browser", + "title": "OData-Reference", + "uid": "yBD7GFEov", + "version": 1, + "weekStart": "" +} diff --git a/provisioning/dashboards/test_dashboard.json b/provisioning/dashboards/test_dashboard.json index cc754dd1..d30881cd 100644 --- a/provisioning/dashboards/test_dashboard.json +++ b/provisioning/dashboards/test_dashboard.json @@ -116,6 +116,121 @@ "title": "Temperatures table", "type": "table" }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A01" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A01" + }, + "entitySet": { + "entityType": "TestService.Temperatures", + "name": "Temperatures" + }, + "properties": [ + { + "name": "value1", + "type": "Edm.Double" + }, + { + "name": "value2", + "type": "Edm.Double" + }, + { + "name": "value3", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "time", + "type": "Edm.DateTimeOffset" + } + } + ], + "title": "Temperatures graph", + "type": "timeseries" + }, { "datasource": { "type": "dvelop-odata-datasource", @@ -154,8 +269,8 @@ "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 0 + "x": 0, + "y": 8 }, "id": 6, "options": { @@ -208,7 +323,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A01" + "uid": "PD461F79B494E0A02" }, "fieldConfig": { "defaults": { @@ -269,10 +384,10 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, + "x": 12, "y": 8 }, - "id": 2, + "id": 8, "options": { "legend": { "calcs": [], @@ -285,14 +400,15 @@ "sort": "none" } }, + "pluginVersion": "10.2.0", "targets": [ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A01" + "uid": "PD461F79B494E0A02" }, "entitySet": { - "entityType": "TestService.Temperatures", + "entityType": "GrafanaMock.Temperature", "name": "Temperatures" }, "properties": [ @@ -316,13 +432,102 @@ } } ], - "title": "Temperatures graph", + "title": "Temperatures graph (mocked)", "type": "timeseries" }, { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A02" + "uid": "PD461F79B494E0A03" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 9, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A03" + }, + "entitySet": { + "entityType": "TestService.Temperatures", + "name": "Temperatures" + }, + "properties": [ + { + "name": "value1", + "type": "Edm.Double" + }, + { + "name": "value2", + "type": "Edm.Double" + }, + { + "name": "value3", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "time", + "type": "Edm.DateTimeOffset" + } + } + ], + "title": "Temperatures table (V2)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A03" }, "fieldConfig": { "defaults": { @@ -384,9 +589,9 @@ "h": 8, "w": 12, "x": 12, - "y": 8 + "y": 16 }, - "id": 8, + "id": 10, "options": { "legend": { "calcs": [], @@ -399,11 +604,12 @@ "sort": "none" } }, + "pluginVersion": "10.2.0", "targets": [ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A02" + "uid": "PD461F79B494E0A03" }, "entitySet": { "entityType": "GrafanaMock.Temperature", @@ -430,7 +636,7 @@ } } ], - "title": "Temperatures graph (mocked)", + "title": "Temperatures graph (V2)", "type": "timeseries" } ], diff --git a/provisioning/datasources/datasources.yml b/provisioning/datasources/datasources.yml index 847d986f..68f55929 100644 --- a/provisioning/datasources/datasources.yml +++ b/provisioning/datasources/datasources.yml @@ -5,12 +5,13 @@ deleteDatasources: orgId: 1 - name: 'OData-Mock' orgId: 1 + - name: 'OData-Test-V2' + orgId: 1 datasources: - name: 'OData-Test' uid: PD461F79B494E0A01 type: dvelop-odata-datasource - access: proxy orgId: 1 isDefault: true url: http://test-server:4004/odata/v4/test @@ -21,7 +22,6 @@ datasources: - name: 'OData-Mock' uid: PD461F79B494E0A02 type: dvelop-odata-datasource - access: proxy orgId: 1 isDefault: false url: http://test-server:4004/mock @@ -29,3 +29,13 @@ datasources: editable: true jsonData: oauthPassThru: true + - name: 'OData-Test-V2' + uid: PD461F79B494E0A03 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: http://test-server:4004/odata/v2/test + version: 1 + editable: true + jsonData: + urlSpaceEncoding: '%20' diff --git a/provisioning/datasources/reference_datasources.yml b/provisioning/datasources/reference_datasources.yml new file mode 100644 index 00000000..87fb6c96 --- /dev/null +++ b/provisioning/datasources/reference_datasources.yml @@ -0,0 +1,61 @@ +apiVersion: 1 + +deleteDatasources: + - name: 'Northwind-Reference-V2' + orgId: 1 + - name: 'Northwind-Reference-V3' + orgId: 1 + - name: 'Northwind-Reference-V4' + orgId: 1 + - name: 'Reference-V3' + orgId: 1 + - name: 'Reference-V4' + orgId: 1 + +datasources: + - name: 'Northwind-Reference-V2' + uid: PD461F79B494E0B01 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: https://services.odata.org/V2/Northwind/Northwind.svc + version: 1 + editable: true + jsonData: + odataVersion: 'V2' + - name: 'Northwind-Reference-V3' + uid: PD461F79B494E0B02 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: https://services.odata.org/V3/Northwind/Northwind.svc + version: 1 + editable: true + jsonData: + odataVersion: 'V3' + - name: 'Northwind-Reference-V4' + uid: PD461F79B494E0B03 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: https://services.odata.org/V4/Northwind/Northwind.svc + version: 1 + editable: true + - name: 'Reference-V3' + uid: PD461F79B494E0B11 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: https://services.odata.org/V3/OData/OData.svc + version: 1 + editable: true + jsonData: + odataVersion: 'V3' + - name: 'Reference-V4' + uid: PD461F79B494E0B12 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: https://services.odata.org/V4/OData/OData.svc + version: 1 + editable: true diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index 48e99a91..b2b74d22 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -4,7 +4,7 @@ import { } from '@grafana/data'; import {DataSourceHttpSettings, FieldSet, InlineField, InlineFieldRow, Select} from '@grafana/ui'; import React, {ComponentType, useCallback} from 'react'; -import {ODataOptions, URLSpaceEncoding} from '../types'; +import {ODataOptions, URLSpaceEncoding, ODataVersion} from '../types'; type Props = DataSourcePluginOptionsEditorProps; @@ -23,6 +23,20 @@ export const ConfigEditor: ComponentType = ({ options, onOptionsChange }) const urlSpaceEncodings = Object.entries(URLSpaceEncoding) .map(([label, value]) => ({ label: `${label} (${value})`, value: value })); + const onODataVersionChange = useCallback((option: SelectableValue) => { + const odataVersion = option.value; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + odataVersion: odataVersion || 'Auto', + }, + }); + }, [onOptionsChange, options]); + + const odataVersions = Object.entries(ODataVersion) + .map(([label, value]) => ({ label: label, value: value })); + return ( <> = ({ options, onOptionsChange })

Additional settings

+ + + Select the OData version, currently V2, V3 and V4 are supported. The plugin currently only supports + XML format for metadata (`$metadata`) and JSON format for payload data for both OData versions. +

+ }> +