Skip to content

Commit

Permalink
Experimental: Add query type definition and schemas (#897)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryantxu authored Mar 6, 2024
1 parent 12ee831 commit a8003ef
Show file tree
Hide file tree
Showing 34 changed files with 3,743 additions and 18 deletions.
15 changes: 15 additions & 0 deletions experimental/apis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## APIServer APIs

This package aims to expose types from the plugins-sdk in the grafana apiserver.

Currently, the types are not useable directly so we can avoid adding a dependency on k8s.io/apimachinery
until it is more necessary. See https://github.com/grafana/grafana-plugin-sdk-go/pull/909

The "v0alpha1" version should be considered experimental and is subject to change at any time without notice.
Once it is more stable, it will be released as a versioned API (v1)


### Codegen

The file [apis/data/v0alpha1/zz_generated.deepcopy.go](data/v0alpha1/zz_generated.deepcopy.go) was generated by copying the folder structure into
https://github.com/grafana/grafana/tree/main/pkg/apis and then run `hack/update-codegen.sh data` in [hack scripts](https://github.com/grafana/grafana/tree/v10.3.3/hack).
61 changes: 61 additions & 0 deletions experimental/apis/data/v0alpha1/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package v0alpha1

import (
"bytes"
"context"
"encoding/json"
"net/http"

"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
)

type QueryDataClient interface {
QueryData(ctx context.Context, req QueryDataRequest) (int, *backend.QueryDataResponse, error)
}

type simpleHTTPClient struct {
url string
client *http.Client
headers map[string]string
}

func NewQueryDataClient(url string, client *http.Client, headers map[string]string) QueryDataClient {
if client == nil {
client = http.DefaultClient
}
return &simpleHTTPClient{
url: url,
client: client,
headers: headers,
}
}

func (c *simpleHTTPClient) QueryData(ctx context.Context, query QueryDataRequest) (int, *backend.QueryDataResponse, error) {
body, err := json.Marshal(query)
if err != nil {
return http.StatusBadRequest, nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewBuffer(body))
if err != nil {
return http.StatusBadRequest, nil, err
}
for k, v := range c.headers {
req.Header.Set(k, v)
}
req.Header.Set("Content-Type", "application/json")

rsp, err := c.client.Do(req)
if err != nil {
return rsp.StatusCode, nil, err
}
defer rsp.Body.Close()

qdr := &backend.QueryDataResponse{}
iter, err := jsoniter.Parse(jsoniter.ConfigCompatibleWithStandardLibrary, rsp.Body, 1024*10)
if err == nil {
err = iter.ReadVal(qdr)
}
return rsp.StatusCode, qdr, err
}
53 changes: 53 additions & 0 deletions experimental/apis/data/v0alpha1/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package v0alpha1_test

import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"

"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/stretchr/testify/require"
)

func TestQueryClient(t *testing.T) {
t.Skip()

client := v0alpha1.NewQueryDataClient("http://localhost:3000/api/ds/query", nil,
map[string]string{
"Authorization": "Bearer XYZ",
})
body := `{
"from": "",
"to": "",
"queries": [
{
"refId": "X",
"scenarioId": "csv_content",
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "PD8C576611E62080A"
},
"csvContent": "a,b,c\n1,hello,true",
"hide": true
}
]
}`
qdr := v0alpha1.QueryDataRequest{}
err := json.Unmarshal([]byte(body), &qdr)
require.NoError(t, err)

code, rsp, err := client.QueryData(context.Background(), qdr)
require.NoError(t, err)
require.Equal(t, http.StatusOK, code)

r, ok := rsp.Responses["X"]
require.True(t, ok)

for _, frame := range r.Frames {
txt, err := frame.StringTable(20, 10)
require.NoError(t, err)
fmt.Printf("%s\n", txt)
}
}
6 changes: 6 additions & 0 deletions experimental/apis/data/v0alpha1/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// +k8s:deepcopy-gen=package
// +k8s:openapi-gen=true
// +k8s:defaulter-gen=TypeMeta
// +groupName=data.grafana.com

package v0alpha1
19 changes: 19 additions & 0 deletions experimental/apis/data/v0alpha1/metaV1.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package v0alpha1

// ObjectMeta is a struct that aims to "look" like a real kubernetes object when
// written to JSON, however it does not require the pile of dependencies
// This is really an internal helper until we decide which dependencies make sense
// to require within the SDK
type ObjectMeta struct {
// The name is for k8s and description, but not used in the schema
Name string `json:"name,omitempty"`
// Changes indicate that *something * changed
ResourceVersion string `json:"resourceVersion,omitempty"`
// Timestamp
CreationTimestamp string `json:"creationTimestamp,omitempty"`
}

type TypeMeta struct {
Kind string `json:"kind"` // "QueryTypeDefinitionList",
APIVersion string `json:"apiVersion"` // "query.grafana.app/v0alpha1",
}
82 changes: 82 additions & 0 deletions experimental/apis/data/v0alpha1/openapi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package v0alpha1

