Skip to content

Commit

Permalink
feat: introduce flag noAdditionalProperties
Browse files Browse the repository at this point in the history
This will default `additionalProperties` to `false` for all objects in the schema if set to `true`.
The `additionalProperties` annotation, `schemaRoot.additionalProperties` and additionalProperties
set via other ways (nested in `itemProperties`, `patternProperties` or similar) will take
precedence over this setting, so it's fully backwards compatible.

Closes #95.
  • Loading branch information
pascal-hofmann committed Oct 24, 2024
1 parent 6d1e5b0 commit 9cfee26
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 47 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ Usage: helm schema [options...] <arguments>
Multiple yaml files as inputs (comma-separated)
-output string
Output file path (default "values.schema.json")
-noAdditionalProperties value
Default additionalProperties to false for all objects in the schema (true/false)
-schemaRoot.additionalProperties value
JSON schema additional properties (true/false)
-schemaRoot.description string
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func ParseFlags(progname string, args []string) (*Config, string, error) {
flags.StringVar(&conf.OutputPath, "output", "values.schema.json", "Output file path")
flags.IntVar(&conf.Draft, "draft", 2020, "Draft version (4, 6, 7, 2019, or 2020)")
flags.IntVar(&conf.Indent, "indent", 4, "Indentation spaces (even number)")
flags.Var(&conf.NoAdditionalProperties, "noAdditionalProperties", "Default additionalProperties to false for all objects in the schema")

// Nested SchemaRoot flags
flags.StringVar(&conf.SchemaRoot.ID, "schemaRoot.id", "", "JSON schema ID")
Expand Down Expand Up @@ -88,6 +89,10 @@ func MergeConfig(fileConfig, flagConfig *Config) *Config {
if flagConfig.IndentSet || mergedConfig.Indent == 0 {
mergedConfig.Indent = flagConfig.Indent
}

if flagConfig.NoAdditionalProperties.IsSet() {
mergedConfig.NoAdditionalProperties = flagConfig.NoAdditionalProperties
}
if flagConfig.SchemaRoot.ID != "" {
mergedConfig.SchemaRoot.ID = flagConfig.SchemaRoot.ID
}
Expand Down
4 changes: 3 additions & 1 deletion pkg/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,16 @@ func GenerateJsonSchema(config *Config) error {
}

// Convert merged Schema into a JSON Schema compliant map
jsonSchemaMap, err := convertSchemaToMap(mergedSchema)
jsonSchemaMap, err := convertSchemaToMap(mergedSchema, config.NoAdditionalProperties.value)
if err != nil {
return err
}
jsonSchemaMap["$schema"] = schemaURL // Include the schema draft version

if config.SchemaRoot.AdditionalProperties.IsSet() {
jsonSchemaMap["additionalProperties"] = config.SchemaRoot.AdditionalProperties.Value()
} else if config.NoAdditionalProperties.value {
jsonSchemaMap["additionalProperties"] = false
}

// If validation is successful, marshal the schema and save to the file
Expand Down
108 changes: 78 additions & 30 deletions pkg/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,68 @@ import (
)

func TestGenerateJsonSchema(t *testing.T) {
config := &Config{
Input: []string{
"../testdata/full.yaml",
"../testdata/empty.yaml",
tests := []struct {
name string
config *Config
templateSchemaFile string
}{
{
name: "full json schema",
config: &Config{
Input: []string{
"../testdata/full.yaml",
"../testdata/empty.yaml",
},
OutputPath: "../testdata/output.json",
Draft: 2020,
Indent: 4,
SchemaRoot: SchemaRoot{
ID: "https://example.com/schema",
Ref: "schema/product.json",
Title: "Helm Values Schema",
Description: "Schema for Helm values",
AdditionalProperties: BoolFlag{set: true, value: true},
},
},
templateSchemaFile: "../testdata/full.schema.json",
},
OutputPath: "../testdata/output.json",
Draft: 2020,
Indent: 4,
SchemaRoot: SchemaRoot{
ID: "https://example.com/schema",
Ref: "schema/product.json",
Title: "Helm Values Schema",
Description: "Schema for Helm values",
AdditionalProperties: BoolFlag{set: true, value: true},
{
name: "full json schema",
config: &Config{
Draft: 2020,
Indent: 4,
NoAdditionalProperties: BoolFlag{set: true, value: true},
Input: []string{
"../testdata/noAdditionalProperties.yaml",
},
OutputPath: "../testdata/output1.json",
},
templateSchemaFile: "../testdata/noAdditionalProperties.schema.json",
},
}

err := GenerateJsonSchema(config)
assert.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := GenerateJsonSchema(tt.config)
assert.NoError(t, err)

generatedBytes, err := os.ReadFile(config.OutputPath)
assert.NoError(t, err)
generatedBytes, err := os.ReadFile(tt.config.OutputPath)
assert.NoError(t, err)

templateBytes, err := os.ReadFile("../testdata/full.schema.json")
assert.NoError(t, err)
templateBytes, err := os.ReadFile(tt.templateSchemaFile)
assert.NoError(t, err)

var generatedSchema, templateSchema map[string]interface{}
err = json.Unmarshal(generatedBytes, &generatedSchema)
assert.NoError(t, err)
err = json.Unmarshal(templateBytes, &templateSchema)
assert.NoError(t, err)
var generatedSchema, templateSchema map[string]interface{}
err = json.Unmarshal(generatedBytes, &generatedSchema)
assert.NoError(t, err)
err = json.Unmarshal(templateBytes, &templateSchema)
assert.NoError(t, err)

assert.Equal(t, templateSchema, generatedSchema, "Generated JSON schema does not match the template")
assert.Equal(t, templateSchema, generatedSchema, "Generated JSON schema does not match the template")

os.Remove(config.OutputPath)
os.Remove(tt.config.OutputPath)
})
}
}

func TestGenerateJsonSchema_Errors(t *testing.T) {
Expand Down Expand Up @@ -149,6 +176,7 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) {
name string
additionalPropertiesSet bool
additionalProperties bool
noAdditionalProperties bool
expected interface{}
}{
{
Expand All @@ -168,22 +196,42 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) {
additionalPropertiesSet: false,
expected: nil,
},
{
name: "AdditionalProperties not set, but NoAdditionalProperties set",
additionalPropertiesSet: false,
noAdditionalProperties: true,
expected: false,
},
{
name: "NoAdditionalProperties set, but AdditionalProperties set to true",
additionalPropertiesSet: true,
additionalProperties: true,
noAdditionalProperties: true,
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
additionalPropertiesFlag := &BoolFlag{}
noAdditionalPropertiesFlag := &BoolFlag{}
if tt.additionalPropertiesSet {
if err := additionalPropertiesFlag.Set(fmt.Sprintf("%t", tt.additionalProperties)); err != nil {
t.Fatalf("Failed to set additionalPropertiesFlag: %v", err)
}
}
if tt.noAdditionalProperties {
if err := noAdditionalPropertiesFlag.Set(fmt.Sprintf("%t", tt.noAdditionalProperties)); err != nil {
t.Fatalf("Failed to set noAdditionalPropertiesFlag: %v", err)
}
}

config := &Config{
Input: []string{"../testdata/empty.yaml"},
OutputPath: "../testdata/empty.schema.json",
Draft: 2020,
Indent: 4,
Input: []string{"../testdata/empty.yaml"},
OutputPath: "../testdata/empty.schema.json",
Draft: 2020,
Indent: 4,
NoAdditionalProperties: *noAdditionalPropertiesFlag,
SchemaRoot: SchemaRoot{
ID: "",
Title: "",
Expand Down
27 changes: 19 additions & 8 deletions pkg/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ func mergeSchemas(dest, src *Schema) *Schema {
return dest
}

func convertSchemaToMap(schema *Schema) (map[string]interface{}, error) {
return convertSchemaToMapRec(schema, make(map[uintptr]bool))
func convertSchemaToMap(schema *Schema, noAdditionalProperties bool) (map[string]interface{}, error) {
return convertSchemaToMapRec(schema, make(map[uintptr]bool), noAdditionalProperties)
}

func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool) (map[string]interface{}, error) {
func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool, noAdditionalProperties bool) (map[string]interface{}, error) {
if schema == nil {
return nil, nil
}
Expand Down Expand Up @@ -161,9 +161,6 @@ func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool) (map[string
if schema.MinProperties != nil {
schemaMap["minProperties"] = *schema.MinProperties
}
if schema.PatternProperties != nil {
schemaMap["patternProperties"] = schema.PatternProperties
}
if schema.Title != "" {
schemaMap["title"] = schema.Title
}
Expand All @@ -178,6 +175,8 @@ func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool) (map[string
}
if schema.AdditionalProperties != nil {
schemaMap["additionalProperties"] = *schema.AdditionalProperties
} else if noAdditionalProperties && schema.Type == "object" {
schemaMap["additionalProperties"] = false
}
if schema.ID != "" {
schemaMap["$id"] = schema.ID
Expand All @@ -196,7 +195,7 @@ func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool) (map[string

// Nested Schemas
if schema.Items != nil {
itemsMap, err := convertSchemaToMapRec(schema.Items, visited)
itemsMap, err := convertSchemaToMapRec(schema.Items, visited, noAdditionalProperties)
if err != nil {
return nil, err
}
Expand All @@ -205,7 +204,7 @@ func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool) (map[string
if schema.Properties != nil {
propertiesMap := make(map[string]interface{})
for propName, propSchema := range schema.Properties {
propMap, err := convertSchemaToMapRec(propSchema, visited)
propMap, err := convertSchemaToMapRec(propSchema, visited, noAdditionalProperties)
if err != nil {
return nil, err
}
Expand All @@ -214,6 +213,18 @@ func convertSchemaToMapRec(schema *Schema, visited map[uintptr]bool) (map[string
schemaMap["properties"] = propertiesMap
}

if schema.PatternProperties != nil {
patternPropertiesMap := make(map[string]interface{})
for propName, propSchema := range schema.PatternProperties {
propMap, err := convertSchemaToMapRec(propSchema, visited, noAdditionalProperties)
if err != nil {
return nil, err
}
patternPropertiesMap[propName] = propMap
}
schemaMap["patternProperties"] = patternPropertiesMap
}

delete(visited, ptr)

return schemaMap, nil
Expand Down
4 changes: 2 additions & 2 deletions pkg/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func TestConvertSchemaToMap(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := convertSchemaToMap(tt.schema)
got, err := convertSchemaToMap(tt.schema, false)
if (err != nil) != tt.wantErr {
t.Errorf("convertSchemaToMap() error = %v, wantErr %v", err, tt.wantErr)
return
Expand Down Expand Up @@ -318,7 +318,7 @@ func TestConvertSchemaToMapFail(t *testing.T) {

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := convertSchemaToMap(tc.schema)
_, err := convertSchemaToMap(tc.schema, false)
tc.expectedErr(t, err)
})
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type Schema struct {
UniqueItems bool `json:"uniqueItems,omitempty"`
MaxProperties *uint64 `json:"maxProperties,omitempty"`
MinProperties *uint64 `json:"minProperties,omitempty"`
PatternProperties interface{} `json:"patternProperties,omitempty"`
PatternProperties map[string]*Schema `json:"patternProperties,omitempty"`
Required []string `json:"required,omitempty"`
Items *Schema `json:"items,omitempty"`
ItemsEnum []any `json:"itemsEnum,omitempty"`
Expand Down Expand Up @@ -170,7 +170,7 @@ func processComment(schema *Schema, comment string) (isRequired bool, isHidden b
schema.MinProperties = &v
}
case "patternProperties":
var jsonObject interface{}
var jsonObject map[string]*Schema
if err := json.Unmarshal([]byte(value), &jsonObject); err == nil {
schema.PatternProperties = jsonObject
}
Expand Down
9 changes: 5 additions & 4 deletions pkg/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ type SchemaRoot struct {

// Save values of parsed flags in Config
type Config struct {
Input multiStringFlag `yaml:"input"`
OutputPath string `yaml:"output"`
Draft int `yaml:"draft"`
Indent int `yaml:"indent"`
Input multiStringFlag `yaml:"input"`
OutputPath string `yaml:"output"`
Draft int `yaml:"draft"`
Indent int `yaml:"indent"`
NoAdditionalProperties BoolFlag `yaml:"noAdditionalProperties"`

SchemaRoot SchemaRoot `yaml:"schemaRoot"`

Expand Down
39 changes: 39 additions & 0 deletions testdata/noAdditionalProperties.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"object": {
"additionalProperties": false,
"properties": {},
"type": "object"
},
"objectOfObjects": {
"additionalProperties": false,
"patternProperties": {
"^.*$": {
"additionalProperties": false,
"type": "object"
}
},
"properties": {},
"type": "object"
},
"objectOfObjectsWithInnerAdditionalPropertiesAllowed": {
"additionalProperties": false,
"patternProperties": {
"^.*$": {
"additionalProperties": true,
"type": "object"
}
},
"properties": {},
"type": "object"
},
"objectWithAdditionalPropertiesAllowed": {
"additionalProperties": true,
"properties": {},
"type": "object"
}
},
"type": "object"
}
4 changes: 4 additions & 0 deletions testdata/noAdditionalProperties.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
object: {}
objectWithAdditionalPropertiesAllowed: {} # @schema additionalProperties: true
objectOfObjects: {} # @schema patternProperties: { "^.*$": {"type": "object" } }
objectOfObjectsWithInnerAdditionalPropertiesAllowed: {} # @schema patternProperties: { "^.*$": {"type": "object", "additionalProperties": true }}

0 comments on commit 9cfee26

Please sign in to comment.