diff --git a/experimental/apis/README.md b/experimental/apis/README.md new file mode 100644 index 000000000..33fd54a65 --- /dev/null +++ b/experimental/apis/README.md @@ -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). \ No newline at end of file diff --git a/experimental/apis/data/v0alpha1/client.go b/experimental/apis/data/v0alpha1/client.go new file mode 100644 index 000000000..b84ae196a --- /dev/null +++ b/experimental/apis/data/v0alpha1/client.go @@ -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 +} diff --git a/experimental/apis/data/v0alpha1/client_test.go b/experimental/apis/data/v0alpha1/client_test.go new file mode 100644 index 000000000..b7915b0dd --- /dev/null +++ b/experimental/apis/data/v0alpha1/client_test.go @@ -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) + } +} diff --git a/experimental/apis/data/v0alpha1/doc.go b/experimental/apis/data/v0alpha1/doc.go new file mode 100644 index 000000000..6a049ca39 --- /dev/null +++ b/experimental/apis/data/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=data.grafana.com + +package v0alpha1 diff --git a/experimental/apis/data/v0alpha1/metaV1.go b/experimental/apis/data/v0alpha1/metaV1.go new file mode 100644 index 000000000..042f01629 --- /dev/null +++ b/experimental/apis/data/v0alpha1/metaV1.go @@ -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", +} diff --git a/experimental/apis/data/v0alpha1/openapi.go b/experimental/apis/data/v0alpha1/openapi.go new file mode 100644 index 000000000..221ec70a6 --- /dev/null +++ b/experimental/apis/data/v0alpha1/openapi.go @@ -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 +} diff --git a/experimental/apis/data/v0alpha1/openapi_test.go b/experimental/apis/data/v0alpha1/openapi_test.go new file mode 100644 index 000000000..06c9eab88 --- /dev/null +++ b/experimental/apis/data/v0alpha1/openapi_test.go @@ -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") + } +} diff --git a/experimental/apis/data/v0alpha1/query.definition.schema.json b/experimental/apis/data/v0alpha1/query.definition.schema.json new file mode 100644 index 000000000..d71d2569c --- /dev/null +++ b/experimental/apis/data/v0alpha1/query.definition.schema.json @@ -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" + ] +} \ No newline at end of file diff --git a/experimental/apis/data/v0alpha1/query.go b/experimental/apis/data/v0alpha1/query.go new file mode 100644 index 000000000..a382f0ded --- /dev/null +++ b/experimental/apis/data/v0alpha1/query.go @@ -0,0 +1,419 @@ +package v0alpha1 + +import ( + "encoding/json" + "fmt" + "unsafe" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana-plugin-sdk-go/data/converters" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" + j "github.com/json-iterator/go" +) + +func init() { //nolint:gochecknoinits + jsoniter.RegisterTypeEncoder("v0alpha1.DataQuery", &genericQueryCodec{}) + jsoniter.RegisterTypeDecoder("v0alpha1.DataQuery", &genericQueryCodec{}) +} + +type QueryDataRequest struct { + // Time range applied to each query (when not included in the query body) + TimeRange `json:",inline"` + + // Datasource queries + Queries []DataQuery `json:"queries"` + + // Optionally include debug information in the response + Debug bool `json:"debug,omitempty"` +} + +// DataQuery is a replacement for `dtos.MetricRequest` with more explicit typing +type DataQuery struct { + CommonQueryProperties `json:",inline"` + + // Additional Properties (that live at the root) + additional map[string]any `json:"-"` // note this uses custom JSON marshalling +} + +func NewDataQuery(body map[string]any) DataQuery { + g := &DataQuery{ + additional: make(map[string]any), + } + for k, v := range body { + _ = g.Set(k, v) + } + return *g +} + +// Set allows setting values using key/value pairs +func (g *DataQuery) Set(key string, val any) *DataQuery { + switch key { + case "refId": + g.RefID, _ = val.(string) + case "resultAssertions": + body, err := json.Marshal(val) + if err != nil { + _ = json.Unmarshal(body, &g.ResultAssertions) + } + case "timeRange": + body, err := json.Marshal(val) + if err != nil { + _ = json.Unmarshal(body, &g.TimeRange) + } + case "datasource": + body, err := json.Marshal(val) + if err != nil { + _ = json.Unmarshal(body, &g.Datasource) + } + case "datasourceId": + v, err := converters.JSONValueToInt64.Converter(val) + if err != nil { + g.DatasourceID, _ = v.(int64) + } + case "queryType": + g.QueryType, _ = val.(string) + case "maxDataPoints": + v, err := converters.JSONValueToInt64.Converter(val) + if err != nil { + g.MaxDataPoints, _ = v.(int64) + } + case "intervalMs": + v, err := converters.JSONValueToFloat64.Converter(val) + if err != nil { + g.IntervalMS, _ = v.(float64) + } + case "hide": + g.Hide, _ = val.(bool) + default: + if g.additional == nil { + g.additional = make(map[string]any) + } + g.additional[key] = val + } + return g +} + +func (g *DataQuery) Get(key string) (any, bool) { + switch key { + case "refId": + return g.RefID, true + case "resultAssertions": + return g.ResultAssertions, true + case "timeRange": + return g.TimeRange, true + case "datasource": + return g.Datasource, true + case "datasourceId": + return g.DatasourceID, true + case "queryType": + return g.QueryType, true + case "maxDataPoints": + return g.MaxDataPoints, true + case "intervalMs": + return g.IntervalMS, true + case "hide": + return g.Hide, true + } + v, ok := g.additional[key] + return v, ok +} + +func (g *DataQuery) GetString(key string) string { + v, ok := g.Get(key) + if ok { + // At the root convert to string + s, ok := v.(string) + if ok { + return s + } + } + return "" +} + +type genericQueryCodec struct{} + +func (codec *genericQueryCodec) IsEmpty(_ unsafe.Pointer) bool { + return false +} + +func (codec *genericQueryCodec) Encode(ptr unsafe.Pointer, stream *j.Stream) { + q := (*DataQuery)(ptr) + writeQuery(q, stream) +} + +func (codec *genericQueryCodec) Decode(ptr unsafe.Pointer, iter *j.Iterator) { + q := DataQuery{} + err := q.readQuery(jsoniter.NewIterator(iter)) + if err != nil { + // keep existing iter error if it exists + if iter.Error == nil { + iter.Error = err + } + return + } + *((*DataQuery)(ptr)) = q +} + +// MarshalJSON writes JSON including the common and custom values +func (g DataQuery) MarshalJSON() ([]byte, error) { + cfg := j.ConfigCompatibleWithStandardLibrary + stream := cfg.BorrowStream(nil) + defer cfg.ReturnStream(stream) + + writeQuery(&g, stream) + return append([]byte(nil), stream.Buffer()...), stream.Error +} + +// UnmarshalJSON reads a query from json byte array +func (g *DataQuery) UnmarshalJSON(b []byte) error { + iter, err := jsoniter.ParseBytes(jsoniter.ConfigDefault, b) + if err != nil { + return err + } + return g.readQuery(iter) +} + +func (g *DataQuery) DeepCopyInto(out *DataQuery) { + *out = *g + g.CommonQueryProperties.DeepCopyInto(&out.CommonQueryProperties) + if g.additional != nil { + out.additional = map[string]any{} + if len(g.additional) > 0 { + jj, err := json.Marshal(g.additional) + if err != nil { + _ = json.Unmarshal(jj, &out.additional) + } + } + } +} + +func writeQuery(g *DataQuery, stream *j.Stream) { + q := g.CommonQueryProperties + stream.WriteObjectStart() + stream.WriteObjectField("refId") + stream.WriteVal(g.RefID) + + if q.ResultAssertions != nil { + stream.WriteMore() + stream.WriteObjectField("resultAssertions") + stream.WriteVal(g.ResultAssertions) + } + + if q.TimeRange != nil { + stream.WriteMore() + stream.WriteObjectField("timeRange") + stream.WriteVal(g.TimeRange) + } + + if q.Datasource != nil { + stream.WriteMore() + stream.WriteObjectField("datasource") + stream.WriteVal(g.Datasource) + } + + if q.DatasourceID > 0 { + stream.WriteMore() + stream.WriteObjectField("datasourceId") + stream.WriteVal(g.DatasourceID) + } + + if q.QueryType != "" { + stream.WriteMore() + stream.WriteObjectField("queryType") + stream.WriteVal(g.QueryType) + } + + if q.MaxDataPoints > 0 { + stream.WriteMore() + stream.WriteObjectField("maxDataPoints") + stream.WriteVal(g.MaxDataPoints) + } + + if q.IntervalMS > 0 { + stream.WriteMore() + stream.WriteObjectField("intervalMs") + stream.WriteVal(g.IntervalMS) + } + + if q.Hide { + stream.WriteMore() + stream.WriteObjectField("hide") + stream.WriteVal(g.Hide) + } + + // The additional properties + if g.additional != nil { + for k, v := range g.additional { + stream.WriteMore() + stream.WriteObjectField(k) + stream.WriteVal(v) + } + } + stream.WriteObjectEnd() +} + +func (g *DataQuery) readQuery(iter *jsoniter.Iterator) error { + return g.CommonQueryProperties.readQuery(iter, func(key string, iter *jsoniter.Iterator) error { + if g.additional == nil { + g.additional = make(map[string]any) + } + v, err := iter.Read() + g.additional[key] = v + return err + }) +} + +func (g *CommonQueryProperties) readQuery(iter *jsoniter.Iterator, + processUnknownKey func(key string, iter *jsoniter.Iterator) error, +) error { + var err error + var next j.ValueType + field := "" + for field, err = iter.ReadObject(); field != ""; field, err = iter.ReadObject() { + switch field { + case "refId": + g.RefID, err = iter.ReadString() + case "resultAssertions": + err = iter.ReadVal(&g.ResultAssertions) + case "timeRange": + err = iter.ReadVal(&g.TimeRange) + case "datasource": + // Old datasource values may just be a string + next, err = iter.WhatIsNext() + if err == nil { + switch next { + case j.StringValue: + g.Datasource = &DataSourceRef{} + g.Datasource.UID, err = iter.ReadString() + case j.ObjectValue: + err = iter.ReadVal(&g.Datasource) + default: + return fmt.Errorf("expected string or object") + } + } + + case "datasourceId": + g.DatasourceID, err = iter.ReadInt64() + case "queryType": + g.QueryType, err = iter.ReadString() + case "maxDataPoints": + g.MaxDataPoints, err = iter.ReadInt64() + case "intervalMs": + g.IntervalMS, err = iter.ReadFloat64() + case "hide": + g.Hide, err = iter.ReadBool() + default: + err = processUnknownKey(field, iter) + } + if err != nil { + return err + } + } + return err +} + +// CommonQueryProperties are properties that can be added to all queries. +// These properties live in the same JSON level as datasource specific properties, +// so care must be taken to ensure they do not overlap +type CommonQueryProperties struct { + // RefID is the unique identifier of the query, set by the frontend call. + RefID string `json:"refId,omitempty"` + + // Optionally define expected query result behavior + ResultAssertions *ResultAssertions `json:"resultAssertions,omitempty"` + + // TimeRange represents the query range + // NOTE: unlike generic /ds/query, we can now send explicit time values in each query + // NOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly + TimeRange *TimeRange `json:"timeRange,omitempty"` + + // The datasource + Datasource *DataSourceRef `json:"datasource,omitempty"` + + // Deprecated -- use datasource ref instead + DatasourceID int64 `json:"datasourceId,omitempty"` + + // QueryType is an optional identifier for the type of query. + // It can be used to distinguish different types of queries. + QueryType string `json:"queryType,omitempty"` + + // MaxDataPoints is the maximum number of data points that should be returned from a time series query. + // NOTE: the values for maxDataPoints is not saved in the query model. It is typically calculated + // from the number of pixels visible in a visualization + MaxDataPoints int64 `json:"maxDataPoints,omitempty"` + + // Interval is the suggested duration between time points in a time series query. + // NOTE: the values for intervalMs is not saved in the query model. It is typically calculated + // from the interval required to fill a pixels in the visualization + IntervalMS float64 `json:"intervalMs,omitempty"` + + // true if query is disabled (ie should not be returned to the dashboard) + // NOTE: this does not always imply that the query should not be executed since + // the results from a hidden query may be used as the input to other queries (SSE etc) + Hide bool `json:"hide,omitempty"` +} + +type DataSourceRef struct { + // The datasource plugin type + Type string `json:"type"` + + // Datasource UID + UID string `json:"uid,omitempty"` + + // ?? the datasource API version? (just version, not the group? type | apiVersion?) +} + +// TimeRange represents a time range for a query and is a property of DataQuery. +type TimeRange struct { + // From is the start time of the query. + From string `json:"from" jsonschema:"example=now-1h,default=now-6h"` + + // To is the end time of the query. + To string `json:"to" jsonschema:"example=now,default=now"` +} + +// ResultAssertions define the expected response shape and query behavior. This is useful to +// enforce behavior over time. The assertions are passed to the query engine and can be used +// to fail queries *before* returning them to a client (select * from bigquery!) +type ResultAssertions struct { + // Type asserts that the frame matches a known type structure. + Type data.FrameType `json:"type,omitempty"` + + // TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane + // contract documentation https://grafana.github.io/dataplane/contract/. + TypeVersion data.FrameTypeVersion `json:"typeVersion"` + + // Maximum frame count + MaxFrames int64 `json:"maxFrames,omitempty"` + + // Once we can support this, adding max bytes would be helpful + // // Maximum bytes that can be read -- if the query planning expects more then this, the query may fail fast + // MaxBytes int64 `json:"maxBytes,omitempty"` +} + +func (r *ResultAssertions) Validate(frames data.Frames) error { + if r.Type != data.FrameTypeUnknown { + for _, frame := range frames { + if frame.Meta == nil { + return fmt.Errorf("result missing frame type (and metadata)") + } + if frame.Meta.Type == data.FrameTypeUnknown { + // ?? should we try to detect? and see if we can use it as that type? + return fmt.Errorf("expected frame type [%s], but the type is unknown", r.Type) + } + if frame.Meta.Type != r.Type { + return fmt.Errorf("expected frame type [%s], but found [%s]", r.Type, frame.Meta.Type) + } + if !r.TypeVersion.IsZero() { + if r.TypeVersion == frame.Meta.TypeVersion { + return fmt.Errorf("type versions do not match. Expected [%s], but found [%s]", r.TypeVersion, frame.Meta.TypeVersion) + } + } + } + } + + if r.MaxFrames > 0 && len(frames) > int(r.MaxFrames) { + return fmt.Errorf("more than expected frames found") + } + return nil +} diff --git a/experimental/apis/data/v0alpha1/query.schema.json b/experimental/apis/data/v0alpha1/query.schema.json new file mode 100644 index 000000000..8972c3fd1 --- /dev/null +++ b/experimental/apis/data/v0alpha1/query.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "properties": { + "refId": { + "type": "string", + "description": "RefID is the unique identifier of the query, set by the frontend call." + }, + "resultAssertions": { + "properties": { + "type": { + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "x-enum-description": {} + }, + "typeVersion": { + "items": { + "type": "integer" + }, + "type": "array", + "maxItems": 2, + "minItems": 2, + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/." + }, + "maxFrames": { + "type": "integer", + "description": "Maximum frame count" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "typeVersion" + ], + "description": "Optionally define expected query result behavior" + }, + "timeRange": { + "properties": { + "from": { + "type": "string", + "description": "From is the start time of the query.", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "type": "string", + "description": "To is the end time of the query.", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "from", + "to" + ], + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly" + }, + "datasource": { + "properties": { + "type": { + "type": "string", + "description": "The datasource plugin type" + }, + "uid": { + "type": "string", + "description": "Datasource UID" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type" + ], + "description": "The datasource" + }, + "queryType": { + "type": "string", + "description": "QueryType is an optional identifier for the type of query.\nIt can be used to distinguish different types of queries." + }, + "maxDataPoints": { + "type": "integer", + "description": "MaxDataPoints is the maximum number of data points that should be returned from a time series query.\nNOTE: the values for maxDataPoints is not saved in the query model. It is typically calculated\nfrom the number of pixels visible in a visualization" + }, + "intervalMs": { + "type": "number", + "description": "Interval is the suggested duration between time points in a time series query.\nNOTE: the values for intervalMs is not saved in the query model. It is typically calculated\nfrom the interval required to fill a pixels in the visualization" + }, + "hide": { + "type": "boolean", + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)" + } + }, + "additionalProperties": true, + "type": "object", + "description": "Generic query properties" +} \ No newline at end of file diff --git a/experimental/apis/data/v0alpha1/query_definition.go b/experimental/apis/data/v0alpha1/query_definition.go new file mode 100644 index 000000000..91662f7b5 --- /dev/null +++ b/experimental/apis/data/v0alpha1/query_definition.go @@ -0,0 +1,78 @@ +package v0alpha1 + +import ( + "fmt" +) + +// QueryTypeDefinition is a kubernetes shaped object that represents a single query definition +type QueryTypeDefinition struct { + ObjectMeta `json:"metadata,omitempty"` + + Spec QueryTypeDefinitionSpec `json:"spec,omitempty"` +} + +// QueryTypeDefinitionList is a kubernetes shaped object that represents a list of query types +// For simple data sources, there may be only a single query type, however when multiple types +// exist they must be clearly specified with distinct discriminator field+value pairs +type QueryTypeDefinitionList struct { + TypeMeta `json:",inline"` + ObjectMeta `json:"metadata,omitempty"` + + Items []QueryTypeDefinition `json:"items"` +} + +type QueryTypeDefinitionSpec struct { + // Multiple schemas can be defined using discriminators + Discriminators []DiscriminatorFieldValue `json:"discriminators,omitempty"` + + // Describe whe the query type is for + Description string `json:"description,omitempty"` + + // The query schema represents the properties that can be sent to the API + // In many cases, this may be the same properties that are saved in a dashboard + // In the case where the save model is different, we must also specify a save model + Schema JSONSchema `json:"schema"` + + // Examples (include a wrapper) ideally a template! + Examples []QueryExample `json:"examples"` + + // Changelog defines the changed from the previous version + // All changes in the same version *must* be backwards compatible + // Only notable changes will be shown here, for the full version history see git! + Changelog []string `json:"changelog,omitempty"` +} + +type QueryExample struct { + // Version identifier or empty if only one exists + Name string `json:"name,omitempty"` + + // Optionally explain why the example is interesting + Description string `json:"description,omitempty"` + + // An example value saved that can be saved in a dashboard + SaveModel Unstructured `json:"saveModel,omitempty"` +} + +type DiscriminatorFieldValue struct { + // DiscriminatorField is the field used to link behavior to this specific + // query type. It is typically "queryType", but can be another field if necessary + Field string `json:"field"` + + // The discriminator value + Value string `json:"value"` +} + +// using any since this will often be enumerations +func NewDiscriminators(keyvals ...any) []DiscriminatorFieldValue { + if len(keyvals)%2 != 0 { + panic("values must be even") + } + dis := []DiscriminatorFieldValue{} + for i := 0; i < len(keyvals); i += 2 { + dis = append(dis, DiscriminatorFieldValue{ + Field: fmt.Sprintf("%v", keyvals[i]), + Value: fmt.Sprintf("%v", keyvals[i+1]), + }) + } + return dis +} diff --git a/experimental/apis/data/v0alpha1/query_test.go b/experimental/apis/data/v0alpha1/query_test.go new file mode 100644 index 000000000..51ba48ab9 --- /dev/null +++ b/experimental/apis/data/v0alpha1/query_test.go @@ -0,0 +1,118 @@ +package v0alpha1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseQueriesIntoQueryDataRequest(t *testing.T) { + request := []byte(`{ + "queries": [ + { + "refId": "A", + "datasource": { + "type": "grafana-googlesheets-datasource", + "uid": "b1808c48-9fc9-4045-82d7-081781f8a553" + }, + "cacheDurationSeconds": 300, + "spreadsheet": "spreadsheetID", + "datasourceId": 4, + "intervalMs": 30000, + "maxDataPoints": 794 + }, + { + "refId": "Z", + "datasource": "old", + "maxDataPoints": 10, + "timeRange": { + "from": "100", + "to": "200" + } + } + ], + "from": "1692624667389", + "to": "1692646267389" + }`) + + req := &QueryDataRequest{} + err := json.Unmarshal(request, req) + require.NoError(t, err) + + t.Run("verify raw unmarshal", func(t *testing.T) { + require.Len(t, req.Queries, 2) + require.Equal(t, "b1808c48-9fc9-4045-82d7-081781f8a553", req.Queries[0].Datasource.UID) + require.Equal(t, "spreadsheetID", req.Queries[0].GetString("spreadsheet")) + + // Write the query (with additional spreadsheetID) to JSON + out, err := json.MarshalIndent(req.Queries[0], "", " ") + require.NoError(t, err) + + // And read it back with standard JSON marshal functions + query := &DataQuery{} + err = json.Unmarshal(out, query) + require.NoError(t, err) + require.Equal(t, "spreadsheetID", query.GetString("spreadsheet")) + + // The second query has an explicit time range, and legacy datasource name + out, err = json.MarshalIndent(req.Queries[1], "", " ") + require.NoError(t, err) + // fmt.Printf("%s\n", string(out)) + require.JSONEq(t, `{ + "datasource": { + "type": "", ` /* NOTE! this implies legacy naming */ +` + "uid": "old" + }, + "maxDataPoints": 10, + "refId": "Z", + "timeRange": { + "from": "100", + "to": "200" + } + }`, string(out)) + }) + + t.Run("same results from either parser", func(t *testing.T) { + typed := &QueryDataRequest{} + err = json.Unmarshal(request, typed) + require.NoError(t, err) + + out1, err := json.MarshalIndent(req, "", " ") + require.NoError(t, err) + + out2, err := json.MarshalIndent(typed, "", " ") + require.NoError(t, err) + + require.JSONEq(t, string(out1), string(out2)) + }) +} + +func TestQueryBuilders(t *testing.T) { + prop := "testkey" + testQ1 := &DataQuery{} + testQ1.Set(prop, "A") + require.Equal(t, "A", testQ1.GetString(prop)) + + testQ1.Set(prop, "B") + require.Equal(t, "B", testQ1.GetString(prop)) + + testQ2 := testQ1 + testQ2.Set(prop, "C") + require.Equal(t, "C", testQ1.GetString(prop)) + require.Equal(t, "C", testQ2.GetString(prop)) + + // Uses the official field when exists + testQ2.Set("queryType", "D") + require.Equal(t, "D", testQ2.QueryType) + require.Equal(t, "D", testQ1.QueryType) + require.Equal(t, "D", testQ2.GetString("queryType")) + + // Map constructor + testQ3 := NewDataQuery(map[string]any{ + "queryType": "D", + "extra": "E", + }) + require.Equal(t, "D", testQ3.QueryType) + require.Equal(t, "E", testQ3.GetString("extra")) +} diff --git a/experimental/apis/data/v0alpha1/schema.go b/experimental/apis/data/v0alpha1/schema.go new file mode 100644 index 000000000..a834b3364 --- /dev/null +++ b/experimental/apis/data/v0alpha1/schema.go @@ -0,0 +1,70 @@ +package v0alpha1 + +import ( + "encoding/json" + + openapi "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// The k8s compatible jsonschema version +const draft04 = "https://json-schema.org/draft-04/schema#" + +type JSONSchema struct { + Spec *spec.Schema +} + +func (s JSONSchema) MarshalJSON() ([]byte, error) { + if s.Spec == nil { + return []byte("{}"), nil + } + body, err := s.Spec.MarshalJSON() + if err == nil { + // The internal format puts $schema last! + // this moves $schema first + cpy := map[string]any{} + err := json.Unmarshal(body, &cpy) + if err == nil { + return json.Marshal(cpy) + } + } + return body, err +} + +func (s *JSONSchema) UnmarshalJSON(data []byte) error { + s.Spec = &spec.Schema{} + return s.Spec.UnmarshalJSON(data) +} + +func (s JSONSchema) OpenAPIDefinition() openapi.OpenAPIDefinition { + return openapi.OpenAPIDefinition{Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: spec.MustCreateRef(draft04), + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: true}, + }, + }} +} + +func (s *JSONSchema) DeepCopy() *JSONSchema { + if s == nil { + return nil + } + out := &JSONSchema{} + if s.Spec != nil { + out.Spec = &spec.Schema{} + jj, err := json.Marshal(s.Spec) + if err == nil { + _ = json.Unmarshal(jj, out.Spec) + } + } + return out +} + +func (s *JSONSchema) DeepCopyInto(out *JSONSchema) { + if s.Spec == nil { + out.Spec = nil + return + } + out.Spec = s.DeepCopy().Spec +} diff --git a/experimental/apis/data/v0alpha1/schema_test.go b/experimental/apis/data/v0alpha1/schema_test.go new file mode 100644 index 000000000..ced3e0437 --- /dev/null +++ b/experimental/apis/data/v0alpha1/schema_test.go @@ -0,0 +1,31 @@ +package v0alpha1 + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +func TestSchemaSupport(t *testing.T) { + val := JSONSchema{ + Spec: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "hello", + Schema: draft04, + ID: "something", + }, + }, + } + jj, err := json.MarshalIndent(val, "", "") + require.NoError(t, err) + + fmt.Printf("%s\n", string(jj)) + + cpy := &JSONSchema{} + err = cpy.UnmarshalJSON(jj) + require.NoError(t, err) + require.Equal(t, val.Spec.Description, cpy.Spec.Description) +} diff --git a/experimental/apis/data/v0alpha1/testdata/sample_query_results.json b/experimental/apis/data/v0alpha1/testdata/sample_query_results.json new file mode 100644 index 000000000..4d0fd14f5 --- /dev/null +++ b/experimental/apis/data/v0alpha1/testdata/sample_query_results.json @@ -0,0 +1,51 @@ +{ + "status": 200, + "frames": [ + { + "schema": { + "refId": "A", + "meta": { + "typeVersion": [0, 0], + "custom": { + "customStat": 10 + } + }, + "fields": [ + { + "name": "time", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + }, + "config": { + "interval": 1800000 + } + }, + { + "name": "A-series", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + }, + "labels": {} + } + ] + }, + "data": { + "values": [ + [ + 1708955198367, 1708956998367, 1708958798367, 1708960598367, 1708962398367, 1708964198367, 1708965998367, + 1708967798367, 1708969598367, 1708971398367, 1708973198367, 1708974998367 + ], + [ + 8.675906980661981, 8.294773885233445, 8.273583516218238, 8.689987124182915, 9.139162216770474, + 8.822382059628058, 8.362948329273713, 8.443914703179315, 8.457037544672227, 8.17480477193586, + 7.965107052488668, 8.029678541545398 + ] + ] + } + } + ] +} diff --git a/experimental/apis/data/v0alpha1/unstructured.go b/experimental/apis/data/v0alpha1/unstructured.go new file mode 100644 index 000000000..dbf87430c --- /dev/null +++ b/experimental/apis/data/v0alpha1/unstructured.go @@ -0,0 +1,81 @@ +package v0alpha1 + +import ( + "encoding/json" + + openapi "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +// Unstructured allows objects that do not have Golang structs registered to be manipulated +// generically. +type Unstructured struct { + // Object is a JSON compatible map with string, float, int, bool, []interface{}, + // or map[string]interface{} children. + Object map[string]any +} + +// Create an unstructured value from any input +func AsUnstructured(v any) Unstructured { + out := Unstructured{} + body, err := json.Marshal(v) + if err == nil { + _ = json.Unmarshal(body, &out.Object) + } + return out +} + +// Produce an API definition that represents map[string]any +func (u Unstructured) OpenAPIDefinition() openapi.OpenAPIDefinition { + return openapi.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: true}, + }, + }, + } +} + +func (u *Unstructured) UnstructuredContent() map[string]interface{} { + if u.Object == nil { + return make(map[string]interface{}) + } + return u.Object +} + +func (u *Unstructured) SetUnstructuredContent(content map[string]interface{}) { + u.Object = content +} + +// MarshalJSON ensures that the unstructured object produces proper +// JSON when passed to Go's standard JSON library. +func (u Unstructured) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Object) +} + +// UnmarshalJSON ensures that the unstructured object properly decodes +// JSON when passed to Go's standard JSON library. +func (u *Unstructured) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &u.Object) +} + +func (u *Unstructured) DeepCopy() *Unstructured { + if u == nil { + return nil + } + out := new(Unstructured) + u.DeepCopyInto(out) + return out +} + +func (u *Unstructured) DeepCopyInto(out *Unstructured) { + obj := map[string]any{} + if u.Object != nil { + jj, err := json.Marshal(u.Object) + if err == nil { + _ = json.Unmarshal(jj, &obj) + } + } + out.Object = obj +} diff --git a/experimental/apis/data/v0alpha1/zz_generated.deepcopy.go b/experimental/apis/data/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..aaf8295c5 --- /dev/null +++ b/experimental/apis/data/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,187 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CommonQueryProperties) DeepCopyInto(out *CommonQueryProperties) { + *out = *in + if in.ResultAssertions != nil { + in, out := &in.ResultAssertions, &out.ResultAssertions + *out = new(ResultAssertions) + (*in).DeepCopyInto(*out) + } + if in.TimeRange != nil { + in, out := &in.TimeRange, &out.TimeRange + *out = new(TimeRange) + **out = **in + } + if in.Datasource != nil { + in, out := &in.Datasource, &out.Datasource + *out = new(DataSourceRef) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommonQueryProperties. +func (in *CommonQueryProperties) DeepCopy() *CommonQueryProperties { + if in == nil { + return nil + } + out := new(CommonQueryProperties) + in.DeepCopyInto(out) + return out +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataQuery. +func (in *DataQuery) DeepCopy() *DataQuery { + if in == nil { + return nil + } + out := new(DataQuery) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryDataRequest) DeepCopyInto(out *QueryDataRequest) { + *out = *in + out.TimeRange = in.TimeRange + if in.Queries != nil { + in, out := &in.Queries, &out.Queries + *out = make([]DataQuery, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryDataRequest. +func (in *QueryDataRequest) DeepCopy() *QueryDataRequest { + if in == nil { + return nil + } + out := new(QueryDataRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceRef) DeepCopyInto(out *DataSourceRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceRef. +func (in *DataSourceRef) DeepCopy() *DataSourceRef { + if in == nil { + return nil + } + out := new(DataSourceRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DiscriminatorFieldValue) DeepCopyInto(out *DiscriminatorFieldValue) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiscriminatorFieldValue. +func (in *DiscriminatorFieldValue) DeepCopy() *DiscriminatorFieldValue { + if in == nil { + return nil + } + out := new(DiscriminatorFieldValue) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryExample) DeepCopyInto(out *QueryExample) { + *out = *in + in.SaveModel.DeepCopyInto(&out.SaveModel) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryExample. +func (in *QueryExample) DeepCopy() *QueryExample { + if in == nil { + return nil + } + out := new(QueryExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryTypeDefinitionSpec) DeepCopyInto(out *QueryTypeDefinitionSpec) { + *out = *in + if in.Discriminators != nil { + in, out := &in.Discriminators, &out.Discriminators + *out = make([]DiscriminatorFieldValue, len(*in)) + copy(*out, *in) + } + in.Schema.DeepCopyInto(&out.Schema) + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]QueryExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Changelog != nil { + in, out := &in.Changelog, &out.Changelog + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryTypeDefinitionSpec. +func (in *QueryTypeDefinitionSpec) DeepCopy() *QueryTypeDefinitionSpec { + if in == nil { + return nil + } + out := new(QueryTypeDefinitionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResultAssertions) DeepCopyInto(out *ResultAssertions) { + *out = *in + out.TypeVersion = in.TypeVersion + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResultAssertions. +func (in *ResultAssertions) DeepCopy() *ResultAssertions { + if in == nil { + return nil + } + out := new(ResultAssertions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeRange) DeepCopyInto(out *TimeRange) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeRange. +func (in *TimeRange) DeepCopy() *TimeRange { + if in == nil { + return nil + } + out := new(TimeRange) + in.DeepCopyInto(out) + return out +} diff --git a/experimental/schemabuilder/enums.go b/experimental/schemabuilder/enums.go new file mode 100644 index 000000000..b0097ce87 --- /dev/null +++ b/experimental/schemabuilder/enums.go @@ -0,0 +1,152 @@ +package schemabuilder + +import ( + "fmt" + "io/fs" + gopath "path" + "path/filepath" + "regexp" + "strings" + + "go/ast" + "go/doc" + "go/parser" + "go/token" + + "github.com/invopop/jsonschema" +) + +type EnumValue struct { + Value string + Comment string +} + +type EnumField struct { + Package string + Name string + Comment string + Values []EnumValue +} + +func findEnumFields(base, startpath string) ([]EnumField, error) { + fset := token.NewFileSet() + dict := make(map[string][]*ast.Package) + err := filepath.Walk(startpath, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + d, err := parser.ParseDir(fset, path, nil, parser.ParseComments) + if err != nil { + return err + } + for _, v := range d { + // paths may have multiple packages, like for tests + k := gopath.Join(base, strings.TrimPrefix(path, startpath)) + dict[k] = append(dict[k], v) + } + } + return nil + }) + if err != nil { + return nil, err + } + + fields := make([]EnumField, 0) + field := &EnumField{} + dp := &doc.Package{} + + for pkg, p := range dict { + for _, f := range p { + gtxt := "" + typ := "" + ast.Inspect(f, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.TypeSpec: + typ = x.Name.String() + if !ast.IsExported(typ) { + typ = "" + } else { + txt := x.Doc.Text() + if txt == "" && gtxt != "" { + txt = gtxt + gtxt = "" + } + txt = strings.TrimSpace(txt) + if strings.HasSuffix(txt, "+enum") { + txt = dp.Synopsis(txt) + fields = append(fields, EnumField{ + Package: pkg, + Name: typ, + Comment: strings.TrimSpace(strings.TrimSuffix(txt, "+enum")), + }) + field = &fields[len(fields)-1] + } + } + case *ast.ValueSpec: + txt := x.Doc.Text() + if txt == "" { + txt = x.Comment.Text() + } + if typ == field.Name && len(x.Values) > 0 { + for _, n := range x.Names { + if ast.IsExported(n.String()) { + v, ok := x.Values[0].(*ast.BasicLit) + if ok { + val := strings.TrimPrefix(v.Value, `"`) + val = strings.TrimSuffix(val, `"`) + txt = strings.TrimSpace(txt) + field.Values = append(field.Values, EnumValue{ + Value: val, + Comment: txt, + }) + } + } + } + } + case *ast.GenDecl: + // remember for the next type + gtxt = x.Doc.Text() + } + return true + }) + } + } + + return fields, nil +} + +// whitespaceRegex is the regex for consecutive whitespaces. +var whitespaceRegex = regexp.MustCompile(`\s+`) + +func updateEnumDescriptions(s *jsonschema.Schema) { + if len(s.Enum) > 0 && s.Extras != nil { + extra, ok := s.Extras["x-enum-description"] + if !ok { + return + } + + lookup, ok := extra.(map[string]string) + if !ok { + return + } + + lines := []string{} + if s.Description != "" { + lines = append(lines, s.Description, "\n") + } + lines = append(lines, "Possible enum values:") + for _, v := range s.Enum { + c := lookup[v.(string)] + c = whitespaceRegex.ReplaceAllString(c, " ") + lines = append(lines, fmt.Sprintf(" - `%q` %s", v, c)) + } + + s.Description = strings.Join(lines, "\n") + return + } + + for pair := s.Properties.Oldest(); pair != nil; pair = pair.Next() { + updateEnumDescriptions(pair.Value) + } +} diff --git a/experimental/schemabuilder/enums_test.go b/experimental/schemabuilder/enums_test.go new file mode 100644 index 000000000..0d0d0b6c3 --- /dev/null +++ b/experimental/schemabuilder/enums_test.go @@ -0,0 +1,37 @@ +package schemabuilder + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindEnums(t *testing.T) { + t.Run("data", func(t *testing.T) { + fields, err := findEnumFields( + "github.com/grafana/grafana-plugin-sdk-go/data", + "../../data") + require.NoError(t, err) + + out, err := json.MarshalIndent(fields, "", " ") + require.NoError(t, err) + fmt.Printf("%s", string(out)) + + require.Equal(t, 1, len(fields)) + }) + + t.Run("example", func(t *testing.T) { + fields, err := findEnumFields( + "github.com/grafana/grafana-plugin-sdk-go/experimental/schemabuilder/example", + "./example") + require.NoError(t, err) + + out, err := json.MarshalIndent(fields, "", " ") + require.NoError(t, err) + fmt.Printf("%s", string(out)) + + require.Equal(t, 3, len(fields)) + }) +} diff --git a/experimental/schemabuilder/example/query.go b/experimental/schemabuilder/example/query.go new file mode 100644 index 000000000..a9bbbfd75 --- /dev/null +++ b/experimental/schemabuilder/example/query.go @@ -0,0 +1,85 @@ +package example + +import "github.com/grafana/grafana-plugin-sdk-go/data" + +// +enum +type QueryType string + +const ( + // Math query type + QueryTypeMath QueryType = "math" + + // Reduce query type + QueryTypeReduce QueryType = "reduce" + + // Reduce query type + QueryTypeResample QueryType = "resample" +) + +type MathQuery struct { + // General math expression + Expression string `json:"expression" jsonschema:"minLength=1,example=$A + 1,example=$A/$B"` +} + +type ReduceQuery struct { + // Reference to other query results + Expression string `json:"expression"` + + // The reducer + Reducer ReducerID `json:"reducer"` + + // Reducer Options + Settings ReduceSettings `json:"settings"` +} + +type ReduceSettings struct { + // Non-number reduce behavior + Mode ReduceMode `json:"mode"` + + // Only valid when mode is replace + ReplaceWithValue *float64 `json:"replaceWithValue,omitempty"` +} + +// The reducer function +// +enum +type ReducerID string + +const ( + // The sum + ReducerSum ReducerID = "sum" + // The mean + ReducerMean ReducerID = "mean" + ReducerMin ReducerID = "min" + ReducerMax ReducerID = "max" + ReducerCount ReducerID = "count" + ReducerLast ReducerID = "last" +) + +// Non-Number behavior mode +// +enum +type ReduceMode string + +const ( + // Drop non-numbers + ReduceModeDrop ReduceMode = "dropNN" + + // Replace non-numbers + ReduceModeReplace ReduceMode = "replaceNN" +) + +// QueryType = resample +type ResampleQuery struct { + // The math expression + Expression string `json:"expression"` + + // A time duration string + Window string `json:"window"` + + // The reducer + Downsampler string `json:"downsampler"` + + // The reducer + Upsampler string `json:"upsampler"` + + LoadedDimensions *data.Frame `json:"loadedDimensions"` +} diff --git a/experimental/schemabuilder/example/query.panel.example.json b/experimental/schemabuilder/example/query.panel.example.json new file mode 100644 index 000000000..5999e8854 --- /dev/null +++ b/experimental/schemabuilder/example/query.panel.example.json @@ -0,0 +1,36 @@ +{ + "type": "table", + "targets": [ + { + "refId": "A", + "datasource": { + "type": "__expr__", + "uid": "TheUID" + }, + "queryType": "math", + "expression": "$A + 11" + }, + { + "refId": "B", + "datasource": { + "type": "__expr__", + "uid": "TheUID" + }, + "queryType": "math", + "expression": "$A - $B" + }, + { + "refId": "C", + "datasource": { + "type": "__expr__", + "uid": "TheUID" + }, + "queryType": "reduce", + "expression": "$A", + "reducer": "max", + "settings": { + "mode": "dropNN" + } + } + ] +} \ No newline at end of file diff --git a/experimental/schemabuilder/example/query.panel.schema.json b/experimental/schemabuilder/example/query.panel.schema.json new file mode 100644 index 000000000..95f65f40e --- /dev/null +++ b/experimental/schemabuilder/example/query.panel.schema.json @@ -0,0 +1,440 @@ +{ + "type": "object", + "required": [ + "targets", + "type" + ], + "properties": { + "targets": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "expression", + "queryType", + "refId" + ], + "properties": { + "datasource": { + "description": "The datasource", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "description": "The datasource plugin type", + "type": "string", + "pattern": "^__expr__$" + }, + "uid": { + "description": "Datasource UID", + "type": "string" + } + }, + "additionalProperties": false + }, + "expression": { + "description": "General math expression", + "type": "string", + "minLength": 1, + "examples": [ + "$A + 1", + "$A/$B" + ] + }, + "hide": { + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)", + "type": "boolean" + }, + "queryType": { + "type": "string", + "pattern": "^math$" + }, + "refId": { + "description": "RefID is the unique identifier of the query, set by the frontend call.", + "type": "string" + }, + "resultAssertions": { + "description": "Optionally define expected query result behavior", + "type": "object", + "required": [ + "typeVersion" + ], + "properties": { + "maxFrames": { + "description": "Maximum frame count", + "type": "integer" + }, + "type": { + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "x-enum-description": {} + }, + "typeVersion": { + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/.", + "type": "array", + "maxItems": 2, + "minItems": 2, + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, + "timeRange": { + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "From is the start time of the query.", + "type": "string", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "description": "To is the end time of the query.", + "type": "string", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema" + }, + { + "type": "object", + "required": [ + "expression", + "reducer", + "settings", + "queryType", + "refId" + ], + "properties": { + "datasource": { + "description": "The datasource", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "description": "The datasource plugin type", + "type": "string", + "pattern": "^__expr__$" + }, + "uid": { + "description": "Datasource UID", + "type": "string" + } + }, + "additionalProperties": false + }, + "expression": { + "description": "Reference to other query results", + "type": "string" + }, + "hide": { + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)", + "type": "boolean" + }, + "queryType": { + "type": "string", + "pattern": "^reduce$" + }, + "reducer": { + "description": "The reducer\n\n\nPossible enum values:\n - `\"sum\"` The sum\n - `\"mean\"` The mean\n - `\"min\"` \n - `\"max\"` \n - `\"count\"` \n - `\"last\"` ", + "type": "string", + "enum": [ + "sum", + "mean", + "min", + "max", + "count", + "last" + ], + "x-enum-description": { + "mean": "The mean", + "sum": "The sum" + } + }, + "refId": { + "description": "RefID is the unique identifier of the query, set by the frontend call.", + "type": "string" + }, + "resultAssertions": { + "description": "Optionally define expected query result behavior", + "type": "object", + "required": [ + "typeVersion" + ], + "properties": { + "maxFrames": { + "description": "Maximum frame count", + "type": "integer" + }, + "type": { + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "x-enum-description": {} + }, + "typeVersion": { + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/.", + "type": "array", + "maxItems": 2, + "minItems": 2, + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, + "settings": { + "description": "Reducer Options", + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "description": "Non-number reduce behavior\n\n\nPossible enum values:\n - `\"dropNN\"` Drop non-numbers\n - `\"replaceNN\"` Replace non-numbers", + "type": "string", + "enum": [ + "dropNN", + "replaceNN" + ], + "x-enum-description": { + "dropNN": "Drop non-numbers", + "replaceNN": "Replace non-numbers" + } + }, + "replaceWithValue": { + "description": "Only valid when mode is replace", + "type": "number" + } + }, + "additionalProperties": false + }, + "timeRange": { + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "From is the start time of the query.", + "type": "string", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "description": "To is the end time of the query.", + "type": "string", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema" + }, + { + "description": "QueryType = resample", + "type": "object", + "required": [ + "expression", + "window", + "downsampler", + "upsampler", + "loadedDimensions", + "queryType", + "refId" + ], + "properties": { + "datasource": { + "description": "The datasource", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "description": "The datasource plugin type", + "type": "string", + "pattern": "^__expr__$" + }, + "uid": { + "description": "Datasource UID", + "type": "string" + } + }, + "additionalProperties": false + }, + "downsampler": { + "description": "The reducer", + "type": "string" + }, + "expression": { + "description": "The math expression", + "type": "string" + }, + "hide": { + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)", + "type": "boolean" + }, + "loadedDimensions": { + "type": "object", + "additionalProperties": true, + "x-grafana-type": "data.DataFrame" + }, + "queryType": { + "type": "string", + "pattern": "^resample$" + }, + "refId": { + "description": "RefID is the unique identifier of the query, set by the frontend call.", + "type": "string" + }, + "resultAssertions": { + "description": "Optionally define expected query result behavior", + "type": "object", + "required": [ + "typeVersion" + ], + "properties": { + "maxFrames": { + "description": "Maximum frame count", + "type": "integer" + }, + "type": { + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "x-enum-description": {} + }, + "typeVersion": { + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/.", + "type": "array", + "maxItems": 2, + "minItems": 2, + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, + "timeRange": { + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "From is the start time of the query.", + "type": "string", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "description": "To is the end time of the query.", + "type": "string", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false + }, + "upsampler": { + "description": "The reducer", + "type": "string" + }, + "window": { + "description": "A time duration string", + "type": "string" + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema" + } + ], + "$schema": "https://json-schema.org/draft-04/schema#" + } + }, + "type": { + "description": "the panel type", + "type": "string" + } + }, + "additionalProperties": true, + "$schema": "https://json-schema.org/draft-04/schema#" +} \ No newline at end of file diff --git a/experimental/schemabuilder/example/query.request.example.json b/experimental/schemabuilder/example/query.request.example.json new file mode 100644 index 000000000..4bbf33a43 --- /dev/null +++ b/experimental/schemabuilder/example/query.request.example.json @@ -0,0 +1,31 @@ +{ + "from": "now-1h", + "to": "now", + "queries": [ + { + "refId": "A", + "queryType": "math", + "maxDataPoints": 1000, + "intervalMs": 5, + "expression": "$A + 11" + }, + { + "refId": "B", + "queryType": "math", + "maxDataPoints": 1000, + "intervalMs": 5, + "expression": "$A - $B" + }, + { + "refId": "C", + "queryType": "reduce", + "maxDataPoints": 1000, + "intervalMs": 5, + "expression": "$A", + "reducer": "max", + "settings": { + "mode": "dropNN" + } + } + ] +} \ No newline at end of file diff --git a/experimental/schemabuilder/example/query.request.schema.json b/experimental/schemabuilder/example/query.request.schema.json new file mode 100644 index 000000000..c88ae04fb --- /dev/null +++ b/experimental/schemabuilder/example/query.request.schema.json @@ -0,0 +1,474 @@ +{ + "type": "object", + "required": [ + "queries" + ], + "properties": { + "$schema": { + "description": "helper", + "type": "string" + }, + "debug": { + "type": "boolean" + }, + "from": { + "description": "From Start time in epoch timestamps in milliseconds or relative using Grafana time units.", + "type": "string" + }, + "queries": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "expression", + "queryType", + "refId" + ], + "properties": { + "datasource": { + "description": "The datasource", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "description": "The datasource plugin type", + "type": "string", + "pattern": "^__expr__$" + }, + "uid": { + "description": "Datasource UID", + "type": "string" + } + }, + "additionalProperties": false + }, + "expression": { + "description": "General math expression", + "type": "string", + "minLength": 1, + "examples": [ + "$A + 1", + "$A/$B" + ] + }, + "hide": { + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)", + "type": "boolean" + }, + "intervalMs": { + "description": "Interval is the suggested duration between time points in a time series query.\nNOTE: the values for intervalMs is not saved in the query model. It is typically calculated\nfrom the interval required to fill a pixels in the visualization", + "type": "number" + }, + "maxDataPoints": { + "description": "MaxDataPoints is the maximum number of data points that should be returned from a time series query.\nNOTE: the values for maxDataPoints is not saved in the query model. It is typically calculated\nfrom the number of pixels visible in a visualization", + "type": "integer" + }, + "queryType": { + "type": "string", + "pattern": "^math$" + }, + "refId": { + "description": "RefID is the unique identifier of the query, set by the frontend call.", + "type": "string" + }, + "resultAssertions": { + "description": "Optionally define expected query result behavior", + "type": "object", + "required": [ + "typeVersion" + ], + "properties": { + "maxFrames": { + "description": "Maximum frame count", + "type": "integer" + }, + "type": { + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "x-enum-description": {} + }, + "typeVersion": { + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/.", + "type": "array", + "maxItems": 2, + "minItems": 2, + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, + "timeRange": { + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "From is the start time of the query.", + "type": "string", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "description": "To is the end time of the query.", + "type": "string", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema" + }, + { + "type": "object", + "required": [ + "expression", + "reducer", + "settings", + "queryType", + "refId" + ], + "properties": { + "datasource": { + "description": "The datasource", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "description": "The datasource plugin type", + "type": "string", + "pattern": "^__expr__$" + }, + "uid": { + "description": "Datasource UID", + "type": "string" + } + }, + "additionalProperties": false + }, + "expression": { + "description": "Reference to other query results", + "type": "string" + }, + "hide": { + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)", + "type": "boolean" + }, + "intervalMs": { + "description": "Interval is the suggested duration between time points in a time series query.\nNOTE: the values for intervalMs is not saved in the query model. It is typically calculated\nfrom the interval required to fill a pixels in the visualization", + "type": "number" + }, + "maxDataPoints": { + "description": "MaxDataPoints is the maximum number of data points that should be returned from a time series query.\nNOTE: the values for maxDataPoints is not saved in the query model. It is typically calculated\nfrom the number of pixels visible in a visualization", + "type": "integer" + }, + "queryType": { + "type": "string", + "pattern": "^reduce$" + }, + "reducer": { + "description": "The reducer\n\n\nPossible enum values:\n - `\"sum\"` The sum\n - `\"mean\"` The mean\n - `\"min\"` \n - `\"max\"` \n - `\"count\"` \n - `\"last\"` ", + "type": "string", + "enum": [ + "sum", + "mean", + "min", + "max", + "count", + "last" + ], + "x-enum-description": { + "mean": "The mean", + "sum": "The sum" + } + }, + "refId": { + "description": "RefID is the unique identifier of the query, set by the frontend call.", + "type": "string" + }, + "resultAssertions": { + "description": "Optionally define expected query result behavior", + "type": "object", + "required": [ + "typeVersion" + ], + "properties": { + "maxFrames": { + "description": "Maximum frame count", + "type": "integer" + }, + "type": { + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "x-enum-description": {} + }, + "typeVersion": { + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/.", + "type": "array", + "maxItems": 2, + "minItems": 2, + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, + "settings": { + "description": "Reducer Options", + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "description": "Non-number reduce behavior\n\n\nPossible enum values:\n - `\"dropNN\"` Drop non-numbers\n - `\"replaceNN\"` Replace non-numbers", + "type": "string", + "enum": [ + "dropNN", + "replaceNN" + ], + "x-enum-description": { + "dropNN": "Drop non-numbers", + "replaceNN": "Replace non-numbers" + } + }, + "replaceWithValue": { + "description": "Only valid when mode is replace", + "type": "number" + } + }, + "additionalProperties": false + }, + "timeRange": { + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "From is the start time of the query.", + "type": "string", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "description": "To is the end time of the query.", + "type": "string", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema" + }, + { + "description": "QueryType = resample", + "type": "object", + "required": [ + "expression", + "window", + "downsampler", + "upsampler", + "loadedDimensions", + "queryType", + "refId" + ], + "properties": { + "datasource": { + "description": "The datasource", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "description": "The datasource plugin type", + "type": "string", + "pattern": "^__expr__$" + }, + "uid": { + "description": "Datasource UID", + "type": "string" + } + }, + "additionalProperties": false + }, + "downsampler": { + "description": "The reducer", + "type": "string" + }, + "expression": { + "description": "The math expression", + "type": "string" + }, + "hide": { + "description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)", + "type": "boolean" + }, + "intervalMs": { + "description": "Interval is the suggested duration between time points in a time series query.\nNOTE: the values for intervalMs is not saved in the query model. It is typically calculated\nfrom the interval required to fill a pixels in the visualization", + "type": "number" + }, + "loadedDimensions": { + "type": "object", + "additionalProperties": true, + "x-grafana-type": "data.DataFrame" + }, + "maxDataPoints": { + "description": "MaxDataPoints is the maximum number of data points that should be returned from a time series query.\nNOTE: the values for maxDataPoints is not saved in the query model. It is typically calculated\nfrom the number of pixels visible in a visualization", + "type": "integer" + }, + "queryType": { + "type": "string", + "pattern": "^resample$" + }, + "refId": { + "description": "RefID is the unique identifier of the query, set by the frontend call.", + "type": "string" + }, + "resultAssertions": { + "description": "Optionally define expected query result behavior", + "type": "object", + "required": [ + "typeVersion" + ], + "properties": { + "maxFrames": { + "description": "Maximum frame count", + "type": "integer" + }, + "type": { + "description": "Type asserts that the frame matches a known type structure.\n\n\nPossible enum values:\n - `\"\"` \n - `\"timeseries-wide\"` \n - `\"timeseries-long\"` \n - `\"timeseries-many\"` \n - `\"timeseries-multi\"` \n - `\"directory-listing\"` \n - `\"table\"` \n - `\"numeric-wide\"` \n - `\"numeric-multi\"` \n - `\"numeric-long\"` \n - `\"log-lines\"` ", + "type": "string", + "enum": [ + "", + "timeseries-wide", + "timeseries-long", + "timeseries-many", + "timeseries-multi", + "directory-listing", + "table", + "numeric-wide", + "numeric-multi", + "numeric-long", + "log-lines" + ], + "x-enum-description": {} + }, + "typeVersion": { + "description": "TypeVersion is the version of the Type property. Versions greater than 0.0 correspond to the dataplane\ncontract documentation https://grafana.github.io/dataplane/contract/.", + "type": "array", + "maxItems": 2, + "minItems": 2, + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, + "timeRange": { + "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "From is the start time of the query.", + "type": "string", + "default": "now-6h", + "examples": [ + "now-1h" + ] + }, + "to": { + "description": "To is the end time of the query.", + "type": "string", + "default": "now", + "examples": [ + "now" + ] + } + }, + "additionalProperties": false + }, + "upsampler": { + "description": "The reducer", + "type": "string" + }, + "window": { + "description": "A time duration string", + "type": "string" + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema" + } + ], + "$schema": "https://json-schema.org/draft-04/schema#" + } + }, + "to": { + "description": "To end time in epoch timestamps in milliseconds or relative using Grafana time units.", + "type": "string" + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft-04/schema#" +} \ No newline at end of file diff --git a/experimental/schemabuilder/example/query.types.json b/experimental/schemabuilder/example/query.types.json new file mode 100644 index 000000000..570c02ab0 --- /dev/null +++ b/experimental/schemabuilder/example/query.types.json @@ -0,0 +1,194 @@ +{ + "kind": "QueryTypeDefinitionList", + "apiVersion": "query.grafana.app/v0alpha1", + "metadata": { + "resourceVersion": "1709230013217" + }, + "items": [ + { + "metadata": { + "name": "math", + "resourceVersion": "1709251645142", + "creationTimestamp": "2024-02-29T18:06:53Z" + }, + "spec": { + "discriminators": [ + { + "field": "queryType", + "value": "math" + } + ], + "schema": { + "$schema": "https://json-schema.org/draft-04/schema", + "additionalProperties": false, + "properties": { + "expression": { + "description": "General math expression", + "examples": [ + "$A + 1", + "$A/$B" + ], + "minLength": 1, + "type": "string" + } + }, + "required": [ + "expression" + ], + "type": "object" + }, + "examples": [ + { + "name": "constant addition", + "saveModel": { + "expression": "$A + 11" + } + }, + { + "name": "math with two queries", + "saveModel": { + "expression": "$A - $B" + } + } + ] + } + }, + { + "metadata": { + "name": "reduce", + "resourceVersion": "1709250388761", + "creationTimestamp": "2024-02-29T18:06:53Z" + }, + "spec": { + "discriminators": [ + { + "field": "queryType", + "value": "reduce" + } + ], + "schema": { + "$schema": "https://json-schema.org/draft-04/schema", + "additionalProperties": false, + "properties": { + "expression": { + "description": "Reference to other query results", + "type": "string" + }, + "reducer": { + "description": "The reducer\n\n\nPossible enum values:\n - `\"sum\"` The sum\n - `\"mean\"` The mean\n - `\"min\"` \n - `\"max\"` \n - `\"count\"` \n - `\"last\"` ", + "enum": [ + "sum", + "mean", + "min", + "max", + "count", + "last" + ], + "type": "string", + "x-enum-description": { + "mean": "The mean", + "sum": "The sum" + } + }, + "settings": { + "additionalProperties": false, + "description": "Reducer Options", + "properties": { + "mode": { + "description": "Non-number reduce behavior\n\n\nPossible enum values:\n - `\"dropNN\"` Drop non-numbers\n - `\"replaceNN\"` Replace non-numbers", + "enum": [ + "dropNN", + "replaceNN" + ], + "type": "string", + "x-enum-description": { + "dropNN": "Drop non-numbers", + "replaceNN": "Replace non-numbers" + } + }, + "replaceWithValue": { + "description": "Only valid when mode is replace", + "type": "number" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + }, + "required": [ + "expression", + "reducer", + "settings" + ], + "type": "object" + }, + "examples": [ + { + "name": "get max value", + "saveModel": { + "expression": "$A", + "reducer": "max", + "settings": { + "mode": "dropNN" + } + } + } + ] + } + }, + { + "metadata": { + "name": "resample", + "resourceVersion": "1709252275481", + "creationTimestamp": "2024-02-29T18:06:53Z" + }, + "spec": { + "discriminators": [ + { + "field": "queryType", + "value": "resample" + } + ], + "schema": { + "$schema": "https://json-schema.org/draft-04/schema", + "additionalProperties": false, + "description": "QueryType = resample", + "properties": { + "downsampler": { + "description": "The reducer", + "type": "string" + }, + "expression": { + "description": "The math expression", + "type": "string" + }, + "loadedDimensions": { + "additionalProperties": true, + "type": "object", + "x-grafana-type": "data.DataFrame" + }, + "upsampler": { + "description": "The reducer", + "type": "string" + }, + "window": { + "description": "A time duration string", + "type": "string" + } + }, + "required": [ + "expression", + "window", + "downsampler", + "upsampler", + "loadedDimensions" + ], + "type": "object" + }, + "examples": [] + } + } + ] +} \ No newline at end of file diff --git a/experimental/schemabuilder/example/query_test.go b/experimental/schemabuilder/example/query_test.go new file mode 100644 index 000000000..039c862b6 --- /dev/null +++ b/experimental/schemabuilder/example/query_test.go @@ -0,0 +1,67 @@ +package example + +import ( + "reflect" + "testing" + + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/grafana/grafana-plugin-sdk-go/experimental/schemabuilder" + "github.com/stretchr/testify/require" +) + +func TestQueryTypeDefinitions(t *testing.T) { + builder, err := schemabuilder.NewSchemaBuilder(schemabuilder.BuilderOptions{ + PluginID: []string{"__expr__"}, + ScanCode: []schemabuilder.CodePaths{{ + BasePackage: "github.com/grafana/grafana-plugin-sdk-go/experimental/schemabuilder/example", + CodePath: "./", + }}, + Enums: []reflect.Type{ + reflect.TypeOf(ReducerSum), // pick an example value (not the root) + reflect.TypeOf(ReduceModeDrop), // pick an example value (not the root) + }, + }) + require.NoError(t, err) + err = builder.AddQueries(schemabuilder.QueryTypeInfo{ + Discriminators: data.NewDiscriminators("queryType", QueryTypeMath), + GoType: reflect.TypeOf(&MathQuery{}), + Examples: []data.QueryExample{ + { + Name: "constant addition", + SaveModel: data.AsUnstructured(MathQuery{ + Expression: "$A + 11", + }), + }, + { + Name: "math with two queries", + SaveModel: data.AsUnstructured(MathQuery{ + Expression: "$A - $B", + }), + }, + }, + }, + schemabuilder.QueryTypeInfo{ + Discriminators: data.NewDiscriminators("queryType", QueryTypeReduce), + GoType: reflect.TypeOf(&ReduceQuery{}), + Examples: []data.QueryExample{ + { + Name: "get max value", + SaveModel: data.AsUnstructured(ReduceQuery{ + Expression: "$A", + Reducer: ReducerMax, + Settings: ReduceSettings{ + Mode: ReduceModeDrop, + }, + }), + }, + }, + }, + schemabuilder.QueryTypeInfo{ + Discriminators: data.NewDiscriminators("queryType", QueryTypeResample), + GoType: reflect.TypeOf(&ResampleQuery{}), + Examples: []data.QueryExample{}, + }) + require.NoError(t, err) + + _ = builder.UpdateQueryDefinition(t, "./") +} diff --git a/experimental/schemabuilder/examples.go b/experimental/schemabuilder/examples.go new file mode 100644 index 000000000..95f9eab6e --- /dev/null +++ b/experimental/schemabuilder/examples.go @@ -0,0 +1,56 @@ +package schemabuilder + +import ( + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" +) + +func exampleRequest(defs data.QueryTypeDefinitionList) data.QueryDataRequest { + rsp := data.QueryDataRequest{ + TimeRange: data.TimeRange{ + From: "now-1h", + To: "now", + }, + Queries: []data.DataQuery{}, + } + + for _, def := range defs.Items { + for _, sample := range def.Spec.Examples { + if sample.SaveModel.Object != nil { + q := data.NewDataQuery(sample.SaveModel.Object) + q.RefID = string(rune('A' + len(rsp.Queries))) + for _, dis := range def.Spec.Discriminators { + _ = q.Set(dis.Field, dis.Value) + } + + if q.MaxDataPoints < 1 { + q.MaxDataPoints = 1000 + } + if q.IntervalMS < 1 { + q.IntervalMS = 5 + } + + rsp.Queries = append(rsp.Queries, q) + } + } + } + return rsp +} + +func examplePanelTargets(ds *data.DataSourceRef, defs data.QueryTypeDefinitionList) []data.DataQuery { + targets := []data.DataQuery{} + + for _, def := range defs.Items { + for _, sample := range def.Spec.Examples { + if sample.SaveModel.Object != nil { + q := data.NewDataQuery(sample.SaveModel.Object) + q.Datasource = ds + q.RefID = string(rune('A' + len(targets))) + for _, dis := range def.Spec.Discriminators { + _ = q.Set(dis.Field, dis.Value) + } + targets = append(targets, q) + } + } + } + return targets +} diff --git a/experimental/schemabuilder/panel.go b/experimental/schemabuilder/panel.go new file mode 100644 index 000000000..e3e0a4d2f --- /dev/null +++ b/experimental/schemabuilder/panel.go @@ -0,0 +1,22 @@ +package schemabuilder + +import sdkapi "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + +// This is only used to write out a sample panel query +// It is not public and not intended to represent a real panel +type pseudoPanel struct { + // Numeric panel id + ID int `json:"id,omitempty"` + + // The panel plugin type + Type string `json:"type"` + + // The panel title + Title string `json:"title,omitempty"` + + // This should no longer be necessary since each target has the datasource reference + Datasource *sdkapi.DataSourceRef `json:"datasource,omitempty"` + + // The query targets + Targets []sdkapi.DataQuery `json:"targets"` +} diff --git a/experimental/schemabuilder/reflector.go b/experimental/schemabuilder/reflector.go new file mode 100644 index 000000000..54d51ecf6 --- /dev/null +++ b/experimental/schemabuilder/reflector.go @@ -0,0 +1,346 @@ +package schemabuilder + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/grafana/grafana-plugin-sdk-go/data" + sdkapi "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/kube-openapi/pkg/validation/strfmt" + "k8s.io/kube-openapi/pkg/validation/validate" +) + +// SchemaBuilder is a helper function that can be used by +// backend build processes to produce static schema definitions +// This is not intended as runtime code, and is not the only way to +// produce a schema (we may also want/need to use typescript as the source) +type Builder struct { + opts BuilderOptions + reflector *jsonschema.Reflector // Needed to use comments + query []sdkapi.QueryTypeDefinition +} + +type CodePaths struct { + // ex "github.com/grafana/github-datasource/pkg/models" + BasePackage string + + // ex "./" + CodePath string +} + +type BuilderOptions struct { + // The plugin type ID used in the DataSourceRef type property + PluginID []string + + // Scan comments and enumerations + ScanCode []CodePaths + + // explicitly define the enumeration fields + Enums []reflect.Type +} + +type QueryTypeInfo struct { + // The management name + Name string + // Optional description + Description string + // Optional discriminators + Discriminators []sdkapi.DiscriminatorFieldValue + // Raw GO type used for reflection + GoType reflect.Type + // Add sample queries + Examples []sdkapi.QueryExample +} + +type SettingTypeInfo struct { + // The management name + Name string + // Optional discriminators + Discriminators []sdkapi.DiscriminatorFieldValue + // Raw GO type used for reflection + GoType reflect.Type + // Map[string]string + SecureGoType reflect.Type +} + +func NewSchemaBuilder(opts BuilderOptions) (*Builder, error) { + if len(opts.PluginID) < 1 { + return nil, fmt.Errorf("missing plugin id") + } + + r := new(jsonschema.Reflector) + r.DoNotReference = true + for _, scan := range opts.ScanCode { + if err := r.AddGoComments(scan.BasePackage, scan.CodePath); err != nil { + return nil, err + } + } + customMapper := map[reflect.Type]*jsonschema.Schema{ + reflect.TypeOf(data.Frame{}): { + Type: "object", + Extras: map[string]any{ + "x-grafana-type": "data.DataFrame", + }, + AdditionalProperties: jsonschema.TrueSchema, + }, + reflect.TypeOf(sdkapi.Unstructured{}): { + Type: "object", + AdditionalProperties: jsonschema.TrueSchema, + }, + reflect.TypeOf(sdkapi.JSONSchema{}): { + Type: "object", + Ref: draft04, + }, + } + r.Mapper = func(t reflect.Type) *jsonschema.Schema { + return customMapper[t] + } + + if len(opts.Enums) > 0 { + fields := []EnumField{} + for _, scan := range opts.ScanCode { + enums, err := findEnumFields(scan.BasePackage, scan.CodePath) + if err != nil { + return nil, err + } + fields = append(fields, enums...) + } + + for _, etype := range opts.Enums { + name := etype.Name() + pack := etype.PkgPath() + for _, f := range fields { + if f.Name == name && f.Package == pack { + enumValueDescriptions := map[string]string{} + s := &jsonschema.Schema{ + Type: "string", + Extras: map[string]any{ + "x-enum-description": enumValueDescriptions, + }, + } + for _, val := range f.Values { + s.Enum = append(s.Enum, val.Value) + if val.Comment != "" { + enumValueDescriptions[val.Value] = val.Comment + } + } + customMapper[etype] = s + } + } + } + } + + return &Builder{ + opts: opts, + reflector: r, + }, nil +} + +func (b *Builder) AddQueries(inputs ...QueryTypeInfo) error { + for _, info := range inputs { + schema := b.reflector.ReflectFromType(info.GoType) + if schema == nil { + return fmt.Errorf("missing schema") + } + updateEnumDescriptions(schema) + + name := info.Name + if name == "" { + for _, dis := range info.Discriminators { + if name != "" { + name += "-" + } + name += dis.Value + } + if name == "" { + return fmt.Errorf("missing name or discriminators") + } + } + + // We need to be careful to only use draft-04 so that this is possible to use + // with kube-openapi + schema.Version = draft04 + schema.ID = "" + schema.Anchor = "" + spec, err := asJSONSchema(schema) + if err != nil { + return err + } + + b.query = append(b.query, sdkapi.QueryTypeDefinition{ + ObjectMeta: sdkapi.ObjectMeta{ + Name: name, + }, + Spec: sdkapi.QueryTypeDefinitionSpec{ + Description: info.Description, + Discriminators: info.Discriminators, + Schema: sdkapi.JSONSchema{ + Spec: spec, + }, + Examples: info.Examples, + }, + }) + } + return nil +} + +// Update the schema definition file +// When placed in `static/schema/query.types.json` folder of a plugin distribution, +// it can be used to advertise various query types +// If the spec contents have changed, the test will fail (but still update the output) +func (b *Builder) UpdateQueryDefinition(t *testing.T, outdir string) sdkapi.QueryTypeDefinitionList { + t.Helper() + + outfile := filepath.Join(outdir, "query.types.json") + now := time.Now().UTC() + rv := fmt.Sprintf("%d", now.UnixMilli()) + + defs := sdkapi.QueryTypeDefinitionList{} + byName := make(map[string]*sdkapi.QueryTypeDefinition) + body, err := os.ReadFile(outfile) + if err == nil { + err = json.Unmarshal(body, &defs) + if err == nil { + for i, def := range defs.Items { + byName[def.ObjectMeta.Name] = &defs.Items[i] + } + } + } + defs.Kind = "QueryTypeDefinitionList" + defs.APIVersion = "query.grafana.app/v0alpha1" + + // The updated schemas + for _, def := range b.query { + found, ok := byName[def.ObjectMeta.Name] + if !ok { + defs.ObjectMeta.ResourceVersion = rv + def.ObjectMeta.ResourceVersion = rv + def.ObjectMeta.CreationTimestamp = now.Format(time.RFC3339) + + defs.Items = append(defs.Items, def) + } else { + x := sdkapi.AsUnstructured(def.Spec) + y := sdkapi.AsUnstructured(found.Spec) + if diff := cmp.Diff(stripNilValues(x.Object), stripNilValues(y.Object), cmpopts.EquateEmpty()); diff != "" { + fmt.Printf("Spec changed:\n%s\n", diff) + found.ObjectMeta.ResourceVersion = rv + found.Spec = def.Spec + } + delete(byName, def.ObjectMeta.Name) + } + } + + if defs.ObjectMeta.ResourceVersion == "" { + defs.ObjectMeta.ResourceVersion = rv + } + + if len(byName) > 0 { + require.FailNow(t, "query type removed, manually update (for now)") + } + maybeUpdateFile(t, outfile, defs, body) + + // Update the query save model schema + //------------------------------------ + outfile = filepath.Join(outdir, "query.panel.schema.json") + schema, err := GetQuerySchema(QuerySchemaOptions{ + PluginID: b.opts.PluginID, + QueryTypes: defs.Items, + Mode: SchemaTypePanelModel, + }) + require.NoError(t, err) + + body, _ = os.ReadFile(outfile) + maybeUpdateFile(t, outfile, schema, body) + + panel := pseudoPanel{ + Type: "table", + } + panel.Targets = examplePanelTargets(&sdkapi.DataSourceRef{ + Type: b.opts.PluginID[0], + UID: "TheUID", + }, defs) + + outfile = filepath.Join(outdir, "query.panel.example.json") + body, _ = os.ReadFile(outfile) + maybeUpdateFile(t, outfile, panel, body) + + // Update the request payload schema + //------------------------------------ + outfile = filepath.Join(outdir, "query.request.schema.json") + schema, err = GetQuerySchema(QuerySchemaOptions{ + PluginID: b.opts.PluginID, + QueryTypes: defs.Items, + Mode: SchemaTypeQueryRequest, + }) + require.NoError(t, err) + + body, _ = os.ReadFile(outfile) + maybeUpdateFile(t, outfile, schema, body) + + request := exampleRequest(defs) + outfile = filepath.Join(outdir, "query.request.example.json") + body, _ = os.ReadFile(outfile) + maybeUpdateFile(t, outfile, request, body) + + validator := validate.NewSchemaValidator(schema, nil, "", strfmt.Default) + result := validator.Validate(request) + if result.HasErrorsOrWarnings() { + for _, err := range result.Errors { + assert.NoError(t, err) + } + for _, err := range result.Warnings { + assert.NoError(t, err, "warning") + } + + body, err = json.MarshalIndent(result, "", " ") + require.NoError(t, err) + fmt.Printf("Validation: %s\n", string(body)) + require.Fail(t, "validation failed") + } + require.True(t, result.MatchCount > 0, "must have some rules") + return defs +} + +func maybeUpdateFile(t *testing.T, outfile string, value any, body []byte) { + t.Helper() + + out, err := json.MarshalIndent(value, "", " ") + require.NoError(t, err) + + update := false + if err == nil { + if !assert.JSONEq(t, string(out), string(body)) { + update = true + } + } else { + update = true + } + if update { + err = os.WriteFile(outfile, out, 0600) + require.NoError(t, err, "error writing file") + } +} + +func stripNilValues(input map[string]any) map[string]any { + for k, v := range input { + if v == nil { + delete(input, k) + } else { + sub, ok := v.(map[string]any) + if ok { + stripNilValues(sub) + } + } + } + return input +} diff --git a/experimental/schemabuilder/reflector_test.go b/experimental/schemabuilder/reflector_test.go new file mode 100644 index 000000000..fd61c5b30 --- /dev/null +++ b/experimental/schemabuilder/reflector_test.go @@ -0,0 +1,63 @@ +package schemabuilder + +import ( + "os" + "reflect" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/data" + apisdata "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/require" +) + +func TestWriteQuerySchema(t *testing.T) { + builder, err := NewSchemaBuilder(BuilderOptions{ + PluginID: []string{"dummy"}, + ScanCode: []CodePaths{ + { + BasePackage: "github.com/grafana/grafana-plugin-sdk-go/experimental/apis", + CodePath: "../apis/data/v0alpha1", + }, + { + BasePackage: "github.com/grafana/grafana-plugin-sdk-go/data", + CodePath: "../../data", + }, + }, + Enums: []reflect.Type{ + reflect.TypeOf(data.FrameTypeLogLines), + }, + }) + require.NoError(t, err) + + query := builder.reflector.Reflect(&apisdata.CommonQueryProperties{}) + updateEnumDescriptions(query) + query.ID = "" + query.Version = draft04 // used by kube-openapi + query.Description = "Generic query properties" + query.AdditionalProperties = jsonschema.TrueSchema + + // // Hide this old property + query.Properties.Delete("datasourceId") + + outfile := "../apis/data/v0alpha1/query.schema.json" + old, _ := os.ReadFile(outfile) + maybeUpdateFile(t, outfile, query, old) + + // Make sure the embedded schema is loadable + schema, err := apisdata.DataQuerySchema() + require.NoError(t, err) + require.Equal(t, 8, len(schema.Properties)) + + // Add schema for query type definition + query = builder.reflector.Reflect(&apisdata.QueryTypeDefinitionSpec{}) + updateEnumDescriptions(query) + query.ID = "" + query.Version = draft04 // used by kube-openapi + outfile = "../apis/data/v0alpha1/query.definition.schema.json" + old, _ = os.ReadFile(outfile) + maybeUpdateFile(t, outfile, query, old) + + def := apisdata.GetOpenAPIDefinitions(nil)["github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.QueryTypeDefinitionSpec"] + require.Equal(t, query.Properties.Len(), len(def.Schema.Properties)) +} diff --git a/experimental/schemabuilder/schema.go b/experimental/schemabuilder/schema.go new file mode 100644 index 000000000..fb290bda2 --- /dev/null +++ b/experimental/schemabuilder/schema.go @@ -0,0 +1,190 @@ +package schemabuilder + +import ( + "encoding/json" + "fmt" + + sdkapi "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// The k8s compatible jsonschema version +const draft04 = "https://json-schema.org/draft-04/schema#" + +// Supported expression types +// +enum +type SchemaType string + +const ( + // Single query target saved in a dashboard/panel/alert JSON + SchemaTypeSaveModel SchemaType = "save" + + // Single query payload included in a query request + SchemaTypeQueryPayload SchemaType = "payload" + + // Pseudo panel model including multiple targets (not mixed) + SchemaTypePanelModel SchemaType = "panel" + + // Query request against a single data source (not mixed) + SchemaTypeQueryRequest SchemaType = "request" +) + +type QuerySchemaOptions struct { + PluginID []string + QueryTypes []sdkapi.QueryTypeDefinition + Mode SchemaType +} + +// Given definitions for a plugin, return a valid spec +func GetQuerySchema(opts QuerySchemaOptions) (*spec.Schema, error) { + isRequest := opts.Mode == SchemaTypeQueryPayload || opts.Mode == SchemaTypeQueryRequest + generic, err := sdkapi.DataQuerySchema() + if err != nil { + return nil, err + } + + ignoreForSave := map[string]bool{"maxDataPoints": true, "intervalMs": true} + common := make(map[string]spec.Schema) + for key, val := range generic.Properties { + if !isRequest && ignoreForSave[key] { + continue // + } + + if key == "datasource" { + pattern := "" + for _, pid := range opts.PluginID { + if pattern != "" { + pattern += "|" + } + pattern += `^` + pid + `$` + } + if pattern == "" { + if opts.Mode == SchemaTypePanelModel { + return nil, fmt.Errorf("panel model requires pluginId") + } + } else { + t := val.Properties["type"] + t.Pattern = pattern + val.Properties["type"] = t + } + } + + common[key] = val + } + + // The types for each query type + queryTypes := []*spec.Schema{} + for _, qt := range opts.QueryTypes { + node := qt.Spec.Schema.DeepCopy().Spec + if node == nil { + return nil, fmt.Errorf("missing schema for: %s", qt.ObjectMeta.Name) + } + + // Match all discriminators + for _, d := range qt.Spec.Discriminators { + ds, ok := node.Properties[d.Field] + if !ok { + ds = *spec.StringProperty() + } + ds.Pattern = `^` + d.Value + `$` + node.Properties[d.Field] = ds + node.Required = append(node.Required, d.Field) + } + + queryTypes = append(queryTypes, node) + } + + s := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Schema: draft04, + Properties: make(map[string]spec.Schema), + }, + } + + // Single node -- just union the global and local properties + if len(queryTypes) == 1 { + s = queryTypes[0] + s.Schema = draft04 + for key, val := range generic.Properties { + _, found := s.Properties[key] + if found { + continue + } + s.Properties[key] = val + } + } else { + for _, qt := range queryTypes { + qt.Required = append(qt.Required, "refId") + + for k, v := range common { + _, found := qt.Properties[k] + if found { + continue + } + qt.Properties[k] = v + } + + s.OneOf = append(s.OneOf, *qt) + } + } + + switch opts.Mode { + case SchemaTypeQueryRequest: + return addRequestWrapper(s), nil + case SchemaTypePanelModel: + return addPanelWrapper(s), nil + } + return s, nil +} + +// moves the schema the the query slot in a request +func addRequestWrapper(s *spec.Schema) *spec.Schema { + return &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Schema: draft04, + Type: []string{"object"}, + Required: []string{"queries"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: false}, + Properties: map[string]spec.Schema{ + "from": *spec.StringProperty().WithDescription( + "From Start time in epoch timestamps in milliseconds or relative using Grafana time units."), + "to": *spec.StringProperty().WithDescription( + "To end time in epoch timestamps in milliseconds or relative using Grafana time units."), + "queries": *spec.ArrayProperty(s), + "debug": *spec.BoolProperty(), + "$schema": *spec.StringProperty().WithDescription("helper"), + }, + }, + } +} + +// Pretends to be a panel object +func addPanelWrapper(s *spec.Schema) *spec.Schema { + return &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Schema: draft04, + Type: []string{"object"}, + Required: []string{"targets", "type"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: true}, + Properties: map[string]spec.Schema{ + "type": *spec.StringProperty().WithDescription("the panel type"), + "targets": *spec.ArrayProperty(s), + }, + }, + } +} + +func asJSONSchema(v any) (*spec.Schema, error) { + s, ok := v.(*spec.Schema) + if ok { + return s, nil + } + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + s = &spec.Schema{} + err = json.Unmarshal(b, s) + return s, err +} diff --git a/experimental/testdata/folder.golden.txt b/experimental/testdata/folder.golden.txt index aff653e7d..aaca2da54 100644 --- a/experimental/testdata/folder.golden.txt +++ b/experimental/testdata/folder.golden.txt @@ -9,24 +9,24 @@ Frame[0] { "pathSeparator": "/" } Name: -Dimensions: 2 Fields by 19 Rows -+------------------+------------------+ -| Name: name | Name: media-type | -| Labels: | Labels: | -| Type: []string | Type: []string | -+------------------+------------------+ -| README.md | | -| actions | directory | -| authclient | directory | -| datasourcetest | directory | -| e2e | directory | -| errorsource | directory | -| featuretoggles | directory | -| fileinfo.go | | -| fileinfo_test.go | | -| ... | ... | -+------------------+------------------+ +Dimensions: 2 Fields by 21 Rows ++----------------+------------------+ +| Name: name | Name: media-type | +| Labels: | Labels: | +| Type: []string | Type: []string | ++----------------+------------------+ +| README.md | | +| actions | directory | +| apis | directory | +| authclient | directory | +| datasourcetest | directory | +| e2e | directory | +| errorsource | directory | +| featuretoggles | directory | +| fileinfo.go | | +| ... | ... | ++----------------+------------------+ ====== TEST DATA RESPONSE (arrow base64) ====== -FRAME=QVJST1cxAAD/////yAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAATAAAACgAAAAEAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADg/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAD///8IAAAAUAAAAEQAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInR5cGVWZXJzaW9uIjpbMCwwXSwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAeAAAAAQAAACi////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAAJD///8IAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAP/////YAAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAACAIAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAeAAAABMAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAFAAAAAAAAAA+QAAAAAAAABQAQAAAAAAAAAAAAAAAAAAUAEAAAAAAABQAAAAAAAAAKABAAAAAAAAYwAAAAAAAAAAAAAAAgAAABMAAAAAAAAAAAAAAAAAAAATAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAQAAAAGgAAACgAAAArAAAANgAAAEQAAABPAAAAXwAAAG4AAACCAAAAnAAAALsAAADGAAAAzAAAANAAAADjAAAA8QAAAPkAAABSRUFETUUubWRhY3Rpb25zYXV0aGNsaWVudGRhdGFzb3VyY2V0ZXN0ZTJlZXJyb3Jzb3VyY2VmZWF0dXJldG9nZ2xlc2ZpbGVpbmZvLmdvZmlsZWluZm9fdGVzdC5nb2ZyYW1lX3NvcnRlci5nb2ZyYW1lX3NvcnRlcl90ZXN0LmdvZ29sZGVuX3Jlc3BvbnNlX2NoZWNrZXIuZ29nb2xkZW5fcmVzcG9uc2VfY2hlY2tlcl90ZXN0LmdvaHR0cF9sb2dnZXJtYWNyb3Ntb2Nrb2F1dGh0b2tlbnJldHJpZXZlcnJlc3RfY2xpZW50LmdvdGVzdGRhdGEAAAAAAAAAAAAAAAAAAAAJAAAAEgAAABsAAAAkAAAALQAAADYAAAA2AAAANgAAADYAAAA2AAAANgAAADYAAAA/AAAASAAAAFEAAABaAAAAWgAAAGMAAABkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnlkaXJlY3RvcnkAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAADYAQAAAAAAAOAAAAAAAAAACAIAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAATAAAACgAAAAEAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADg/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAD///8IAAAAUAAAAEQAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInR5cGVWZXJzaW9uIjpbMCwwXSwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAeAAAAAQAAACi////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAAJD///8IAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAPgBAABBUlJPVzE= +FRAME=QVJST1cxAAD/////yAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAATAAAACgAAAAEAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADg/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAD///8IAAAAUAAAAEQAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInR5cGVWZXJzaW9uIjpbMCwwXSwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAeAAAAAQAAACi////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAAJD///8IAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAP/////YAAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAOAIAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAeAAAABUAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABYAAAAAAAAAFgAAAAAAAAACgEAAAAAAABoAQAAAAAAAAAAAAAAAAAAaAEAAAAAAABYAAAAAAAAAMABAAAAAAAAdQAAAAAAAAAAAAAAAgAAABUAAAAAAAAAAAAAAAAAAAAVAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAQAAAAFAAAAB4AAAAsAAAALwAAADoAAABIAAAAUwAAAGMAAAByAAAAhgAAAKAAAAC/AAAAygAAANAAAADUAAAA5wAAAPUAAAACAQAACgEAAFJFQURNRS5tZGFjdGlvbnNhcGlzYXV0aGNsaWVudGRhdGFzb3VyY2V0ZXN0ZTJlZXJyb3Jzb3VyY2VmZWF0dXJldG9nZ2xlc2ZpbGVpbmZvLmdvZmlsZWluZm9fdGVzdC5nb2ZyYW1lX3NvcnRlci5nb2ZyYW1lX3NvcnRlcl90ZXN0LmdvZ29sZGVuX3Jlc3BvbnNlX2NoZWNrZXIuZ29nb2xkZW5fcmVzcG9uc2VfY2hlY2tlcl90ZXN0LmdvaHR0cF9sb2dnZXJtYWNyb3Ntb2Nrb2F1dGh0b2tlbnJldHJpZXZlcnJlc3RfY2xpZW50Lmdvc2NoZW1hYnVpbGRlcnRlc3RkYXRhAAAAAAAAAAAAAAAAAAAJAAAAEgAAABsAAAAkAAAALQAAADYAAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAASAAAAFEAAABaAAAAYwAAAGMAAABsAAAAdQAAAGRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeQAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAADYAQAAAAAAAOAAAAAAAAAAOAIAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAATAAAACgAAAAEAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADg/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAD///8IAAAAUAAAAEQAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInR5cGVWZXJzaW9uIjpbMCwwXSwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAeAAAAAQAAACi////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAAJD///8IAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAPgBAABBUlJPVzE= diff --git a/go.mod b/go.mod index 1e3201ee9..15420bf19 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/hashicorp/go-hclog v1.6.2 github.com/hashicorp/go-plugin v1.6.0 github.com/hashicorp/yamux v0.1.1 // indirect + github.com/invopop/jsonschema v0.12.0 // for schema codgen+extraction github.com/json-iterator/go v1.1.12 github.com/magefile/mage v1.15.0 github.com/mattetti/filebuffer v1.0.1 @@ -51,24 +52,32 @@ require ( golang.org/x/net v0.21.0 golang.org/x/oauth2 v0.16.0 golang.org/x/text v0.14.0 + k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 // @grafana/grafana-app-platform-squad ) require ( github.com/BurntSushi/toml v1.3.2 // indirect + github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac // indirect + github.com/emicklei/go-restful/v3 v3.8.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.1 // indirect github.com/go-openapi/swag v0.22.4 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.1.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect github.com/invopop/yaml v0.2.0 // indirect @@ -92,6 +101,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/unknwon/com v1.0.1 // indirect github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/proto/otlp v1.1.0 // indirect @@ -104,4 +114,5 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect ) diff --git a/go.sum b/go.sum index c5c58c175..da878b61d 100644 --- a/go.sum +++ b/go.sum @@ -4,11 +4,17 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/apache/arrow/go/v15 v15.0.0 h1:1zZACWf85oEZY5/kd9dsQS7i+2G5zVQcbKTHgslqHNA= github.com/apache/arrow/go/v15 v15.0.0/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -33,6 +39,8 @@ github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/El github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc0ytWPASlaPwQfQMQYnRf0RSDe1XvHw0Q= github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/emicklei/go-restful/v3 v3.8.0 h1:eCZ8ulSerjdAiaNpF7GxXIE7ZCMo1moN1qX+S609eVw= +github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -57,6 +65,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.1 h1:FBLnyygC4/IZZr893oiomc9XaghoveYTrLC1F86HID8= +github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= @@ -78,12 +88,16 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= @@ -102,6 +116,8 @@ github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDm github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= @@ -214,6 +230,8 @@ github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP9 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -382,3 +400,9 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5 h1:QSpdNrZ9uRlV0VkqLvVO0Rqg8ioKi3oSw7O5P7pJV8M= +k8s.io/kube-openapi v0.0.0-20240220201932-37d671a357a5/go.mod h1:Pa1PvrP7ACSkuX6I7KYomY6cmMA0Tx86waBhDUgoKPw= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=