grafana/pkg/codegen/jenny_docs.go
Joan López de la Franca Beltran 19ed9181e1
Kindsys: Extend DocsJenny with support for 'allOf' (#62558)
* Kindsys: Extend DocsJenny with support for 'allOf'

* Update generated docs

* Multiple refinements

* Minor fixes

* Undo undesired changes

* Fix

* Fix linter complains

---------

Co-authored-by: Tania B <yalyna.ts@gmail.com>
2023-02-02 16:12:31 +01:00

631 lines
15 KiB
Go

package codegen
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"sort"
"strings"
"text/template"
"cuelang.org/go/cue/cuecontext"
"github.com/grafana/codejen"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/kindsys"
"github.com/grafana/thema/encoding/jsonschema"
"github.com/olekukonko/tablewriter"
"github.com/xeipuuv/gojsonpointer"
)
func DocsJenny(docsPath string) OneToOne {
return docsJenny{
docsPath: docsPath,
}
}
type docsJenny struct {
docsPath string
}
func (j docsJenny) JennyName() string {
return "DocsJenny"
}
func (j docsJenny) Generate(kind kindsys.Kind) (*codejen.File, error) {
// TODO remove this once codejen catches nils https://github.com/grafana/codejen/issues/5
if kind == nil {
return nil, nil
}
f, err := jsonschema.GenerateSchema(kind.Lineage().Latest())
if err != nil {
return nil, fmt.Errorf("failed to generate json representation for the schema: %v", err)
}
b, err := cuecontext.New().BuildFile(f).MarshalJSON()
if err != nil {
return nil, fmt.Errorf("failed to marshal schema value to json: %v", err)
}
// We don't need entire json obj, only the value of components.schemas path
var obj struct {
Info struct {
Title string
}
Components struct {
Schemas json.RawMessage
}
}
err = json.Unmarshal(b, &obj)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal schema json: %v", err)
}
// fixes the references between the types within a json after making components.schema.<types> the root of the json
kindJsonStr := strings.Replace(string(obj.Components.Schemas), "#/components/schemas/", "#/", -1)
kindProps := kind.Props().Common()
data := templateData{
KindName: kindProps.Name,
KindVersion: kind.Lineage().Latest().Version().String(),
KindMaturity: string(kindProps.Maturity),
KindDescription: kindProps.Description,
Markdown: "{{ .Markdown 1 }}",
}
tmpl, err := makeTemplate(data, "docs.tmpl")
if err != nil {
return nil, err
}
doc, err := jsonToMarkdown([]byte(kindJsonStr), string(tmpl), obj.Info.Title)
if err != nil {
return nil, fmt.Errorf("failed to build markdown for kind %s: %v", kindProps.Name, err)
}
return codejen.NewFile(filepath.Join(j.docsPath, strings.ToLower(kindProps.Name), "schema-reference.md"), doc, j), nil
}
// makeTemplate pre-populates the template with the kind metadata
func makeTemplate(data templateData, tmpl string) ([]byte, error) {
buf := new(bytes.Buffer)
if err := tmpls.Lookup(tmpl).Execute(buf, data); err != nil {
return []byte{}, fmt.Errorf("failed to populate docs template with the kind metadata")
}
return buf.Bytes(), nil
}
type templateData struct {
KindName string
KindVersion string
KindMaturity string
KindDescription string
Markdown string
}
// -------------------- JSON to Markdown conversion --------------------
// Copied from https://github.com/marcusolsson/json-schema-docs and slightly changed to fit the DocsJenny
type schema struct {
ID string `json:"$id,omitempty"`
Ref string `json:"$ref,omitempty"`
Schema string `json:"$schema,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Required []string `json:"required,omitempty"`
Type PropertyTypes `json:"type,omitempty"`
Properties map[string]*schema `json:"properties,omitempty"`
Items *schema `json:"items,omitempty"`
Definitions map[string]*schema `json:"definitions,omitempty"`
Enum []Any `json:"enum"`
Default any `json:"default"`
AllOf []*schema `json:"allOf"`
AdditionalProperties *schema `json:"additionalProperties"`
extends []string `json:"-"`
inheritedFrom string `json:"-"`
}
func renderMapType(props *schema) string {
if props == nil {
return ""
}
if props.Type.HasType(PropertyTypeObject) {
name, anchor := propNameAndAnchor(props.Title, props.Title)
return fmt.Sprintf("[%s](#%s)", name, anchor)
}
if props.AdditionalProperties != nil {
return "map[string]" + renderMapType(props.AdditionalProperties)
}
if props.Items != nil {
return "[]" + renderMapType(props.Items)
}
var types []string
for _, t := range props.Type {
types = append(types, string(t))
}
return strings.Join(types, ", ")
}
func jsonToMarkdown(jsonData []byte, tpl string, kindName string) ([]byte, error) {
sch, err := newSchema(jsonData, kindName)
if err != nil {
return []byte{}, err
}
t, err := template.New("markdown").Parse(tpl)
if err != nil {
return []byte{}, err
}
buf := new(bytes.Buffer)
err = t.Execute(buf, sch)
if err != nil {
return []byte{}, err
}
return buf.Bytes(), nil
}
func newSchema(b []byte, kindName string) (*schema, error) {
var data map[string]*schema
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
// Needed for resolving in-schema references.
root, err := simplejson.NewJson(b)
if err != nil {
return nil, err
}
return resolveSchema(data[kindName], root)
}
// resolveSchema recursively resolves schemas.
func resolveSchema(schem *schema, root *simplejson.Json) (*schema, error) {
for _, prop := range schem.Properties {
if prop.Ref != "" {
tmp, err := resolveReference(prop.Ref, root)
if err != nil {
return nil, err
}
*prop = *tmp
}
foo, err := resolveSchema(prop, root)
if err != nil {
return nil, err
}
*prop = *foo
}
if schem.Items != nil {
if schem.Items.Ref != "" {
tmp, err := resolveReference(schem.Items.Ref, root)
if err != nil {
return nil, err
}
*schem.Items = *tmp
}
foo, err := resolveSchema(schem.Items, root)
if err != nil {
return nil, err
}
*schem.Items = *foo
}
if len(schem.AllOf) > 0 {
for idx, child := range schem.AllOf {
tmp, err := resolveSubSchema(schem, child, root)
if err != nil {
return nil, err
}
schem.AllOf[idx] = tmp
if len(tmp.Title) > 0 {
schem.extends = append(schem.extends, tmp.Title)
}
}
}
if schem.AdditionalProperties != nil {
if schem.AdditionalProperties.Ref != "" {
tmp, err := resolveReference(schem.AdditionalProperties.Ref, root)
if err != nil {
return nil, err
}
*schem.AdditionalProperties = *tmp
}
foo, err := resolveSchema(schem.AdditionalProperties, root)
if err != nil {
return nil, err
}
*schem.AdditionalProperties = *foo
}
return schem, nil
}
func resolveSubSchema(parent, child *schema, root *simplejson.Json) (*schema, error) {
if child.Ref != "" {
tmp, err := resolveReference(child.Ref, root)
if err != nil {
return nil, err
}
*child = *tmp
}
if len(child.Required) > 0 {
parent.Required = append(parent.Required, child.Required...)
}
child, err := resolveSchema(child, root)
if err != nil {
return nil, err
}
if parent.Properties == nil {
parent.Properties = make(map[string]*schema)
}
for k, v := range child.Properties {
prop := *v
prop.inheritedFrom = child.Title
parent.Properties[k] = &prop
}
return child, err
}
// resolveReference loads a schema from a $ref.
// If ref contains a hashtag (#), the part after represents a in-schema reference.
func resolveReference(ref string, root *simplejson.Json) (*schema, error) {
i := strings.Index(ref, "#")
if i != 0 {
return nil, fmt.Errorf("not in-schema reference: %s", ref)
}
return resolveInSchemaReference(ref[i+1:], root)
}
func resolveInSchemaReference(ref string, root *simplejson.Json) (*schema, error) {
// in-schema reference
pointer, err := gojsonpointer.NewJsonPointer(ref)
if err != nil {
return nil, err
}
v, _, err := pointer.Get(root.MustMap())
if err != nil {
return nil, err
}
var sch schema
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &sch); err != nil {
return nil, err
}
// Set the ref name as title
sch.Title = path.Base(ref)
return &sch, nil
}
// Markdown returns the Markdown representation of the schema.
//
// The level argument can be used to offset the heading levels. This can be
// useful if you want to add the schema under a subheading.
func (s schema) Markdown(level int) string {
if level < 1 {
level = 1
}
buf := new(bytes.Buffer)
if s.Title != "" && s.AdditionalProperties == nil {
fmt.Fprintf(buf, "### %s\n", strings.Title(s.Title))
fmt.Fprintln(buf)
}
if s.Description != "" {
fmt.Fprintln(buf, s.Description)
fmt.Fprintln(buf)
}
if len(s.extends) > 0 {
fmt.Fprintln(buf, makeExtends(s.extends))
fmt.Fprintln(buf)
}
printProperties(buf, &s)
// Add padding.
fmt.Fprintln(buf)
for _, obj := range findDefinitions(&s) {
fmt.Fprint(buf, obj.Markdown(level+1))
}
return buf.String()
}
func makeExtends(from []string) string {
fromLinks := make([]string, 0, len(from))
for _, f := range from {
fromLinks = append(fromLinks, fmt.Sprintf("[%s](#%s)", f, strings.ToLower(f)))
}
return fmt.Sprintf("It extends %s.", strings.Join(fromLinks, " and "))
}
func findDefinitions(s *schema) []*schema {
// Gather all properties of object type so that we can generate the
// properties for them recursively.
var objs []*schema
definition := func(k string, p *schema) {
if p.Type.HasType(PropertyTypeObject) && p.AdditionalProperties == nil {
// Use the identifier as the title.
if len(p.Title) == 0 {
p.Title = k
}
objs = append(objs, p)
}
// If the property is an array of objects, use the name of the array
// property as the title.
if p.Type.HasType(PropertyTypeArray) {
if p.Items != nil {
if p.Items.Type.HasType(PropertyTypeObject) {
if len(p.Items.Title) == 0 {
p.Items.Title = k
}
objs = append(objs, p.Items)
}
}
}
}
for k, p := range s.Properties {
// If a property has AdditionalProperties, then it's a map
if p.AdditionalProperties != nil {
definition(k, p.AdditionalProperties)
}
definition(k, p)
}
// This code could probably be unified with the one above
for _, child := range s.AllOf {
if child.Type.HasType(PropertyTypeObject) {
objs = append(objs, child)
}
if child.Type.HasType(PropertyTypeArray) {
if child.Items != nil {
if child.Items.Type.HasType(PropertyTypeObject) {
objs = append(objs, child.Items)
}
}
}
}
// Sort the object schemas.
sort.Slice(objs, func(i, j int) bool {
return objs[i].Title < objs[j].Title
})
return objs
}
func printProperties(w io.Writer, s *schema) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"Property", "Type", "Required", "Description"})
table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
table.SetCenterSeparator("|")
table.SetAutoFormatHeaders(false)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAutoWrapText(false)
// Buffer all property rows so that we can sort them before printing them.
rows := make([][]string, 0, len(s.Properties))
for key, p := range s.Properties {
typeStr := propTypeStr(key, p)
// Emphasize required properties.
var required string
if in(s.Required, key) {
required = "**Yes**"
} else {
required = "No"
}
var desc string
if p.inheritedFrom != "" {
desc = fmt.Sprintf("*(Inherited from [%s](#%s))*", p.inheritedFrom, strings.ToLower(p.inheritedFrom))
}
if p.Description != "" {
desc += "\n" + p.Description
}
if len(p.Enum) > 0 {
vals := make([]string, 0, len(p.Enum))
for _, e := range p.Enum {
vals = append(vals, e.String())
}
desc += "\nPossible values are: `" + strings.Join(vals, "`, `") + "`."
}
if p.Default != nil {
desc += fmt.Sprintf(" Default: `%v`.", p.Default)
}
rows = append(rows, []string{fmt.Sprintf("`%s`", key), typeStr, required, formatForTable(desc)})
}
// Sort by the required column, then by the name column.
sort.Slice(rows, func(i, j int) bool {
if rows[i][2] < rows[j][2] {
return true
}
if rows[i][2] > rows[j][2] {
return false
}
return rows[i][0] < rows[j][0]
})
table.AppendBulk(rows)
table.Render()
}
func propTypeStr(propName string, propValue *schema) string {
// If the property has AdditionalProperties, it is most likely a map type
if propValue.AdditionalProperties != nil {
mapValue := renderMapType(propValue.AdditionalProperties)
return "map[string]" + mapValue
}
propType := make([]string, 0, len(propValue.Type))
// Generate relative links for objects and arrays of objects.
for _, pt := range propValue.Type {
switch pt {
case PropertyTypeObject:
name, anchor := propNameAndAnchor(propName, propValue.Title)
propType = append(propType, fmt.Sprintf("[%s](#%s)", name, anchor))
case PropertyTypeArray:
if propValue.Items != nil {
for _, pi := range propValue.Items.Type {
if pi == PropertyTypeObject {
name, anchor := propNameAndAnchor(propName, propValue.Items.Title)
propType = append(propType, fmt.Sprintf("[%s](#%s)[]", name, anchor))
} else {
propType = append(propType, fmt.Sprintf("%s[]", pi))
}
}
} else {
propType = append(propType, string(pt))
}
default:
propType = append(propType, string(pt))
}
}
if len(propType) == 0 {
return ""
}
if len(propType) == 1 {
return propType[0]
}
if len(propType) == 2 {
return strings.Join(propType, " or ")
}
return fmt.Sprintf("%s, or %s", strings.Join(propType[:len(propType)-1], ", "), propType[len(propType)-1])
}
func propNameAndAnchor(prop, title string) (string, string) {
if len(title) > 0 {
return title, strings.ToLower(title)
}
return string(PropertyTypeObject), strings.ToLower(prop)
}
// in returns true if a string slice contains a specific string.
func in(strs []string, str string) bool {
for _, s := range strs {
if s == str {
return true
}
}
return false
}
// formatForTable returns string usable in a Markdown table.
// It trims white spaces, replaces new lines and pipe characters.
func formatForTable(in string) string {
s := strings.TrimSpace(in)
s = strings.ReplaceAll(s, "\n", "<br/>")
s = strings.ReplaceAll(s, "|", "&#124;")
return s
}
type PropertyTypes []PropertyType
func (pts *PropertyTypes) HasType(pt PropertyType) bool {
for _, t := range *pts {
if t == pt {
return true
}
}
return false
}
func (pts *PropertyTypes) UnmarshalJSON(data []byte) error {
var value interface{}
if err := json.Unmarshal(data, &value); err != nil {
return err
}
switch val := value.(type) {
case string:
*pts = []PropertyType{PropertyType(val)}
return nil
case []interface{}:
var pt []PropertyType
for _, t := range val {
s, ok := t.(string)
if !ok {
return errors.New("unsupported property type")
}
pt = append(pt, PropertyType(s))
}
*pts = pt
default:
return errors.New("unsupported property type")
}
return nil
}
type PropertyType string
const (
PropertyTypeString PropertyType = "string"
PropertyTypeNumber PropertyType = "number"
PropertyTypeBoolean PropertyType = "boolean"
PropertyTypeObject PropertyType = "object"
PropertyTypeArray PropertyType = "array"
PropertyTypeNull PropertyType = "null"
)
type Any struct {
value interface{}
}
func (u *Any) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &u.value); err != nil {
return err
}
return nil
}
func (u *Any) String() string {
return fmt.Sprintf("%v", u.value)
}