talos/hack/docgen/schema.go
Andrey Smirnov e71508ec10
chore: update dependencies
Go modules, Cilium CLI, Helm, etc.

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
2023-09-28 19:48:02 +04:00

296 lines
7.3 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/invopop/jsonschema"
"github.com/microcosm-cc/bluemonday"
validatejsonschema "github.com/santhosh-tekuri/jsonschema/v5"
"github.com/siderolabs/gen/slices"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
const ConfigSchemaURLFormat = "https://talos.dev/%s/schemas/v1alpha1_config.schema.json"
// SchemaWrapper wraps jsonschema.Schema to provide correct YAML unmarshalling using its internal JSON marshaller.
type SchemaWrapper struct {
jsonschema.Schema
}
// UnmarshalYAML unmarshals the schema from YAML.
//
// This converts the YAML that was read from the comments to JSON,
// then uses the custom JSON unmarshaler of the wrapped Schema.
func (t *SchemaWrapper) UnmarshalYAML(unmarshal func(any) error) error {
var data map[string]any
err := unmarshal(&data)
if err != nil {
return err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return err
}
return t.UnmarshalJSON(jsonBytes)
}
type SchemaTypeInfo struct {
typeName string
ref string
}
type SchemaDefinitionInfo struct {
typeInfo SchemaTypeInfo
arrayItemsTypeInfo *SchemaTypeInfo
mapValueTypeInfo *SchemaTypeInfo
enumValues []any
}
func goTypeToTypeInfo(goType string) *SchemaTypeInfo {
switch goType {
case "string":
return &SchemaTypeInfo{typeName: "string"}
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64":
return &SchemaTypeInfo{typeName: "integer"}
case "bool":
return &SchemaTypeInfo{typeName: "boolean"}
default:
return &SchemaTypeInfo{ref: "#/$defs/" + goType}
}
}
func fieldToDefinitionInfo(field *Field) SchemaDefinitionInfo {
goType := field.Type
if field.Text != nil {
// skip enumifying boolean fields so that we don't pick up the "on", "off", "yes", "no" from the values
if goType != "bool" && field.Text.Values != nil {
enumValues := make([]any, 0, len(field.Text.Values))
for _, val := range field.Text.Values {
enumValues = append(enumValues, val)
}
return SchemaDefinitionInfo{enumValues: enumValues}
}
}
if strings.HasPrefix(goType, "[]") {
return SchemaDefinitionInfo{
typeInfo: SchemaTypeInfo{typeName: "array"},
arrayItemsTypeInfo: goTypeToTypeInfo(strings.TrimPrefix(goType, "[]")),
}
}
if strings.HasPrefix(goType, "map[string]") {
return SchemaDefinitionInfo{
typeInfo: SchemaTypeInfo{typeName: "object"},
mapValueTypeInfo: goTypeToTypeInfo(strings.TrimPrefix(goType, "map[string]")),
}
}
return SchemaDefinitionInfo{
typeInfo: *goTypeToTypeInfo(goType),
}
}
func typeInfoToSchema(typeInfo *SchemaTypeInfo) *jsonschema.Schema {
schema := jsonschema.Schema{}
if typeInfo.typeName != "" {
schema.Type = typeInfo.typeName
}
if typeInfo.ref != "" {
schema.Ref = typeInfo.ref
}
return &schema
}
func fieldToSchema(field *Field) *jsonschema.Schema {
schema := jsonschema.Schema{}
if field.Text != nil {
// if there is an explicit schema, use it
if field.Text.Schema != nil {
schema = field.Text.Schema.Schema
}
// if no title is provided on the explicit schema, grab it from the comment
if schema.Title == "" {
schema.Title = strings.ReplaceAll(field.Tag, "\\n", "\n")
}
populateDescriptionFields(field, &schema)
// if an explicit schema was provided, return it
if field.Text.Schema != nil {
return &schema
}
}
// schema was not explicitly provided, generate it from the comment
info := fieldToDefinitionInfo(field)
if info.typeInfo.ref != "" {
schema.Ref = info.typeInfo.ref
}
if info.enumValues != nil {
schema.Enum = info.enumValues
}
if info.typeInfo.typeName != "" {
schema.Type = info.typeInfo.typeName
}
if info.arrayItemsTypeInfo != nil {
schema.Items = typeInfoToSchema(info.arrayItemsTypeInfo)
}
if info.mapValueTypeInfo != nil {
schema.PatternProperties = map[string]*jsonschema.Schema{
".*": typeInfoToSchema(info.mapValueTypeInfo),
}
}
return &schema
}
func populateDescriptionFields(field *Field, schema *jsonschema.Schema) {
if schema.Extras == nil {
schema.Extras = make(map[string]any)
}
markdownDescription := normalizeDescription(field.Text.Description)
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
htmlDescription := string(markdown.ToHTML([]byte(markdownDescription), nil, renderer))
policy := bluemonday.StrictPolicy()
plaintextDescription := policy.Sanitize(htmlDescription)
// set description
if schema.Description == "" {
schema.Description = plaintextDescription
}
// set markdownDescription for vscode/monaco editor
if schema.Extras["markdownDescription"] == nil {
schema.Extras["markdownDescription"] = markdownDescription
}
// set htmlDescription for Jetbrains IDEs
if schema.Extras["x-intellij-html-description"] == nil {
schema.Extras["x-intellij-html-description"] = htmlDescription
}
}
func structToSchema(st *Struct) *jsonschema.Schema {
schema := jsonschema.Schema{
Type: "object",
AdditionalProperties: jsonschema.FalseSchema,
}
properties := orderedmap.New[string, *jsonschema.Schema]()
for _, field := range st.Fields {
if field.Tag == "" {
// skip unknown/untagged field
continue
}
properties.Set(field.Tag, fieldToSchema(field))
}
schema.Properties = properties
return &schema
}
func docToSchema(doc *Doc, schemaURL string) *jsonschema.Schema {
schema := jsonschema.Schema{
Version: jsonschema.Version,
ID: jsonschema.ID(schemaURL),
Ref: "#/$defs/Config",
}
schema.Definitions = slices.ToMap(doc.Structs, func(st *Struct) (string, *jsonschema.Schema) {
return st.Name, structToSchema(st)
})
return &schema
}
func renderSchema(doc *Doc, destinationFile, versionTagFile string) {
version := readMajorMinorVersion(versionTagFile)
schemaURL := fmt.Sprintf(ConfigSchemaURLFormat, version)
schema := docToSchema(doc, schemaURL)
marshaled, err := json.MarshalIndent(schema, "", " ")
if err != nil {
log.Fatalf("failed to marshal schema: %v", err)
}
validateSchema(string(marshaled), schemaURL)
err = os.WriteFile(destinationFile, marshaled, 0o644)
if err != nil {
log.Fatalf("failed to write schema to %s: %v", destinationFile, err)
}
}
// validateSchema validates the schema itself by compiling it.
func validateSchema(schema, schemaURL string) {
_, err := validatejsonschema.CompileString(schemaURL, schema)
if err != nil {
log.Fatalf("failed to compile schema: %v", err)
}
}
func normalizeDescription(description string) string {
description = strings.ReplaceAll(description, `\n`, "\n")
description = strings.ReplaceAll(description, `\"`, `"`)
description = strings.TrimSpace(description)
return description
}
func readMajorMinorVersion(filePath string) string {
fileBytes, err := os.ReadFile(filePath)
if err != nil {
log.Fatalf("failed to read version file: %v", err)
}
version := string(fileBytes)
versionParts := strings.Split(version, ".")
if len(versionParts) < 2 {
log.Fatalf("unexpected version in version file: %s", version)
}
return versionParts[0] + "." + versionParts[1]
}