Skip to content

Commit 042c8d8

Browse files
ilyakuz-dbpietern
andauthored
Custom annotations for bundle-specific JSON schema fields (#1957)
## Changes Adds annotations to json-schema for fields which are not covered by OpenAPI spec. Custom descriptions were copy-pasted from documentation PR which is still WIP so descriptions for some fields are missing Further improvements: * documentation autogen based on json-schema * fix missing descriptions ## Tests This script is not part of CLI package so I didn't test all corner cases. Few high-level tests were added to be sure that schema annotations is in sync with actual config --------- Co-authored-by: Pieter Noordhuis <[email protected]>
1 parent 5b84856 commit 042c8d8

File tree

16 files changed

+4142
-272
lines changed

16 files changed

+4142
-272
lines changed

.codegen.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"required": ["go"],
1212
"post_generate": [
1313
"go test -timeout 240s -run TestConsistentDatabricksSdkVersion github.com/databricks/cli/internal/build",
14-
"go run ./bundle/internal/schema/*.go ./bundle/schema/jsonschema.json",
14+
"make schema",
1515
"echo 'bundle/internal/tf/schema/\\*.go linguist-generated=true' >> ./.gitattributes",
1616
"echo 'go.sum linguist-generated=true' >> ./.gitattributes",
1717
"echo 'bundle/schema/jsonschema.json linguist-generated=true' >> ./.gitattributes"

.github/workflows/push.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,19 @@ jobs:
9999
# By default the ajv-cli runs in strict mode which will fail if the schema
100100
# itself is not valid. Strict mode is more strict than the JSON schema
101101
# specification. See for details: https://ajv.js.org/options.html#strict-mode-options
102+
# The ajv-cli is configured to use the markdownDescription keyword which is not part of the JSON schema specification,
103+
# but is used in editors like VSCode to render markdown in the description field
102104
- name: Validate bundle schema
103105
run: |
104106
go run main.go bundle schema > schema.json
105107
108+
# Add markdownDescription keyword to ajv
109+
echo "module.exports=function(a){a.addKeyword('markdownDescription')}" >> keywords.js
110+
106111
for file in ./bundle/internal/schema/testdata/pass/*.yml; do
107-
ajv test -s schema.json -d $file --valid
112+
ajv test -s schema.json -d $file --valid -c=./keywords.js
108113
done
109114
110115
for file in ./bundle/internal/schema/testdata/fail/*.yml; do
111-
ajv test -s schema.json -d $file --invalid
116+
ajv test -s schema.json -d $file --invalid -c=./keywords.js
112117
done

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ snapshot:
2929
vendor:
3030
@echo "✓ Filling vendor folder with library code ..."
3131
@go mod vendor
32+
33+
schema:
34+
@echo "✓ Generating json-schema ..."
35+
@go run ./bundle/internal/schema ./bundle/internal/schema ./bundle/schema/jsonschema.json
3236

3337
INTEGRATION = gotestsum --format github-actions --rerun-fails --jsonfile output.json --packages "./integration/..." -- -parallel 4 -timeout=2h
3438

@@ -38,4 +42,4 @@ integration:
3842
integration-short:
3943
$(INTEGRATION) -short
4044

41-
.PHONY: lint lintcheck test testonly coverage build snapshot vendor integration integration-short
45+
.PHONY: lint lintcheck test testonly coverage build snapshot vendor schema integration integration-short
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"reflect"
8+
"regexp"
9+
"strings"
10+
11+
yaml3 "gopkg.in/yaml.v3"
12+
13+
"github.com/databricks/cli/libs/dyn"
14+
"github.com/databricks/cli/libs/dyn/convert"
15+
"github.com/databricks/cli/libs/dyn/merge"
16+
"github.com/databricks/cli/libs/dyn/yamlloader"
17+
"github.com/databricks/cli/libs/dyn/yamlsaver"
18+
"github.com/databricks/cli/libs/jsonschema"
19+
)
20+
21+
type annotation struct {
22+
Description string `json:"description,omitempty"`
23+
MarkdownDescription string `json:"markdown_description,omitempty"`
24+
Title string `json:"title,omitempty"`
25+
Default any `json:"default,omitempty"`
26+
Enum []any `json:"enum,omitempty"`
27+
}
28+
29+
type annotationHandler struct {
30+
// Annotations read from all annotation files including all overrides
31+
parsedAnnotations annotationFile
32+
// Missing annotations for fields that are found in config that need to be added to the annotation file
33+
missingAnnotations annotationFile
34+
}
35+
36+
/**
37+
* Parsed file with annotations, expected format:
38+
* github.com/databricks/cli/bundle/config.Bundle:
39+
* cluster_id:
40+
* description: "Description"
41+
*/
42+
type annotationFile map[string]map[string]annotation
43+
44+
const Placeholder = "PLACEHOLDER"
45+
46+
// Adds annotations to the JSON schema reading from the annotation files.
47+
// More details https://json-schema.org/understanding-json-schema/reference/annotations
48+
func newAnnotationHandler(sources []string) (*annotationHandler, error) {
49+
prev := dyn.NilValue
50+
for _, path := range sources {
51+
b, err := os.ReadFile(path)
52+
if err != nil {
53+
return nil, err
54+
}
55+
generated, err := yamlloader.LoadYAML(path, bytes.NewBuffer(b))
56+
if err != nil {
57+
return nil, err
58+
}
59+
prev, err = merge.Merge(prev, generated)
60+
if err != nil {
61+
return nil, err
62+
}
63+
}
64+
65+
var data annotationFile
66+
67+
err := convert.ToTyped(&data, prev)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
d := &annotationHandler{}
73+
d.parsedAnnotations = data
74+
d.missingAnnotations = annotationFile{}
75+
return d, nil
76+
}
77+
78+
func (d *annotationHandler) addAnnotations(typ reflect.Type, s jsonschema.Schema) jsonschema.Schema {
79+
refPath := getPath(typ)
80+
shouldHandle := strings.HasPrefix(refPath, "github.com")
81+
if !shouldHandle {
82+
return s
83+
}
84+
85+
annotations := d.parsedAnnotations[refPath]
86+
if annotations == nil {
87+
annotations = map[string]annotation{}
88+
}
89+
90+
rootTypeAnnotation, ok := annotations[RootTypeKey]
91+
if ok {
92+
assignAnnotation(&s, rootTypeAnnotation)
93+
}
94+
95+
for k, v := range s.Properties {
96+
item := annotations[k]
97+
if item.Description == "" {
98+
item.Description = Placeholder
99+
100+
emptyAnnotations := d.missingAnnotations[refPath]
101+
if emptyAnnotations == nil {
102+
emptyAnnotations = map[string]annotation{}
103+
d.missingAnnotations[refPath] = emptyAnnotations
104+
}
105+
emptyAnnotations[k] = item
106+
}
107+
assignAnnotation(v, item)
108+
}
109+
return s
110+
}
111+
112+
// Writes missing annotations with placeholder values back to the annotation file
113+
func (d *annotationHandler) syncWithMissingAnnotations(outputPath string) error {
114+
existingFile, err := os.ReadFile(outputPath)
115+
if err != nil {
116+
return err
117+
}
118+
existing, err := yamlloader.LoadYAML("", bytes.NewBuffer(existingFile))
119+
if err != nil {
120+
return err
121+
}
122+
missingAnnotations, err := convert.FromTyped(&d.missingAnnotations, dyn.NilValue)
123+
if err != nil {
124+
return err
125+
}
126+
127+
output, err := merge.Merge(existing, missingAnnotations)
128+
if err != nil {
129+
return err
130+
}
131+
132+
err = saveYamlWithStyle(outputPath, output)
133+
if err != nil {
134+
return err
135+
}
136+
return nil
137+
}
138+
139+
func getPath(typ reflect.Type) string {
140+
return typ.PkgPath() + "." + typ.Name()
141+
}
142+
143+
func assignAnnotation(s *jsonschema.Schema, a annotation) {
144+
if a.Description != Placeholder {
145+
s.Description = a.Description
146+
}
147+
148+
if a.Default != nil {
149+
s.Default = a.Default
150+
}
151+
s.MarkdownDescription = convertLinksToAbsoluteUrl(a.MarkdownDescription)
152+
s.Title = a.Title
153+
s.Enum = a.Enum
154+
}
155+
156+
func saveYamlWithStyle(outputPath string, input dyn.Value) error {
157+
style := map[string]yaml3.Style{}
158+
file, _ := input.AsMap()
159+
for _, v := range file.Keys() {
160+
style[v.MustString()] = yaml3.LiteralStyle
161+
}
162+
163+
saver := yamlsaver.NewSaverWithStyle(style)
164+
err := saver.SaveAsYAML(file, outputPath, true)
165+
if err != nil {
166+
return err
167+
}
168+
return nil
169+
}
170+
171+
func convertLinksToAbsoluteUrl(s string) string {
172+
if s == "" {
173+
return s
174+
}
175+
base := "https://docs.databricks.com"
176+
referencePage := "/dev-tools/bundles/reference.html"
177+
178+
// Regular expression to match Markdown-style links like [_](link)
179+
re := regexp.MustCompile(`\[_\]\(([^)]+)\)`)
180+
result := re.ReplaceAllStringFunc(s, func(match string) string {
181+
matches := re.FindStringSubmatch(match)
182+
if len(matches) < 2 {
183+
return match
184+
}
185+
link := matches[1]
186+
var text, absoluteURL string
187+
188+
if strings.HasPrefix(link, "#") {
189+
text = strings.TrimPrefix(link, "#")
190+
absoluteURL = fmt.Sprintf("%s%s%s", base, referencePage, link)
191+
192+
// Handle relative paths like /dev-tools/bundles/resources.html#dashboard
193+
} else if strings.HasPrefix(link, "/") {
194+
absoluteURL = strings.ReplaceAll(fmt.Sprintf("%s%s", base, link), ".md", ".html")
195+
if strings.Contains(link, "#") {
196+
parts := strings.Split(link, "#")
197+
text = parts[1]
198+
} else {
199+
text = "link"
200+
}
201+
} else {
202+
return match
203+
}
204+
205+
return fmt.Sprintf("[%s](%s)", text, absoluteURL)
206+
})
207+
208+
return result
209+
}

0 commit comments

Comments
 (0)