import (
"embed"

"k8s.io/kube-openapi/pkg/common"
spec "k8s.io/kube-openapi/pkg/validation/spec"
)

//go:embed query.schema.json query.definition.schema.json
var f embed.FS

func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana-plugin-sdk-go/backend.DataResponse": schemaDataResponse(ref),
"github.com/grafana/grafana-plugin-sdk-go/data.Frame": schemaDataFrame(ref),
"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery": schemaDataQuery(ref),
"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.QueryTypeDefinitionSpec": schemaQueryTypeDefinitionSpec(ref),
}
}

// Individual response
func schemaDataResponse(_ common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "todo... improve schema",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{Allows: true},
},
},
}
}

func schemaDataFrame(_ common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "any object for now",
Type: []string{"object"},
Properties: map[string]spec.Schema{},
AdditionalProperties: &spec.SchemaOrBool{Allows: true},
},
},
}
}

func schemaQueryTypeDefinitionSpec(_ common.ReferenceCallback) common.OpenAPIDefinition {
s, _ := loadSchema("query.definition.schema.json")
if s == nil {
s = &spec.Schema{}
}
return common.OpenAPIDefinition{
Schema: *s,
}
}

func schemaDataQuery(_ common.ReferenceCallback) common.OpenAPIDefinition {
s, _ := DataQuerySchema()
if s == nil {
s = &spec.Schema{}
}
s.SchemaProps.Type = []string{"object"}
s.SchemaProps.AdditionalProperties = &spec.SchemaOrBool{Allows: true}
return common.OpenAPIDefinition{Schema: *s}
}

// Get the cached feature list (exposed as a k8s resource)
func DataQuerySchema() (*spec.Schema, error) {
return loadSchema("query.schema.json")
}

// Get the cached feature list (exposed as a k8s resource)
func loadSchema(path string) (*spec.Schema, error) {
body, err := f.ReadFile(path)
if err != nil {
return nil, err
}
s := &spec.Schema{}
err = s.UnmarshalJSON(body)
return s, err
}
40 changes: 40 additions & 0 deletions experimental/apis/data/v0alpha1/openapi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package v0alpha1

import (
"encoding/json"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/kube-openapi/pkg/validation/spec"
"k8s.io/kube-openapi/pkg/validation/strfmt"
"k8s.io/kube-openapi/pkg/validation/validate"
)

func TestOpenAPI(t *testing.T) {
//nolint:gocritic
defs := GetOpenAPIDefinitions(func(path string) spec.Ref { // (unlambda: replace ¯\_(ツ)_/¯)
return spec.MustCreateRef(path) // placeholder for tests
})

def, ok := defs["github.com/grafana/grafana-plugin-sdk-go/backend.DataResponse"]
require.True(t, ok)
require.Empty(t, def.Dependencies) // not yet supported!

validator := validate.NewSchemaValidator(&def.Schema, nil, "data", strfmt.Default)

body, err := os.ReadFile("./testdata/sample_query_results.json")
require.NoError(t, err)
unstructured := make(map[string]any)
err = json.Unmarshal(body, &unstructured)
require.NoError(t, err)

result := validator.Validate(unstructured)
for _, err := range result.Errors {
assert.NoError(t, err, "validation error")
}
for _, err := range result.Warnings {
assert.NoError(t, err, "validation warning")
}
}
72 changes: 72 additions & 0 deletions experimental/apis/data/v0alpha1/query.definition.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"$schema": "https://json-schema.org/draft-04/schema#",
"properties": {
"discriminators": {
"items": {
"properties": {
"field": {
"type": "string",
"description": "DiscriminatorField is the field used to link behavior to this specific\nquery type. It is typically \"queryType\", but can be another field if necessary"
},
"value": {
"type": "string",
"description": "The discriminator value"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"field",
"value"
]
},
"type": "array",
"description": "Multiple schemas can be defined using discriminators"
},
"description": {
"type": "string",
"description": "Describe whe the query type is for"
},
"schema": {
"$ref": "https://json-schema.org/draft-04/schema#",
"type": "object",
"description": "The query schema represents the properties that can be sent to the API\nIn many cases, this may be the same properties that are saved in a dashboard\nIn the case where the save model is different, we must also specify a save model"
},
"examples": {
"items": {
"properties": {
"name": {
"type": "string",
"description": "Version identifier or empty if only one exists"
},
"description": {
"type": "string",
"description": "Optionally explain why the example is interesting"
},
"saveModel": {
"additionalProperties": true,
"type": "object",
"description": "An example value saved that can be saved in a dashboard"
}
},
"additionalProperties": false,
"type": "object"
},
"type": "array",
"description": "Examples (include a wrapper) ideally a template!"
},
"changelog": {
"items": {
"type": "string"
},
"type": "array",
"description": "Changelog defines the changed from the previous version\nAll changes in the same version *must* be backwards compatible\nOnly notable changes will be shown here, for the full version history see git!"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"schema",
"examples"
]
}
Loading

0 comments on commit a8003ef

Please sign in to comment.