From 9cfee2633dfd2f845c71d2edbeb6b46cd44cd2ad Mon Sep 17 00:00:00 2001 From: Pascal Hofmann Date: Thu, 24 Oct 2024 16:06:07 +0200 Subject: [PATCH] feat: introduce flag noAdditionalProperties 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. --- README.md | 2 + pkg/cmd.go | 5 + pkg/generator.go | 4 +- pkg/generator_test.go | 108 ++++++++++++++------ pkg/parser.go | 27 +++-- pkg/parser_test.go | 4 +- pkg/schema.go | 4 +- pkg/utils.go | 9 +- testdata/noAdditionalProperties.schema.json | 39 +++++++ testdata/noAdditionalProperties.yaml | 4 + 10 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 testdata/noAdditionalProperties.schema.json create mode 100644 testdata/noAdditionalProperties.yaml diff --git a/README.md b/README.md index 2b8c17e..569c8c0 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ Usage: helm schema [options...] 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 diff --git a/pkg/cmd.go b/pkg/cmd.go index 44b07d9..16e3420 100644 --- a/pkg/cmd.go +++ b/pkg/cmd.go @@ -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") @@ -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 } diff --git a/pkg/generator.go b/pkg/generator.go index 29fed13..90f562b 100644 --- a/pkg/generator.go +++ b/pkg/generator.go @@ -89,7 +89,7 @@ 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 } @@ -97,6 +97,8 @@ func GenerateJsonSchema(config *Config) error { 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 diff --git a/pkg/generator_test.go b/pkg/generator_test.go index 2055b3d..11fff24 100644 --- a/pkg/generator_test.go +++ b/pkg/generator_test.go @@ -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) { @@ -149,6 +176,7 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) { name string additionalPropertiesSet bool additionalProperties bool + noAdditionalProperties bool expected interface{} }{ { @@ -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: "", diff --git a/pkg/parser.go b/pkg/parser.go index 057c69f..9c9ef57 100644 --- a/pkg/parser.go +++ b/pkg/parser.go @@ -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 } @@ -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 } @@ -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 @@ -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 } @@ -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 } @@ -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 diff --git a/pkg/parser_test.go b/pkg/parser_test.go index a889d95..6b1f1a7 100644 --- a/pkg/parser_test.go +++ b/pkg/parser_test.go @@ -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 @@ -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) }) } diff --git a/pkg/schema.go b/pkg/schema.go index 246615c..991d320 100644 --- a/pkg/schema.go +++ b/pkg/schema.go @@ -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"` @@ -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 } diff --git a/pkg/utils.go b/pkg/utils.go index b3492c8..ff7eb32 100644 --- a/pkg/utils.go +++ b/pkg/utils.go @@ -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"` diff --git a/testdata/noAdditionalProperties.schema.json b/testdata/noAdditionalProperties.schema.json new file mode 100644 index 0000000..f393a72 --- /dev/null +++ b/testdata/noAdditionalProperties.schema.json @@ -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" +} diff --git a/testdata/noAdditionalProperties.yaml b/testdata/noAdditionalProperties.yaml new file mode 100644 index 0000000..8bcbeab --- /dev/null +++ b/testdata/noAdditionalProperties.yaml @@ -0,0 +1,4 @@ +object: {} +objectWithAdditionalPropertiesAllowed: {} # @schema additionalProperties: true +objectOfObjects: {} # @schema patternProperties: { "^.*$": {"type": "object" } } +objectOfObjectsWithInnerAdditionalPropertiesAllowed: {} # @schema patternProperties: { "^.*$": {"type": "object", "additionalProperties": true }}