grafana/pkg/plugins/openapi/augment.go
2026-05-06 14:24:22 +03:00

304 lines
7.7 KiB
Go

package openapi
import (
"fmt"
"maps"
"net/http"
"slices"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana-plugin-sdk-go/experimental/pluginschema"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
const app_INSTANCE_NAME = "instance"
type PluginOptions struct {
Schema *pluginschema.PluginSchema
// The full resource config (spec and secure are children)
Resource *spec.Schema
// The name where the resource spec should be registered
// eg: DataSourceSpec | AppPluginSpec
SpecName string
// root+"namespaces/{namespace}/datasources"
// This is used for the POST examples
Path string
// When the value is an app, we expect {namespace}/app/instance
IsApp bool
}
// nolint:gocyclo
func AugmentOpenAPI(oas *spec3.OpenAPI, opts PluginOptions) (*spec3.OpenAPI, error) {
if opts.Schema.IsZero() {
return oas, nil // nothing special
}
// Find the root path
cfg := oas.Paths.Paths[opts.Path]
if cfg == nil {
return nil, fmt.Errorf("no route registered: %s", opts.Path)
}
if cfg.Post == nil {
return nil, fmt.Errorf("expecting POST under: %s", opts.Path)
}
// Replace the generic DataSourceSpec with the explicit one
settings := opts.Schema.SettingsSchema
if !settings.IsZero() {
resourceSpec := settings.Spec
if opts.IsApp {
resourceSpec = &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"pinned": *spec.BooleanProperty().WithDescription("shows up in the sidebar"),
"enabled": *spec.BooleanProperty().WithDescription("can be executed"),
"jsonData": *settings.Spec,
},
},
}
example := map[string]any{
"metadata": map[string]any{
"name": app_INSTANCE_NAME,
},
"spec": map[string]any{
"enabled": true,
"pinned": true,
// JSONData (from examples)
},
}
opts.Resource.Example = example
}
oas.Components.Schemas[opts.SpecName] = resourceSpec
opts.Resource.Properties["spec"] = spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/" + opts.SpecName),
},
}
if len(settings.SecureValues) > 0 {
example := common.InlineSecureValues{}
ref := spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue")
secure := &spec.Schema{
SchemaProps: spec.SchemaProps{
Properties: make(map[string]spec.Schema),
AdditionalProperties: &spec.SchemaOrBool{Allows: false},
}}
for _, v := range settings.SecureValues {
secure.Properties[v.Key] = spec.Schema{
SchemaProps: spec.SchemaProps{
Description: v.Description,
Ref: ref,
},
}
if v.Required {
secure.Required = append(secure.Required, v.Key)
example[v.Key] = common.InlineSecureValue{Create: "***"}
}
}
if len(example) > 0 {
secure.Example = example
}
// Link the explicit secure values in the resource
oas.Components.Schemas["SecureValues"] = secure
opts.Resource.Properties["secure"] = spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/SecureValues"),
},
}
}
examples := opts.Schema.SettingsExamples
if !examples.IsZero() {
for _, c := range cfg.Post.RequestBody.Content {
c.Examples = examples.Examples
}
}
}
routes := opts.Schema.Routes
if routes.IsZero() {
routes = &pluginschema.Routes{}
}
// Add custom schemas
if routes.Components != nil {
copyComponents(routes.Components, oas.Components)
}
if err := routes.AssertPrefixes("/resources", "/proxy"); err != nil {
return oas, err
}
routePrefix := opts.Path + "/{name}"
cfg = oas.Paths.Paths[routePrefix]
if cfg == nil {
return nil, fmt.Errorf("expecting route registered: %s", routePrefix)
}
if cfg.Get == nil {
return nil, fmt.Errorf("expecting GET under: %s/{name}", routePrefix)
}
var params []*spec3.Parameter
for _, p := range cfg.Parameters {
if p.Name == "namespace" {
params = append(params, p)
}
if p.Name == "name" && !opts.IsApp {
params = append(params, p)
}
}
if opts.IsApp {
// Hide the non-instance based routes
delete(oas.Paths.Paths, opts.Path)
removeName := func(pp []*spec3.Parameter) (ret []*spec3.Parameter) {
for _, p := range pp {
if p.Name != "name" {
ret = append(ret, p)
}
}
return
}
// Replace the {name} property with /instance path
appRoutePrefix := opts.Path + "/" + app_INSTANCE_NAME
for k, v := range oas.Paths.Paths {
if strings.HasPrefix(k, routePrefix) {
delete(oas.Paths.Paths, k)
k = strings.Replace(k, routePrefix, appRoutePrefix, 1)
v.Parameters = removeName(v.Parameters)
oas.Paths.Paths[k] = v
}
}
routePrefix = appRoutePrefix
}
// When a schema is configured, remove the default mappings
if len(routes.Paths) > 0 {
delete(oas.Paths.Paths, routePrefix+"/resources")
delete(oas.Paths.Paths, routePrefix+"/proxy")
}
// Add all the paths
caser := cases.Title(language.English)
for k, v := range routes.Paths {
tag := caser.String(k[1:]) // "Resources", "Proxy"
if idx := strings.Index(tag, "/"); idx > 0 {
tag = tag[:idx]
}
v.Parameters = append(params, v.Parameters...)
for m, op := range getPathOperations(&v.PathProps) {
if op.Extensions == nil {
op.Extensions = make(spec.Extensions)
}
if !slices.Contains(op.Tags, tag) {
op.Tags = append(op.Tags, tag)
}
tmp := strings.ReplaceAll(strings.ReplaceAll(k, "{", ""), "}", "")
op.OperationId = fmt.Sprintf("%s%s", strings.ToLower(m), strings.ReplaceAll(tmp, "/", "_"))
}
oas.Paths.Paths[routePrefix+k] = v
}
return oas, nil
}
// getPathOperations returns the set of non-nil operations defined on a path.
// Equivalent to builder.GetPathOperations in the root module, inlined here to
// avoid a cross-module dependency that breaks go mod tidy.
func getPathOperations(path *spec3.PathProps) map[string]*spec3.Operation {
ops := make(map[string]*spec3.Operation)
if path.Get != nil {
ops[http.MethodGet] = path.Get
}
if path.Head != nil {
ops[http.MethodHead] = path.Head
}
if path.Delete != nil {
ops[http.MethodDelete] = path.Delete
}
if path.Post != nil {
ops[http.MethodPost] = path.Post
}
if path.Put != nil {
ops[http.MethodPut] = path.Put
}
if path.Patch != nil {
ops[http.MethodPatch] = path.Patch
}
if path.Trace != nil {
ops[http.MethodTrace] = path.Trace
}
if path.Options != nil {
ops[http.MethodOptions] = path.Options
}
return ops
}
// safely copy components from src to dst
func copyComponents(src *spec3.Components, dst *spec3.Components) {
if src.Schemas != nil {
if dst.Schemas == nil {
dst.Schemas = make(map[string]*spec.Schema)
}
maps.Copy(dst.Schemas, src.Schemas)
}
if src.Responses != nil {
if dst.Responses == nil {
dst.Responses = make(map[string]*spec3.Response)
}
maps.Copy(dst.Responses, src.Responses)
}
if src.Examples != nil {
if dst.Examples == nil {
dst.Examples = make(map[string]*spec3.Example)
}
maps.Copy(dst.Examples, src.Examples)
}
if src.Headers != nil {
if dst.Headers == nil {
dst.Headers = make(map[string]*spec3.Header)
}
maps.Copy(dst.Headers, src.Headers)
}
if src.Parameters != nil {
if dst.Parameters == nil {
dst.Parameters = make(map[string]*spec3.Parameter)
}
maps.Copy(dst.Parameters, src.Parameters)
}
if src.Links != nil {
if dst.Links == nil {
dst.Links = make(map[string]*spec3.Link)
}
maps.Copy(dst.Links, src.Links)
}
if src.RequestBodies != nil {
if dst.RequestBodies == nil {
dst.RequestBodies = make(map[string]*spec3.RequestBody)
}
maps.Copy(dst.RequestBodies, src.RequestBodies)
}
}