Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/...",
)
}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
76 changes: 67 additions & 9 deletions pkg/plugin/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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
}
65 changes: 59 additions & 6 deletions pkg/plugin/client_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ 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)),
int32Eq5),
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)),
Expand All @@ -35,24 +37,75 @@ 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)),
withFilterCondition(stringProp, "eq", "")),
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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading