├── .github ├── FUNDING.yml ├── dependabot.yaml └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .schema.yaml ├── CODEOWNERS ├── LICENSE ├── Makefile ├── README.md ├── codecov.yml ├── docs └── README.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── pkg ├── bundle.go ├── bundle_test.go ├── cmd.go ├── cmd_test.go ├── generator.go ├── generator_test.go ├── parser.go ├── parser_test.go ├── pointer.go ├── pointer_test.go ├── schema.go ├── schema_test.go ├── utils.go └── utils_test.go ├── plugin.complete ├── plugin.yaml ├── scripts ├── git-hook.sh └── install.sh └── testdata ├── anchors.schema.json ├── anchors.yaml ├── basic.schema.json ├── basic.yaml ├── bundle ├── dir1 │ └── namecollision-subschema.schema.json ├── dir2 │ └── namecollision-subschema.schema.json ├── fragment-without-id.schema.json ├── fragment.schema.json ├── fragment.yaml ├── invalid-schema.json ├── invalid-schema.yaml ├── namecollision.schema.json ├── namecollision.yaml ├── nested-subschema.schema.json ├── nested-without-id.schema.json ├── nested.schema.json ├── nested.yaml ├── simple-disabled.schema.json ├── simple-subschema.schema.json ├── simple-without-id.schema.json ├── simple.schema.json ├── simple.yaml ├── yaml-subschema.schema.yaml ├── yaml.schema.json └── yaml.yaml ├── doc.go ├── empty.yaml ├── fail ├── fail-type.yaml ├── full.schema.json ├── full.yaml ├── k8sRef.schema.json ├── k8sRef.yaml ├── meta.schema.json ├── meta.yaml ├── noAdditionalProperties.schema.json ├── noAdditionalProperties.yaml ├── ref-draft2020.schema.json ├── ref-draft7.schema.json ├── ref.yaml ├── subschema.schema.json ├── subschema.yaml └── values.schema.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: losisin 2 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | labels: 6 | - dependabot 7 | - actions 8 | schedule: 9 | interval: weekly 10 | 11 | - package-ecosystem: gomod 12 | directory: / 13 | labels: 14 | - dependabot 15 | - go 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | env: 14 | VERBOSE: 1 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Set Helm 21 | uses: azure/setup-helm@v4.3.0 22 | with: 23 | version: v3.12.1 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version-file: go.mod 28 | cache: true 29 | - name: golangci-lint 30 | uses: golangci/golangci-lint-action@v8 31 | with: 32 | args: --timeout 5m0s 33 | skip-cache: false 34 | - name: Check code 35 | run: make check 36 | - name: Run Gosec Security Scanner 37 | uses: securego/gosec@v2.22.4 38 | with: 39 | args: ./... 40 | - name: Bearer 41 | uses: bearer/bearer-action@v2 42 | with: 43 | scanner: secrets,sast 44 | diff: true 45 | - name: Run tests 46 | run: make test-all 47 | - name: Install plugin 48 | run: make install 49 | - name: Run plugin 50 | run: helm schema -help 51 | - name: Upload coverage reports to Codecov 52 | uses: codecov/codecov-action@v5 53 | with: 54 | flags: unittests 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | permissions: 7 | contents: write 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version-file: go.mod 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | distribution: goreleaser 24 | version: latest 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | schema 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | *.html 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | vendor/ 18 | 19 | # Go workspace file 20 | go.work 21 | 22 | #custom 23 | .vscode 24 | cover.out 25 | dist/ -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines bellow are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | 16 | builds: 17 | - env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - darwin 21 | - linux 22 | - windows 23 | goarch: 24 | - amd64 25 | - arm64 26 | ldflags: 27 | - -s -w 28 | -X main.Version={{ .Tag }} 29 | -X main.GitCommit={{ .Commit }} 30 | -X main.BuildDate={{ .Date }} 31 | binary: schema 32 | 33 | archives: 34 | - id: schema 35 | formats: 36 | - tgz 37 | files: 38 | - LICENSE 39 | - README.md 40 | - plugin.yaml 41 | 42 | checksum: 43 | name_template: '{{ .ProjectName }}-checksum.sha' 44 | algorithm: sha256 45 | 46 | changelog: 47 | sort: asc 48 | groups: 49 | - title: Added 50 | regexp: '^.*?feature(\([[:word:]]+\))??!?:.+$' 51 | order: 0 52 | - title: "Fixed" 53 | regexp: '^.*?bug|fix(\([[:word:]]+\))??!?:.+$' 54 | order: 1 55 | - title: Updated 56 | regexp: '^.*?Bump|chore(\([[:word:]]+\))??!?:.+$' 57 | order: 2 58 | - title: Docs 59 | regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' 60 | order: 3 61 | - title: Other 62 | order: 999 63 | filters: 64 | exclude: 65 | - "^Merge" # exclude merge commits 66 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/losisin/helm-values-schema-json 3 | rev: v1.8.0 4 | hooks: 5 | - id: helm-schema 6 | args: 7 | # Single or multiple yaml files as inputs (comma-separated) 8 | - --input=values.yaml 9 | # Output file path (default "values.schema.json") 10 | - --output=values.schema.json 11 | # Draft version (4, 6, 7, 2019, or 2020) (default 2020) 12 | - --draft=2020 13 | # Indentation spaces (even number) 14 | - --indent=4 15 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: helm-schema 2 | args: [] 3 | description: Helm plugin for generating values.schema.json from single or multiple values files. Works only with Helm3 charts. 4 | entry: scripts/git-hook.sh 5 | language: script 6 | name: Helm values schema json 7 | require_serial: true 8 | -------------------------------------------------------------------------------- /.schema.yaml: -------------------------------------------------------------------------------- 1 | # Required 2 | input: 3 | - ".schema.yaml" 4 | 5 | draft: 2020 # @schema enum: [4, 6, 7, 2019, 2020]; default: 2020 6 | indent: 4 # @schema default: 4 7 | output: values.schema.json # @schema default: values.schema.json 8 | 9 | bundle: true # @schema default: false 10 | bundleRoot: testdata # @schema default: "" 11 | bundleWithoutID: true # @schema default: false 12 | 13 | k8sSchemaURL: https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/ 14 | k8sSchemaVersion: "v1.33.1" 15 | 16 | schemaRoot: 17 | id: https://example.com/schema 18 | ref: bundle/simple-subschema.schema.json 19 | title: Helm Values Schema 20 | description: Schema for Helm values 21 | additionalProperties: true 22 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @losisin @applejag 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 - 2024 Aleksandar Stojanov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | BINNAME := schema 4 | PLUGIN_SHORTNAME := json-schema 5 | 6 | BUILD_DATE := $(shell date -u '+%Y-%m-%d %I:%M:%S UTC' 2> /dev/null) 7 | GIT_HASH := $(shell git rev-parse HEAD 2> /dev/null) 8 | 9 | GOPATH ?= $(shell go env GOPATH) 10 | PATH := $(GOPATH)/bin:$(PATH) 11 | GO_BUILD_ENV_VARS = $(if $(GO_ENV_VARS),$(GO_ENV_VARS),CGO_ENABLED=0) 12 | GO_BUILD_ARGS = -buildvcs=false -ldflags "-X main.GitCommit=${GIT_HASH}" 13 | 14 | HELM_PLUGINS = $(shell helm env HELM_PLUGINS) 15 | HELM_PLUGIN_DIR = $(HELM_PLUGINS)/$(PLUGIN_SHORTNAME) 16 | 17 | .PHONY: build \ 18 | install \ 19 | verify \ 20 | tidy \ 21 | fmt \ 22 | vet \ 23 | check \ 24 | test-unit \ 25 | test-coverage \ 26 | test-all \ 27 | clean \ 28 | help 29 | 30 | build: ## Build the plugin 31 | @echo "Building plugin..." 32 | @${GO_BUILD_ENV_VARS} go build -o $(BINNAME) ${GO_BUILD_ARGS} 33 | 34 | install: build ## Install the plugin 35 | @echo "Installing plugin..." 36 | @mkdir -p $(HELM_PLUGIN_DIR) 37 | @cp $(BINNAME) $(HELM_PLUGIN_DIR) 38 | @cp plugin.yaml $(HELM_PLUGIN_DIR) 39 | @cp plugin.complete $(HELM_PLUGIN_DIR) 40 | 41 | generate: ## Generate files 42 | go generate ./testdata 43 | 44 | verify: ## Verify the plugin 45 | @echo 46 | @echo "Verifying plugin..." 47 | @go mod verify 48 | 49 | tidy: ## Tidy the plugin 50 | @echo 51 | @echo "Tidying plugin..." 52 | @go mod tidy 53 | 54 | fmt: ## Format the plugin 55 | @echo 56 | @echo "Formatting plugin..." 57 | @go fmt ./... 58 | 59 | vet: ## Vet the plugin 60 | @echo 61 | @echo "Vetting plugin..." 62 | @go vet ./... 63 | 64 | check: verify tidy fmt vet ## Verify, tidy, fmt and vet the plugin 65 | 66 | test-unit: ## Run unit tests 67 | @echo 68 | @echo "Running unit tests..." 69 | @go test -short ./... 70 | 71 | test-coverage: ## Run tests with coverage 72 | @echo 73 | @echo "Running tests with coverage..." 74 | @go test -v -race -covermode=atomic -coverprofile=cover.out ./... 75 | 76 | test-all: test-unit test-coverage ## Includes test-unit and test-coverage 77 | 78 | clean: ## Clean the plugin 79 | @echo "Cleaning plugin..." 80 | @rm -rf $(BINNAME) $(HELM_PLUGIN_DIR) 81 | 82 | help: ## Show this help message 83 | @echo "Usage: make " 84 | @echo "" 85 | @echo "Targets:" 86 | @echo " build Build the plugin" 87 | @echo " install Install the plugin" 88 | @echo " generate Generate files" 89 | @echo " verify Verify the plugin" 90 | @echo " tidy Tidy the plugin" 91 | @echo " fmt Format the plugin" 92 | @echo " vet Vet the plugin" 93 | @echo " check Includes verify, tidy, fmt, vet" 94 | @echo " test-unit Run unit tests" 95 | @echo " test-coverage Run tests with coverage" 96 | @echo " test-all Includes test-unit, test-coverage" 97 | @echo " clean Clean the plugin" 98 | @echo " help Show this help message" 99 | @echo "" 100 | @echo "Variables:" 101 | @echo " GOPATH The GOPATH to use (default: \$$GOPATH)" 102 | @echo " PATH The PATH to use (default: \$$GOPATH/bin:\$$PATH)" 103 | @echo " HELM_PLUGINS The HELM_PLUGINS directory (default: \$$HELM_PLUGINS)" 104 | @echo " HELM_PLUGIN_DIR The HELM_PLUGIN_DIR directory (default: \$$HELM_PLUGIN_DIR)" 105 | @echo " BINNAME The name of the binary to build (default: $(BINNAME))" 106 | @echo " PLUGIN_SHORTNAME The short name of the plugin (default: $(PLUGIN_SHORTNAME))" 107 | @echo "" 108 | 109 | default: help 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # helm values schema json plugin 2 | 3 | [![ci](https://github.com/losisin/helm-values-schema-json/actions/workflows/ci.yaml/badge.svg)](https://github.com/losisin/helm-values-schema-json/actions/workflows/ci.yaml) 4 | [![codecov](https://codecov.io/gh/losisin/helm-values-schema-json/graph/badge.svg?token=0QQVCFJH84)](https://codecov.io/gh/losisin/helm-values-schema-json) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/losisin/helm-values-schema-json)](https://goreportcard.com/report/github.com/losisin/helm-values-schema-json) 6 | [![Static Badge](https://img.shields.io/badge/licence%20-%20MIT-green)](https://github.com/losisin/helm-values-schema-json/blob/main/LICENSE) 7 | [![GitHub release (with filter)](https://img.shields.io/github/v/release/losisin/helm-values-schema-json)](https://github.com/losisin/helm-values-schema-json/releases) 8 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/losisin/helm-values-schema-json/total) 9 | 10 | 11 | Helm plugin for generating `values.schema.json` from single or multiple values files. Schema can be enriched by reading annotations from comments. Works only with Helm3 charts. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | $ helm plugin install https://github.com/losisin/helm-values-schema-json.git 17 | Installed plugin: schema 18 | ``` 19 | 20 | ## Features 21 | 22 | - Add multiple values files and merge them together - required 23 | - Save output with custom name and location - default is values.schema.json in current working directory 24 | - Use preferred schema draft version - default is draft 2020 25 | - Read annotations from comments. See [docs](https://github.com/losisin/helm-values-schema-json/tree/main/docs) for more info or checkout example yaml files in [testdata](https://github.com/losisin/helm-values-schema-json/tree/main/testdata). 26 | 27 | ## Integrations 28 | 29 | There are several ways to automate schema generation with this plugin. Main reason is that the json schema file can be hard to follow and we as humans tend to forget and update routine tasks. So why not automate it? 30 | 31 | ### GitHub actions 32 | 33 | There is GitHub action that I've build using typescript and published on marketplace. You can find it [here](https://github.com/marketplace/actions/helm-values-schema-json-action). Basic usage is as follows: 34 | 35 | ```yaml 36 | name: Generate values schema json 37 | on: 38 | - pull_request 39 | jobs: 40 | generate: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | with: 45 | ref: ${{ github.event.pull_request.head.ref }} 46 | - name: Generate values schema json 47 | uses: losisin/helm-values-schema-json-action@v1 48 | with: 49 | input: values.yaml 50 | ``` 51 | 52 | ### pre-commit hook 53 | 54 | With pre-commit, you can ensure your JSON schema is kept up-to-date each time you make a commit. 55 | 56 | First [install pre-commit](https://pre-commit.com/#install) and then create or update a `.pre-commit-config.yaml` in the root of your Git repo with at least the following content: 57 | 58 | ```yaml 59 | repos: 60 | - repo: https://github.com/losisin/helm-values-schema-json 61 | rev: v1.7.2 62 | hooks: 63 | - id: helm-schema 64 | args: ["-input", "values.yaml"] 65 | ``` 66 | 67 | Then run: 68 | 69 | ```bash 70 | pre-commit install 71 | pre-commit install-hooks 72 | ``` 73 | 74 | Further changes to your chart files will cause an update to json schema when you make a commit. 75 | 76 | ### Husky 77 | 78 | This is a great tool for adding git hooks to your project. You can find it's documentation [here](https://typicode.github.io/husky/). Here is how you can use it: 79 | 80 | ```json 81 | "husky": { 82 | "hooks": { 83 | "pre-commit": "helm schema -input values.yaml" 84 | } 85 | }, 86 | ``` 87 | 88 | ### CI/CD fail-on-diff 89 | 90 | You can use this plugin in your CI/CD pipeline to ensure that the schema is always up-to-date. Here is an example for GitLab [#82](https://github.com/losisin/helm-values-schema-json/issues/82): 91 | 92 | ```yaml 93 | schema-check: 94 | script: 95 | - cd path/to/helm/chart 96 | - helm schema -output generated-schema.json 97 | - CURRENT_SCHEMA=$(cat values.schema.json) 98 | - GENERATED_SCHEMA=$(cat generated-schema.json) 99 | - | 100 | if [ "$CURRENT_SCHEMA" != "$GENERATED_SCHEMA" ]; then 101 | echo "Schema must be re-generated! Run 'helm schema' in the helm-chart directory" 1>&2 102 | exit 1 103 | fi 104 | ``` 105 | 106 | ## Usage 107 | 108 | ```bash 109 | $ helm schema -help 110 | Usage: helm schema [options...] 111 | -draft int 112 | Draft version (4, 6, 7, 2019, or 2020) (default 2020) 113 | -indent int 114 | Indentation spaces (even number) (default 4) 115 | -input value 116 | Multiple yaml files as inputs (comma-separated) 117 | -output string 118 | Output file path (default "values.schema.json") 119 | -noAdditionalProperties value 120 | Default additionalProperties to false for all objects in the schema (true/false) 121 | -schemaRoot.additionalProperties value 122 | JSON schema additional properties (true/false) 123 | -schemaRoot.description string 124 | JSON schema description 125 | -schemaRoot.id string 126 | JSON schema ID 127 | -schemaRoot.ref string 128 | JSON schema URI reference 129 | -schemaRoot.title string 130 | JSON schema title 131 | -bundle value 132 | Bundle referenced ($ref) subschemas into a single file inside $defs 133 | -bundleRoot string 134 | Root directory to allow local referenced files to be loaded from (default current working directory) 135 | -bundleWithoutID value 136 | Bundle without using $id to reference bundled schemas, which improves compatibility with e.g the VS Code JSON extension 137 | -k8sSchemaURL string 138 | URL template used in $ref: $k8s/... alias (default "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/") 139 | -k8sSchemaVersion string 140 | Version used in the --k8sSchemaURL template for $ref: $k8s/... alias 141 | ``` 142 | 143 | ### Configuration file 144 | 145 | This plugin will look for it's configuration file called `.schema.yaml` in the current working directory. All options available from CLI can be set in this file. Example: 146 | 147 | ```yaml 148 | # Required 149 | input: 150 | - values.yaml 151 | 152 | draft: 2020 153 | indent: 4 154 | output: values.schema.json 155 | 156 | schemaRoot: 157 | id: https://example.com/schema 158 | title: Helm Values Schema 159 | description: Schema for Helm values 160 | additionalProperties: true 161 | ``` 162 | 163 | Then, just run the plugin without any arguments: 164 | 165 | ```bash 166 | $ helm schema 167 | ``` 168 | 169 | ### CLI 170 | 171 | #### Basic 172 | 173 | In most cases you will want to run the plugin with default options: 174 | 175 | ```bash 176 | $ helm schema -input values.yaml 177 | ``` 178 | 179 | This will read `values.yaml`, set draft version to `2020-12` and save outpout to `values.schema.json`. 180 | 181 | #### Extended 182 | 183 | ##### Multiple values files 184 | 185 | Merge multiple values files, set json-schema draft version explicitly and save output to `my.schema.json`: 186 | 187 | `values_1.yaml` 188 | 189 | ```yaml 190 | nodeSelector: 191 | kubernetes.io/hostname: "" 192 | dummyList: 193 | - "a" 194 | - "b" 195 | - "c" 196 | key1: "asd" 197 | key2: 42 198 | key3: {} 199 | key4: [] 200 | ``` 201 | 202 | `custom/path/values_2.yaml` 203 | 204 | ```yaml 205 | nodeSelector: 206 | kubernetes.io/hostname: "node1" 207 | deep: 208 | deep1: 209 | deep2: 210 | deep3: 211 | deep4: "asdf" 212 | ``` 213 | 214 | Run the following command to merge the yaml files and output json schema: 215 | 216 | ```bash 217 | $ helm schema -input values_1.yaml,custom/path/values_2.yaml -draft 7 -output my.schema.json 218 | ``` 219 | 220 | Output will be something like this: 221 | 222 | ```json 223 | { 224 | "$schema": "http://json-schema.org/draft-07/schema#", 225 | "type": "object", 226 | "properties": { 227 | "deep": { 228 | "type": "object", 229 | "properties": { 230 | "deep1": { 231 | "type": "object", 232 | "properties": { 233 | "deep2": { 234 | "type": "object", 235 | "properties": { 236 | "deep3": { 237 | "type": "object", 238 | "properties": { 239 | "deep4": { 240 | "type": "string" 241 | } 242 | } 243 | } 244 | } 245 | } 246 | } 247 | } 248 | } 249 | }, 250 | "dummyList": { 251 | "type": "array", 252 | "items": { 253 | "type": "string" 254 | } 255 | }, 256 | "key1": { 257 | "type": "string" 258 | }, 259 | "key2": { 260 | "type": "integer" 261 | }, 262 | "key3": { 263 | "type": "object" 264 | }, 265 | "key4": { 266 | "type": "array" 267 | }, 268 | "nodeSelector": { 269 | "type": "object", 270 | "properties": { 271 | "kubernetes.io/hostname": { 272 | "type": "string" 273 | } 274 | } 275 | } 276 | } 277 | } 278 | ``` 279 | 280 | > [!NOTE] 281 | > When using multiple values files as input, the plugin follows Helm's behavior. This means that if the same yaml keys are present in multiple files, the latter file will take precedence over the former. The same applies to annotations in comments. Therefore, the order of the input files is important. 282 | 283 | ##### Root JSON object properties 284 | 285 | Adding ID, title and description to the schema: 286 | 287 | `basic.yaml` 288 | 289 | ```yaml 290 | image: 291 | repository: nginx 292 | tag: latest 293 | pullPolicy: Always 294 | ``` 295 | 296 | ```bash 297 | $ helm schema -input values.yaml -schemaRoot.id "https://example.com/schema" -schemaRoot.ref "schema/product.json" -schemaRoot.title "My schema" -schemaRoot.description "This is my schema" 298 | ``` 299 | 300 | Generated schema will be: 301 | 302 | ```json 303 | { 304 | "$id": "https://example.com/schema", 305 | "$ref": "schema/product.json", 306 | "$schema": "https://json-schema.org/draft/2020-12/schema", 307 | "additionalProperties": true, 308 | "description": "This is my schema", 309 | "properties": { 310 | "image": { 311 | "properties": { 312 | "pullPolicy": { 313 | "type": "string" 314 | }, 315 | "repository": { 316 | "type": "string" 317 | }, 318 | "tag": { 319 | "type": "string" 320 | } 321 | }, 322 | "type": "object" 323 | } 324 | }, 325 | "title": "My schema", 326 | "type": "object" 327 | } 328 | ``` 329 | 330 | ## Issues, Features, Feedback 331 | 332 | Your input matters. Feel free to open [issues](https://github.com/losisin/helm-values-schema-json/issues) for bugs, feature requests, or any feedback you may have. Check if a similar issue exists before creating a new one, and please use clear titles and explanations to help understand your point better. Your thoughts help me improve this project! 333 | 334 | ### How to Contribute 335 | 336 | 🌟 Thank you for considering contributing to my project! Your efforts are incredibly valuable. To get started: 337 | 1. Fork the repository. 338 | 2. Create your feature branch: `git checkout -b feature/YourFeature` 339 | 3. Commit your changes: `git commit -am 'Add: YourFeature'` 340 | 4. Push to the branch: `git push origin feature/YourFeature` 341 | 5. Submit a pull request! 🚀 342 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: nearest 4 | range: 50...70 5 | comment: 6 | require_changes: true 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/losisin/helm-values-schema-json 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/stretchr/testify v1.10.0 7 | gopkg.in/yaml.v3 v3.0.1 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 4 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 5 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 6 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 7 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 11 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 14 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/losisin/helm-values-schema-json/pkg" 10 | ) 11 | 12 | func main() { 13 | // Load configuration from a YAML file 14 | fileConfig, err := pkg.LoadConfig(".schema.yaml") 15 | if err != nil { 16 | fmt.Println("Error loading config file:", err) 17 | } 18 | 19 | // Parse CLI flags 20 | var completeErr pkg.ErrCompletionRequested 21 | flagConfig, output, err := pkg.ParseFlags(os.Args[0], os.Args[1:]) 22 | if err == flag.ErrHelp { 23 | fmt.Println(output) 24 | return 25 | } else if errors.As(err, &completeErr) { 26 | completeErr.Fprint(os.Stdout) 27 | return 28 | } else if err != nil { 29 | fmt.Println("Error parsing flags:", output) 30 | return 31 | } 32 | 33 | // Merge configurations, giving precedence to CLI flags 34 | var finalConfig *pkg.Config 35 | if fileConfig != nil { 36 | finalConfig = pkg.MergeConfig(fileConfig, flagConfig) 37 | } else { 38 | finalConfig = flagConfig 39 | } 40 | 41 | err = pkg.GenerateJsonSchema(finalConfig) 42 | if err != nil { 43 | fmt.Println("Error:", err) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestMain(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | args []string 17 | setup func() 18 | cleanup func() 19 | expectedError string 20 | expectedOut string 21 | }{ 22 | { 23 | name: "HelpFlag", 24 | args: []string{"schema", "-h"}, 25 | expectedOut: "Usage of schema", 26 | expectedError: "", 27 | }, 28 | { 29 | name: "CompleteFlag", 30 | args: []string{"schema", "__complete"}, 31 | expectedOut: "--draft\tDraft version", 32 | expectedError: "", 33 | }, 34 | { 35 | name: "InvalidFlags", 36 | args: []string{"schema", "-fail"}, 37 | expectedOut: "", 38 | expectedError: "flag provided but not defined", 39 | }, 40 | { 41 | name: "SuccessfulRun", 42 | args: []string{"schema", "-input", "testdata/basic.yaml"}, 43 | expectedOut: "JSON schema successfully generated", 44 | expectedError: "", 45 | }, 46 | { 47 | name: "GenerateError", 48 | args: []string{"schema", "-input", "fail.yaml", "-draft", "2020"}, 49 | expectedOut: "error reading YAML file(s)", 50 | expectedError: "", 51 | }, 52 | { 53 | name: "ErrorLoadingConfigFile", 54 | args: []string{"schema", "-input", "testdata/basic.yaml"}, 55 | setup: func() { 56 | if _, err := os.Stat(".schema.yaml"); err == nil { 57 | if err := os.Rename(".schema.yaml", ".schema.yaml.bak"); err != nil { 58 | log.Fatalf("Error renaming file: %v", err) 59 | } 60 | } 61 | 62 | file, _ := os.Create(".schema.yaml") 63 | defer func() { 64 | if err := file.Close(); err != nil { 65 | log.Fatalf("Error closing file: %v", err) 66 | } 67 | }() 68 | if _, err := file.WriteString("draft: invalid\n"); err != nil { 69 | log.Fatalf("Error writing to file: %v", err) 70 | } 71 | }, 72 | cleanup: func() { 73 | if _, err := os.Stat(".schema.yaml.bak"); err == nil { 74 | if err := os.Remove(".schema.yaml"); err != nil && !os.IsNotExist(err) { 75 | log.Fatalf("Error removing file: %v", err) 76 | } 77 | if err := os.Rename(".schema.yaml.bak", ".schema.yaml"); err != nil { 78 | log.Fatalf("Error renaming file: %v", err) 79 | } 80 | } else { 81 | if err := os.Remove(".schema.yaml"); err != nil && !os.IsNotExist(err) { 82 | log.Fatalf("Error removing file: %v", err) 83 | } 84 | } 85 | }, 86 | expectedOut: "", 87 | expectedError: "Error loading config file", 88 | }, 89 | } 90 | 91 | for _, tt := range tests { 92 | t.Run(tt.name, func(t *testing.T) { 93 | originalArgs := os.Args 94 | originalStdout := os.Stdout 95 | 96 | if tt.setup != nil { 97 | tt.setup() 98 | } 99 | if tt.cleanup != nil { 100 | defer tt.cleanup() 101 | } 102 | 103 | r, w, _ := os.Pipe() 104 | os.Stdout = w 105 | 106 | os.Args = tt.args 107 | 108 | errCh := make(chan error, 1) 109 | 110 | go func() { 111 | main() 112 | if err := w.Close(); err != nil { 113 | errCh <- err 114 | } 115 | close(errCh) 116 | }() 117 | 118 | if err := <-errCh; err != nil { 119 | t.Errorf("Error closing pipe: %v", err) 120 | } 121 | 122 | var buf bytes.Buffer 123 | _, err := io.Copy(&buf, r) 124 | if err != nil { 125 | t.Errorf("Error reading stdout: %v", err) 126 | } 127 | 128 | os.Args = originalArgs 129 | os.Stdout = originalStdout 130 | 131 | out := buf.String() 132 | 133 | assert.Contains(t, out, tt.expectedError) 134 | if tt.expectedOut != "" { 135 | assert.Contains(t, out, tt.expectedOut) 136 | } 137 | if err := os.Remove("values.schema.json"); err != nil && !os.IsNotExist(err) { 138 | t.Errorf("failed to remove values.schema.json: %v", err) 139 | } 140 | }) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /pkg/bundle.go: -------------------------------------------------------------------------------- 1 | /* 2 | This file contains some modified versions of the 3 | santhosh-tekuri/jsonschema loader code, licensed 4 | under the Apache License, Version 2.0 5 | 6 | Based on code from: 7 | - https://github.com/santhosh-tekuri/jsonschema/blob/87df339550a7b2440ff7da286bd34ece7d74039b/loader.go 8 | - https://github.com/santhosh-tekuri/jsonschema/blob/87df339550a7b2440ff7da286bd34ece7d74039b/cmd/jv/loader.go 9 | */ 10 | 11 | package pkg 12 | 13 | import ( 14 | "compress/gzip" 15 | "context" 16 | "encoding/json" 17 | "errors" 18 | "fmt" 19 | "io" 20 | "maps" 21 | "mime" 22 | "net/http" 23 | "net/url" 24 | "os" 25 | "path" 26 | "path/filepath" 27 | "regexp" 28 | "runtime" 29 | "slices" 30 | "strings" 31 | "time" 32 | 33 | "gopkg.in/yaml.v3" 34 | ) 35 | 36 | func NewDefaultLoader(client *http.Client, root *os.Root, basePath string) Loader { 37 | fileLoader := NewFileLoader(root, basePath) 38 | httpLoader := NewHTTPLoader(client) 39 | return NewCacheLoader(URLSchemeLoader{ 40 | "http": httpLoader, 41 | "https": httpLoader, 42 | "file": fileLoader, // Used for "file:///some/abs/path" 43 | "": fileLoader, // Used for "./foobar.json" or "/some/abs/path" 44 | }) 45 | } 46 | 47 | // BundleSchema will use the [Loader] to load any "$ref" references and 48 | // store them in "$defs". 49 | // 50 | // This function will update the schema in-place. 51 | func BundleSchema(ctx context.Context, loader Loader, schema *Schema) error { 52 | if loader == nil { 53 | return fmt.Errorf("nil loader") 54 | } 55 | if schema == nil { 56 | return fmt.Errorf("nil schema") 57 | } 58 | return bundleSchemaRec(ctx, nil, loader, schema, schema) 59 | } 60 | 61 | func bundleSchemaRec(ctx context.Context, ptr Ptr, loader Loader, root, schema *Schema) error { 62 | for path, subSchema := range schema.Subschemas() { 63 | ptr := ptr.Add(path) 64 | if err := bundleSchemaRec(ctx, ptr, loader, root, subSchema); err != nil { 65 | return fmt.Errorf("%s: %w", ptr, err) 66 | } 67 | } 68 | 69 | if schema.Ref == "" || strings.HasPrefix(schema.Ref, "#") { 70 | // Nothing to bundle 71 | return nil 72 | } 73 | for _, def := range root.Defs { 74 | if def.ID == bundleRefToID(schema.Ref) { 75 | // Already bundled 76 | return nil 77 | } 78 | } 79 | if schema.ID != "" { 80 | ctx = ContextWithLoaderReferrer(ctx, schema.ID) 81 | } 82 | loaded, err := Load(ctx, loader, schema.Ref) 83 | if err != nil { 84 | return err 85 | } 86 | if root.Defs == nil { 87 | root.Defs = map[string]*Schema{} 88 | } 89 | 90 | // Copy over $defs 91 | moveDefToRoot(root, &loaded.Defs) 92 | moveDefToRoot(root, &loaded.Definitions) 93 | 94 | // Add the value itself 95 | root.Defs[generateBundledName(loaded.ID, root.Defs)] = loaded 96 | 97 | return bundleSchemaRec(ctx, ptr, loader, root, loaded) 98 | } 99 | 100 | func moveDefToRoot(root *Schema, defs *map[string]*Schema) { 101 | for key, def := range *defs { 102 | if def.ID == "" { 103 | // Only move items that are referenced by $id. 104 | continue 105 | } 106 | root.Defs[generateBundledName(def.ID, root.Defs)] = def 107 | delete(*defs, key) 108 | } 109 | if len(*defs) == 0 { 110 | *defs = nil 111 | } 112 | } 113 | 114 | func generateBundledName(id string, defs map[string]*Schema) string { 115 | if id == "" { 116 | return "" 117 | } 118 | for name, def := range defs { 119 | if def.ID == id { 120 | return name 121 | } 122 | } 123 | baseName := path.Base(id) 124 | name := baseName 125 | i := 1 126 | for defs[name] != nil { 127 | i++ 128 | name = fmt.Sprintf("%s_%d", baseName, i) 129 | } 130 | return name 131 | } 132 | 133 | // BundleRemoveIDs removes "$id" references to "$defs" and updates the "$ref" 134 | // to point to the "$defs" elements directly inside the same document. 135 | // This is non-standard behavior, but helps adding compatibility with 136 | // non-compliant implementations such as the JSON & YAML language servers 137 | // found in Visual Studio Code: https://github.com/microsoft/vscode-json-languageservice/issues/224 138 | // 139 | // For example, before: 140 | // 141 | // { 142 | // "$schema": "https://json-schema.org/draft/2020-12/schema", 143 | // "properties": { 144 | // "foo": { 145 | // "$ref": "https://example.com/schema.json", 146 | // } 147 | // }, 148 | // "$defs": { 149 | // "values.schema.json": { 150 | // "$id": "https://example.com/schema.json" 151 | // } 152 | // } 153 | // } 154 | // 155 | // After: 156 | // 157 | // { 158 | // "$schema": "https://json-schema.org/draft/2020-12/schema", 159 | // "properties": { 160 | // "foo": { 161 | // "$ref": "#/$defs/values.schema.json", 162 | // } 163 | // }, 164 | // "$defs": { 165 | // "values.schema.json": { 166 | // } 167 | // } 168 | // } 169 | // 170 | // This function will update the schema in-place. 171 | func BundleRemoveIDs(schema *Schema) error { 172 | if schema == nil { 173 | return fmt.Errorf("nil schema") 174 | } 175 | if err := bundleChangeRefsRec(nil, nil, schema, schema); err != nil { 176 | return err 177 | } 178 | for _, def := range schema.Defs { 179 | def.ID = "" 180 | } 181 | return nil 182 | } 183 | 184 | func bundleChangeRefsRec(parentDefPtr, ptr Ptr, root, schema *Schema) error { 185 | if schema.ID != "" { 186 | parentDefPtr = ptr 187 | } 188 | 189 | for subPath, subSchema := range schema.Subschemas() { 190 | ptr := ptr.Add(subPath) 191 | if err := bundleChangeRefsRec(parentDefPtr, ptr, root, subSchema); err != nil { 192 | return fmt.Errorf("%s: %w", ptr, err) 193 | } 194 | } 195 | 196 | if schema.Ref == "" || strings.HasPrefix(schema.Ref, "#") { 197 | if schema.Ref != "" && len(parentDefPtr) > 0 { 198 | // Update inline refs 199 | schema.Ref = fmt.Sprintf("#%s%s", parentDefPtr, strings.TrimPrefix(schema.Ref, "#")) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | ref, err := url.Parse(schema.Ref) 206 | if err != nil { 207 | return fmt.Errorf("parse $ref=%q as URL: %w", schema.Ref, err) 208 | } 209 | 210 | name, ok := findDefNameByRef(root.Defs, ref) 211 | if !ok { 212 | return fmt.Errorf("no $defs found that matches $ref=%q", schema.Ref) 213 | } 214 | 215 | if ref.Fragment != "" { 216 | schema.Ref = fmt.Sprintf("#%s%s", NewPtr("$defs", name), ref.Fragment) 217 | } else { 218 | schema.Ref = fmt.Sprintf("#%s", NewPtr("$defs", name)) 219 | } 220 | 221 | return nil 222 | } 223 | 224 | func findDefNameByRef(defs map[string]*Schema, ref *url.URL) (string, bool) { 225 | for name, def := range defs { 226 | if def.ID == bundleRefURLToID(ref) { 227 | return name, true 228 | } 229 | } 230 | return "", false 231 | } 232 | 233 | // RemoveUnusedDefs will try clean up all unused $defs to reduce the size of the 234 | // final bundled schema. 235 | func RemoveUnusedDefs(schema *Schema) { 236 | refCounts := map[*Schema]int{} 237 | for { 238 | clear(refCounts) 239 | findUnusedDefs(nil, schema, schema, refCounts) 240 | deletedCount := removeUnusedDefs(schema, refCounts) 241 | if deletedCount == 0 { 242 | break 243 | } 244 | } 245 | } 246 | 247 | func removeUnusedDefs(schema *Schema, refCounts map[*Schema]int) int { 248 | deletedCount := 0 249 | 250 | for _, def := range schema.Subschemas() { 251 | deletedCount += removeUnusedDefs(def, refCounts) 252 | } 253 | 254 | for name, def := range schema.Defs { 255 | if refCounts[def] == 0 { 256 | delete(schema.Defs, name) 257 | deletedCount++ 258 | } 259 | } 260 | if len(schema.Defs) == 0 { 261 | schema.Defs = nil 262 | } 263 | 264 | for name, def := range schema.Definitions { 265 | if refCounts[def] == 0 { 266 | delete(schema.Definitions, name) 267 | deletedCount++ 268 | } 269 | } 270 | if len(schema.Definitions) == 0 { 271 | schema.Definitions = nil 272 | } 273 | return deletedCount 274 | } 275 | 276 | func findUnusedDefs(ptr Ptr, root, schema *Schema, refCounts map[*Schema]int) { 277 | for path, def := range schema.Subschemas() { 278 | findUnusedDefs(ptr.Add(path), root, def, refCounts) 279 | } 280 | 281 | if schema.Ref == "" { 282 | return 283 | } 284 | 285 | if strings.HasPrefix(schema.Ref, "#/") { 286 | refPtr := ParsePtr(schema.Ref) 287 | if len(refPtr) > 0 && ptr.HasPrefix(refPtr) { 288 | // Ignore self-referential 289 | // E.g "#/$defs/foo.json/properties/moo" has $ref to "#/$defs/foo.json" 290 | return 291 | } 292 | for _, def := range resolvePtr(root, refPtr) { 293 | refCounts[def]++ 294 | } 295 | return 296 | } 297 | 298 | ref, err := url.Parse(schema.Ref) 299 | if err != nil { 300 | return 301 | } 302 | 303 | if name, ok := findDefNameByRef(root.Defs, ref); ok { 304 | refCounts[root.Defs[name]]++ 305 | } 306 | } 307 | 308 | func resolvePtr(schema *Schema, ptr Ptr) []*Schema { 309 | if schema == nil { 310 | return nil 311 | } 312 | if len(ptr) == 0 { 313 | return []*Schema{schema} 314 | } 315 | if len(ptr) < 2 { 316 | return []*Schema{schema} 317 | } 318 | switch ptr[0] { 319 | case "$defs": 320 | return append([]*Schema{schema}, resolvePtr(schema.Defs[ptr[1]], ptr[2:])...) 321 | case "definitions": 322 | return append([]*Schema{schema}, resolvePtr(schema.Definitions[ptr[1]], ptr[2:])...) 323 | default: 324 | return []*Schema{schema} 325 | } 326 | } 327 | 328 | func Load(ctx context.Context, loader Loader, ref string) (*Schema, error) { 329 | if loader == nil { 330 | return nil, fmt.Errorf("nil loader") 331 | } 332 | if ref == "" { 333 | return nil, fmt.Errorf("cannot load empty $ref") 334 | } 335 | refURL, err := url.Parse(ref) 336 | if err != nil { 337 | return nil, fmt.Errorf("parse $ref as URL: %w", err) 338 | } 339 | schema, err := loader.Load(ctx, refURL) 340 | if err != nil { 341 | return nil, err 342 | } 343 | 344 | schema.ID = bundleRefURLToID(refURL) 345 | return schema, nil 346 | } 347 | 348 | func bundleRefToID(ref string) string { 349 | refURL, err := url.Parse(ref) 350 | if err != nil { 351 | return "" 352 | } 353 | return bundleRefURLToID(refURL) 354 | } 355 | 356 | func bundleRefURLToID(ref *url.URL) string { 357 | refClone := *ref 358 | refClone.Fragment = "" 359 | return refClone.String() 360 | } 361 | 362 | type Loader interface { 363 | Load(ctx context.Context, ref *url.URL) (*Schema, error) 364 | } 365 | 366 | // DummyLoader is a dummy implementation of [Loader] meant to be 367 | // used in tests. 368 | type DummyLoader struct { 369 | LoadFunc func(ctx context.Context, ref *url.URL) (*Schema, error) 370 | } 371 | 372 | var _ Loader = DummyLoader{} 373 | 374 | // Load implements [Loader]. 375 | func (loader DummyLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) { 376 | return loader.LoadFunc(ctx, ref) 377 | } 378 | 379 | // FileLoader loads a schema from a "$ref: file:/some/path" reference 380 | // from the local file-system. 381 | type FileLoader struct { 382 | root *os.Root 383 | basePath string 384 | } 385 | 386 | func NewFileLoader(root *os.Root, basePath string) FileLoader { 387 | return FileLoader{ 388 | root: root, 389 | basePath: basePath, 390 | } 391 | } 392 | 393 | var _ Loader = FileLoader{} 394 | 395 | // Load implements [Loader]. 396 | func (loader FileLoader) Load(_ context.Context, ref *url.URL) (*Schema, error) { 397 | if ref.Scheme != "file" && ref.Scheme != "" { 398 | return nil, fmt.Errorf(`file url in $ref=%q must start with "file://", "./", or "/"`, ref) 399 | } 400 | if ref.Path == "" { 401 | return nil, fmt.Errorf(`file url in $ref=%q must contain a path`, ref) 402 | } 403 | path := ref.Path 404 | if runtime.GOOS == "windows" { 405 | path = strings.TrimPrefix(path, "/") 406 | path = filepath.FromSlash(path) 407 | } 408 | if loader.basePath != "" && !filepath.IsAbs(path) { 409 | path = filepath.Join(loader.basePath, path) 410 | } 411 | 412 | fmt.Println("Loading file", path) 413 | f, err := loader.root.Open(path) 414 | if err != nil { 415 | return nil, fmt.Errorf("open $ref=%q file: %w", ref, err) 416 | } 417 | defer closeIgnoreError(f) 418 | b, err := io.ReadAll(f) 419 | if err != nil { 420 | return nil, fmt.Errorf("read $ref=%q file: %w", ref, err) 421 | } 422 | 423 | fmt.Printf("=> got %dKB\n", len(b)/1000) 424 | 425 | switch filepath.Ext(path) { 426 | case ".yml", ".yaml": 427 | var schema Schema 428 | if err := yaml.Unmarshal(b, &schema); err != nil { 429 | return nil, fmt.Errorf("parse $ref=%q YAML file: %w", ref, err) 430 | } 431 | return &schema, nil 432 | default: 433 | var schema Schema 434 | if err := json.Unmarshal(b, &schema); err != nil { 435 | return nil, fmt.Errorf("parse $ref=%q JSON file: %w", ref, err) 436 | } 437 | return &schema, nil 438 | } 439 | } 440 | 441 | // URLSchemeLoader delegates to other [Loader] implementations 442 | // based on the [url.URL] scheme. 443 | type URLSchemeLoader map[string]Loader 444 | 445 | var _ Loader = URLSchemeLoader{} 446 | 447 | // Load implements [Loader]. 448 | func (loader URLSchemeLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) { 449 | loaderForScheme, ok := loader[ref.Scheme] 450 | if !ok { 451 | return nil, fmt.Errorf("%w: cannot load schema from $ref=%q, supported schemes: %v", 452 | errors.ErrUnsupported, ref, strings.Join(slices.Collect(maps.Keys(loader)), ",")) 453 | } 454 | return loaderForScheme.Load(ctx, ref) 455 | } 456 | 457 | // CacheLoader stores loaded schemas in memory and reuses (or "memoizes", if you will) 458 | // calls to the underlying [Loader]. 459 | type CacheLoader struct { 460 | schemas map[string]*Schema 461 | subLoader Loader 462 | } 463 | 464 | func NewCacheLoader(loader Loader) *CacheLoader { 465 | return &CacheLoader{ 466 | schemas: map[string]*Schema{}, 467 | subLoader: loader, 468 | } 469 | } 470 | 471 | var _ Loader = CacheLoader{} 472 | 473 | // Load implements [Loader]. 474 | func (loader CacheLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) { 475 | urlString := bundleRefURLToID(ref) 476 | if schema := loader.schemas[urlString]; schema != nil { 477 | return schema, nil 478 | } 479 | schema, err := loader.subLoader.Load(ctx, ref) 480 | if err != nil { 481 | return nil, err 482 | } 483 | loader.schemas[urlString] = schema 484 | return schema, nil 485 | } 486 | 487 | type HTTPLoader struct { 488 | client *http.Client 489 | } 490 | 491 | func NewHTTPLoader(client *http.Client) HTTPLoader { 492 | return HTTPLoader{client: client} 493 | } 494 | 495 | var _ Loader = HTTPLoader{} 496 | 497 | var yamlMediaTypeRegexp = regexp.MustCompile(`^application/(.*\+)?yaml$`) 498 | 499 | // Load implements [Loader]. 500 | func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) { 501 | // Hardcoding a higher limit so CI/CD pipelines don't get stuck 502 | ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 503 | defer cancel() 504 | 505 | refClone := *ref 506 | refClone.Fragment = "" 507 | 508 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, refClone.String(), nil) 509 | if err != nil { 510 | return nil, err 511 | } 512 | // YAML now has a proper media type since Feb 2024 :D 513 | // https://datatracker.ietf.org/doc/rfc9512/ 514 | req.Header.Add("Accept", "application/schema+json,application/json,application/schema+yaml,application/yaml,text/plain; charset=utf-8") 515 | req.Header.Add("Accept-Encoding", "gzip") 516 | if referrer, ok := ctx.Value(loaderContextReferrer).(string); ok { 517 | if strings.HasPrefix(referrer, "http://") || strings.HasPrefix(referrer, "https://") { 518 | req.Header.Add("Link", fmt.Sprintf(`<%s>; rel="describedby"`, referrer)) 519 | } 520 | } 521 | if req.Header.Get("User-Agent") == "" { 522 | req.Header.Set("User-Agent", "helm-values-schema-json/1") 523 | } 524 | 525 | start := time.Now() 526 | fmt.Println("Loading", req.URL.Redacted()) 527 | 528 | resp, err := loader.client.Do(req) 529 | if err != nil { 530 | return nil, fmt.Errorf("request $ref=%q over HTTP: %w", ref, err) 531 | } 532 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 533 | return nil, fmt.Errorf("request $ref=%q over HTTP: got non-2xx status code: %s", ref, resp.Status) 534 | } 535 | defer closeIgnoreError(resp.Body) 536 | 537 | reader := resp.Body 538 | switch resp.Header.Get("Content-Encoding") { 539 | case "gzip": 540 | r, err := gzip.NewReader(reader) 541 | if err != nil { 542 | return nil, fmt.Errorf("request $ref=%q over HTTP: create gzip reader: %w", ref, err) 543 | } 544 | reader = r 545 | case "": 546 | // Do nothing 547 | default: 548 | return nil, fmt.Errorf("request $ref=%q over HTTP: %w: unsupported content encoding: %q", ref, errors.ErrUnsupported, resp.Header.Get("Content-Encoding")) 549 | } 550 | 551 | var isYAML bool 552 | if mediatype, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")); err == nil { 553 | switch strings.ToLower(params["charset"]) { 554 | case "", "utf-8", "utf8": 555 | // OK 556 | default: 557 | return nil, fmt.Errorf("request $ref=%q over HTTP: %w: unsupported response charset: %q", ref, errors.ErrUnsupported, params["charset"]) 558 | } 559 | 560 | if yamlMediaTypeRegexp.MatchString(mediatype) { 561 | isYAML = true 562 | } 563 | } 564 | 565 | b, err := io.ReadAll(reader) 566 | if err != nil { 567 | return nil, fmt.Errorf("request $ref=%q over HTTP: %w", ref, err) 568 | } 569 | 570 | duration := time.Since(start) 571 | fmt.Printf("=> got %dKB in %s\n", len(b)/1000, duration.Truncate(time.Millisecond)) 572 | 573 | if isYAML { 574 | var schema Schema 575 | if err := yaml.Unmarshal(b, &schema); err != nil { 576 | return nil, fmt.Errorf("parse $ref=%q YAML: %w", ref, err) 577 | } 578 | return &schema, nil 579 | } else { 580 | var schema Schema 581 | if err := json.Unmarshal(b, &schema); err != nil { 582 | return nil, fmt.Errorf("parse $ref=%q JSON: %w", ref, err) 583 | } 584 | return &schema, nil 585 | } 586 | } 587 | 588 | type loaderContextKey int 589 | 590 | var loaderContextReferrer = loaderContextKey(1) 591 | 592 | func ContextWithLoaderReferrer(parent context.Context, referrer string) context.Context { 593 | return context.WithValue(parent, loaderContextReferrer, referrer) 594 | } 595 | -------------------------------------------------------------------------------- /pkg/cmd.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | // Parse flags 15 | func ParseFlags(progname string, args []string) (*Config, string, error) { 16 | flags := flag.NewFlagSet(progname, flag.ContinueOnError) 17 | var buf bytes.Buffer 18 | flags.SetOutput(&buf) 19 | 20 | conf := &Config{} 21 | flags.Var(&conf.Input, "input", "Multiple yaml files as inputs (comma-separated)") 22 | flags.StringVar(&conf.OutputPath, "output", "values.schema.json", "Output file path") 23 | flags.IntVar(&conf.Draft, "draft", 2020, "Draft version (4, 6, 7, 2019, or 2020)") 24 | flags.IntVar(&conf.Indent, "indent", 4, "Indentation spaces (even number)") 25 | flags.Var(&conf.NoAdditionalProperties, "noAdditionalProperties", "Default additionalProperties to false for all objects in the schema") 26 | 27 | flags.Var(&conf.Bundle, "bundle", "Bundle referenced ($ref) subschemas into a single file inside $defs") 28 | flags.Var(&conf.BundleWithoutID, "bundleWithoutID", "Bundle without using $id to reference bundled schemas, which improves compatibility with e.g the VS Code JSON extension") 29 | flags.StringVar(&conf.BundleRoot, "bundleRoot", "", "Root directory to allow local referenced files to be loaded from (default current working directory)") 30 | 31 | flags.StringVar(&conf.K8sSchemaURL, "k8sSchemaURL", "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", "URL template used in $ref: $k8s/... alias") 32 | flags.StringVar(&conf.K8sSchemaVersion, "k8sSchemaVersion", "", "Version used in the --k8sSchemaURL template for $ref: $k8s/... alias") 33 | 34 | // Nested SchemaRoot flags 35 | flags.StringVar(&conf.SchemaRoot.ID, "schemaRoot.id", "", "JSON schema ID") 36 | flags.StringVar(&conf.SchemaRoot.Ref, "schemaRoot.ref", "", "JSON schema URI reference") 37 | flags.StringVar(&conf.SchemaRoot.Title, "schemaRoot.title", "", "JSON schema title") 38 | flags.StringVar(&conf.SchemaRoot.Description, "schemaRoot.description", "", "JSON schema description") 39 | flags.Var(&conf.SchemaRoot.AdditionalProperties, "schemaRoot.additionalProperties", "Allow additional properties") 40 | 41 | err := flags.Parse(args) 42 | if err != nil { 43 | fmt.Println("Usage: helm schema [options...] ") 44 | return nil, buf.String(), err 45 | } 46 | 47 | if flags.NArg() >= 1 && flags.Arg(0) == "__complete" { 48 | return nil, "", ErrCompletionRequested{FlagSet: flags} 49 | } 50 | 51 | // Mark fields as set if they were provided as flags 52 | flags.Visit(func(f *flag.Flag) { 53 | switch f.Name { 54 | case "output": 55 | conf.OutputPathSet = true 56 | case "draft": 57 | conf.DraftSet = true 58 | case "indent": 59 | conf.IndentSet = true 60 | case "k8sSchemaURL": 61 | conf.K8sSchemaURLSet = true 62 | } 63 | }) 64 | 65 | conf.Args = flags.Args() 66 | return conf, buf.String(), nil 67 | } 68 | 69 | // LoadConfig loads configuration from a YAML file 70 | var readFileFunc = os.ReadFile 71 | 72 | func LoadConfig(configPath string) (*Config, error) { 73 | data, err := readFileFunc(configPath) 74 | if err != nil { 75 | if os.IsNotExist(err) { 76 | // Return an empty config if the file does not exist 77 | return &Config{}, nil 78 | } 79 | return nil, err 80 | } 81 | 82 | var config Config 83 | err = yaml.Unmarshal(data, &config) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return &config, nil 89 | } 90 | 91 | // MergeConfig merges CLI flags into the configuration file values, giving precedence to CLI flags 92 | func MergeConfig(fileConfig, flagConfig *Config) *Config { 93 | mergedConfig := *fileConfig 94 | 95 | if len(flagConfig.Input) > 0 { 96 | mergedConfig.Input = flagConfig.Input 97 | } 98 | if flagConfig.OutputPathSet || mergedConfig.OutputPath == "" { 99 | mergedConfig.OutputPath = flagConfig.OutputPath 100 | } 101 | if flagConfig.DraftSet || mergedConfig.Draft == 0 { 102 | mergedConfig.Draft = flagConfig.Draft 103 | } 104 | if flagConfig.IndentSet || mergedConfig.Indent == 0 { 105 | mergedConfig.Indent = flagConfig.Indent 106 | } 107 | 108 | if flagConfig.NoAdditionalProperties.IsSet() { 109 | mergedConfig.NoAdditionalProperties = flagConfig.NoAdditionalProperties 110 | } 111 | if flagConfig.Bundle.IsSet() { 112 | mergedConfig.Bundle = flagConfig.Bundle 113 | } 114 | if flagConfig.BundleWithoutID.IsSet() { 115 | mergedConfig.BundleWithoutID = flagConfig.BundleWithoutID 116 | } 117 | if flagConfig.BundleRoot != "" { 118 | mergedConfig.BundleRoot = flagConfig.BundleRoot 119 | } 120 | if flagConfig.K8sSchemaURLSet || mergedConfig.K8sSchemaURL == "" { 121 | mergedConfig.K8sSchemaURL = flagConfig.K8sSchemaURL 122 | } 123 | if flagConfig.K8sSchemaVersion != "" { 124 | mergedConfig.K8sSchemaVersion = flagConfig.K8sSchemaVersion 125 | } 126 | if flagConfig.SchemaRoot.ID != "" { 127 | mergedConfig.SchemaRoot.ID = flagConfig.SchemaRoot.ID 128 | } 129 | if flagConfig.SchemaRoot.Ref != "" { 130 | mergedConfig.SchemaRoot.Ref = flagConfig.SchemaRoot.Ref 131 | } 132 | if flagConfig.SchemaRoot.Title != "" { 133 | mergedConfig.SchemaRoot.Title = flagConfig.SchemaRoot.Title 134 | } 135 | if flagConfig.SchemaRoot.Description != "" { 136 | mergedConfig.SchemaRoot.Description = flagConfig.SchemaRoot.Description 137 | } 138 | if flagConfig.SchemaRoot.AdditionalProperties.IsSet() { 139 | mergedConfig.SchemaRoot.AdditionalProperties = flagConfig.SchemaRoot.AdditionalProperties 140 | } 141 | mergedConfig.Args = flagConfig.Args 142 | 143 | return &mergedConfig 144 | } 145 | 146 | type ErrCompletionRequested struct { 147 | FlagSet *flag.FlagSet 148 | } 149 | 150 | // Error implements [error]. 151 | func (ErrCompletionRequested) Error() string { 152 | return "completion requested" 153 | } 154 | 155 | func (err ErrCompletionRequested) Fprint(writer io.Writer) { 156 | args := err.FlagSet.Args() 157 | if len(args) > 1 && args[1] == "__complete" { 158 | args = args[2:] 159 | } 160 | if len(args) >= 2 { 161 | prevArg := args[len(args)-2] 162 | currArg := args[len(args)-1] 163 | if strings.HasPrefix(prevArg, "-") && !strings.Contains(prevArg, "=") && 164 | !strings.HasPrefix(currArg, "-") { 165 | // Don't suggest any flags as the last argument was "--foo", 166 | // so the user must provide a flag value 167 | return 168 | } 169 | } 170 | err.FlagSet.VisitAll(func(f *flag.Flag) { 171 | switch f.Value.(type) { 172 | case *BoolFlag: 173 | _, _ = fmt.Fprintf(writer, "--%s=true\t%s\n", f.Name, f.Usage) 174 | default: 175 | _, _ = fmt.Fprintf(writer, "--%s\t%s\n", f.Name, f.Usage) 176 | } 177 | }) 178 | } 179 | -------------------------------------------------------------------------------- /pkg/cmd_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestParseFlagsPass(t *testing.T) { 14 | tests := []struct { 15 | args []string 16 | conf Config 17 | }{ 18 | { 19 | []string{"-input", "values.yaml"}, 20 | Config{ 21 | Input: multiStringFlag{"values.yaml"}, 22 | OutputPath: "values.schema.json", 23 | Draft: 2020, 24 | Indent: 4, 25 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 26 | Args: []string{}, 27 | }, 28 | }, 29 | 30 | { 31 | []string{"-input", "values1.yaml values2.yaml", "-indent", "2"}, 32 | Config{ 33 | Input: multiStringFlag{"values1.yaml values2.yaml"}, 34 | OutputPath: "values.schema.json", 35 | Draft: 2020, 36 | Indent: 2, 37 | OutputPathSet: false, 38 | DraftSet: false, 39 | IndentSet: true, 40 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 41 | Args: []string{}, 42 | }, 43 | }, 44 | 45 | { 46 | []string{"-input", "values.yaml", "-output", "my.schema.json", "-draft", "2019", "-indent", "2"}, 47 | Config{ 48 | Input: multiStringFlag{"values.yaml"}, 49 | OutputPath: "my.schema.json", 50 | Draft: 2019, Indent: 2, 51 | OutputPathSet: true, 52 | DraftSet: true, 53 | IndentSet: true, 54 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 55 | Args: []string{}, 56 | }, 57 | }, 58 | 59 | { 60 | []string{"-input", "values.yaml", "-output", "my.schema.json", "-draft", "2019", "-k8sSchemaURL", "foobar"}, 61 | Config{ 62 | Input: multiStringFlag{"values.yaml"}, 63 | OutputPath: "my.schema.json", 64 | Draft: 2019, 65 | Indent: 4, 66 | K8sSchemaURL: "foobar", 67 | OutputPathSet: true, 68 | DraftSet: true, 69 | K8sSchemaURLSet: true, 70 | IndentSet: false, 71 | Args: []string{}, 72 | }, 73 | }, 74 | 75 | { 76 | []string{"-input", "values.yaml", "-schemaRoot.id", "http://example.com/schema", "-schemaRoot.ref", "schema/product.json", "-schemaRoot.title", "MySchema", "-schemaRoot.description", "My schema description"}, 77 | Config{ 78 | Input: multiStringFlag{"values.yaml"}, 79 | OutputPath: "values.schema.json", 80 | Draft: 2020, 81 | Indent: 4, 82 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 83 | SchemaRoot: SchemaRoot{ 84 | ID: "http://example.com/schema", 85 | Ref: "schema/product.json", 86 | Title: "MySchema", 87 | Description: "My schema description", 88 | }, 89 | Args: []string{}, 90 | }, 91 | }, 92 | 93 | { 94 | []string{"-bundle=true", "-bundleRoot", "/foo/bar", "-bundleWithoutID=true"}, 95 | Config{ 96 | Indent: 4, 97 | OutputPath: "values.schema.json", 98 | Draft: 2020, 99 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 100 | Bundle: BoolFlag{set: true, value: true}, 101 | BundleRoot: "/foo/bar", 102 | BundleWithoutID: BoolFlag{set: true, value: true}, 103 | Args: []string{}, 104 | }, 105 | }, 106 | { 107 | []string{"-bundle=false", "-bundleRoot", "", "-bundleWithoutID=false"}, 108 | Config{ 109 | Indent: 4, 110 | OutputPath: "values.schema.json", 111 | Draft: 2020, 112 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 113 | Bundle: BoolFlag{set: true, value: false}, 114 | BundleRoot: "", 115 | BundleWithoutID: BoolFlag{set: true, value: false}, 116 | Args: []string{}, 117 | }, 118 | }, 119 | } 120 | 121 | for _, tt := range tests { 122 | t.Run(strings.Join(tt.args, " "), func(t *testing.T) { 123 | conf, output, err := ParseFlags("schema", tt.args) 124 | assert.NoError(t, err) 125 | assert.Empty(t, output, "output") 126 | assert.Equal(t, &tt.conf, conf, "conf") 127 | }) 128 | } 129 | } 130 | 131 | func TestParseFlagsUsage(t *testing.T) { 132 | usageArgs := []string{"-help", "-h", "--help"} 133 | 134 | for _, arg := range usageArgs { 135 | t.Run(arg, func(t *testing.T) { 136 | conf, output, err := ParseFlags("schema", []string{arg}) 137 | if err != flag.ErrHelp { 138 | t.Errorf("err got %v, want ErrHelp", err) 139 | } 140 | if conf != nil { 141 | t.Errorf("conf got %v, want nil", conf) 142 | } 143 | if !strings.Contains(output, "Usage of") { 144 | t.Errorf("output can't find \"Usage of\": %q", output) 145 | } 146 | }) 147 | } 148 | } 149 | 150 | func TestParseFlagsComplete(t *testing.T) { 151 | _, _, err := ParseFlags("schema", []string{"__complete"}) 152 | 153 | var completeErr ErrCompletionRequested 154 | assert.ErrorAs(t, err, &completeErr) 155 | } 156 | 157 | func TestParseFlagsFail(t *testing.T) { 158 | tests := []struct { 159 | args []string 160 | errStr string 161 | }{ 162 | {[]string{"-input"}, "flag needs an argument"}, 163 | {[]string{"-draft", "foo"}, "invalid value"}, 164 | {[]string{"-foo"}, "flag provided but not defined"}, 165 | {[]string{"-schemaRoot.additionalProperties", "null"}, "invalid boolean value"}, 166 | {[]string{"-bundle", "null"}, "invalid boolean value"}, 167 | {[]string{"-bundleWithoutID", "null"}, "invalid boolean value"}, 168 | } 169 | 170 | for _, tt := range tests { 171 | t.Run(strings.Join(tt.args, " "), func(t *testing.T) { 172 | conf, output, err := ParseFlags("schema", tt.args) 173 | if conf != nil { 174 | t.Errorf("conf got %v, want nil", conf) 175 | } 176 | if !strings.Contains(err.Error(), tt.errStr) { 177 | t.Errorf("err got %q, want to find %q", err.Error(), tt.errStr) 178 | } 179 | if !strings.Contains(output, "Usage of") { 180 | t.Errorf("output got %q", output) 181 | } 182 | }) 183 | } 184 | } 185 | 186 | func TestLoadConfig(t *testing.T) { 187 | tests := []struct { 188 | name string 189 | configContent string 190 | expectedConf Config 191 | expectedErr bool 192 | }{ 193 | { 194 | name: "ValidConfig", 195 | configContent: ` 196 | input: 197 | - testdata/empty.yaml 198 | - testdata/meta.yaml 199 | output: values.schema.json 200 | draft: 2020 201 | indent: 2 202 | bundle: true 203 | bundleRoot: ./ 204 | bundleWithoutID: true 205 | schemaRoot: 206 | id: https://example.com/schema 207 | ref: schema/product.json 208 | title: Helm Values Schema 209 | description: Schema for Helm values 210 | additionalProperties: true 211 | `, 212 | expectedConf: Config{ 213 | Input: multiStringFlag{"testdata/empty.yaml", "testdata/meta.yaml"}, 214 | OutputPath: "values.schema.json", 215 | Draft: 2020, 216 | Indent: 2, 217 | Bundle: BoolFlag{set: true, value: true}, 218 | BundleRoot: "./", 219 | BundleWithoutID: BoolFlag{set: true, value: true}, 220 | SchemaRoot: SchemaRoot{ 221 | Title: "Helm Values Schema", 222 | ID: "https://example.com/schema", 223 | Ref: "schema/product.json", 224 | Description: "Schema for Helm values", 225 | AdditionalProperties: BoolFlag{set: true, value: true}, 226 | }, 227 | }, 228 | expectedErr: false, 229 | }, 230 | { 231 | name: "InvalidConfig", 232 | configContent: ` 233 | input: "invalid" "input" 234 | input: 235 | `, 236 | expectedConf: Config{}, 237 | expectedErr: true, 238 | }, 239 | { 240 | name: "InvalidYAML", 241 | configContent: `draft: "invalid"`, 242 | expectedConf: Config{}, 243 | expectedErr: true, 244 | }, 245 | { 246 | name: "MissingFile", 247 | configContent: "", 248 | expectedConf: Config{}, 249 | expectedErr: false, 250 | }, 251 | { 252 | name: "EmptyConfig", 253 | configContent: `input: []`, 254 | expectedConf: Config{ 255 | Input: multiStringFlag{}, 256 | OutputPath: "", 257 | Draft: 0, 258 | Indent: 0, 259 | SchemaRoot: SchemaRoot{ 260 | ID: "", 261 | Ref: "", 262 | Title: "", 263 | Description: "", 264 | AdditionalProperties: BoolFlag{set: false, value: false}, 265 | }, 266 | }, 267 | expectedErr: false, 268 | }, 269 | } 270 | 271 | for _, tt := range tests { 272 | t.Run(tt.name, func(t *testing.T) { 273 | var configFilePath string 274 | if tt.configContent != "" { 275 | tmpFile, err := os.CreateTemp("", "config-*.yaml") 276 | assert.NoError(t, err) 277 | defer func() { 278 | if err := os.Remove(tmpFile.Name()); err != nil && !os.IsNotExist(err) { 279 | t.Errorf("failed to remove temporary file %s: %v", tmpFile.Name(), err) 280 | } 281 | }() 282 | _, err = tmpFile.Write([]byte(tt.configContent)) 283 | assert.NoError(t, err) 284 | configFilePath = tmpFile.Name() 285 | } else { 286 | configFilePath = "nonexistent.yaml" 287 | } 288 | 289 | conf, err := LoadConfig(configFilePath) 290 | 291 | if tt.expectedErr { 292 | assert.Error(t, err) 293 | assert.Nil(t, conf) 294 | } else { 295 | assert.NoError(t, err) 296 | assert.NotNil(t, conf) 297 | assert.Equal(t, tt.expectedConf, *conf) 298 | } 299 | }) 300 | } 301 | } 302 | 303 | func TestLoadConfig_PermissionDenied(t *testing.T) { 304 | restrictedDir := "/restricted" 305 | configFilePath := restrictedDir + "/restricted.yaml" 306 | 307 | readFileFunc = func(filename string) ([]byte, error) { 308 | return nil, os.ErrPermission 309 | } 310 | defer func() { readFileFunc = os.ReadFile }() 311 | 312 | conf, err := LoadConfig(configFilePath) 313 | assert.ErrorIs(t, err, os.ErrPermission, "Expected permission denied error") 314 | assert.Nil(t, conf, "Expected config to be nil for permission denied error") 315 | } 316 | 317 | func TestMergeConfig(t *testing.T) { 318 | tests := []struct { 319 | name string 320 | fileConfig *Config 321 | flagConfig *Config 322 | expectedConfig *Config 323 | }{ 324 | { 325 | name: "FlagConfigOverridesFileConfig", 326 | fileConfig: &Config{ 327 | Input: multiStringFlag{"fileInput.yaml"}, 328 | OutputPath: "fileOutput.json", 329 | Draft: 2020, 330 | Indent: 4, 331 | NoAdditionalProperties: BoolFlag{set: true, value: true}, 332 | K8sSchemaURL: "fileURL", 333 | K8sSchemaVersion: "fileVersion", 334 | SchemaRoot: SchemaRoot{ 335 | ID: "fileID", 336 | Ref: "fileRef", 337 | Title: "fileTitle", 338 | Description: "fileDescription", 339 | AdditionalProperties: BoolFlag{set: true, value: false}, 340 | }, 341 | }, 342 | flagConfig: &Config{ 343 | Input: multiStringFlag{"flagInput.yaml"}, 344 | OutputPath: "flagOutput.json", 345 | Draft: 2019, 346 | Indent: 2, 347 | NoAdditionalProperties: BoolFlag{set: true, value: false}, 348 | K8sSchemaURL: "flagURL", 349 | K8sSchemaVersion: "flagVersion", 350 | SchemaRoot: SchemaRoot{ 351 | ID: "flagID", 352 | Ref: "flagRef", 353 | Title: "flagTitle", 354 | Description: "flagDescription", 355 | AdditionalProperties: BoolFlag{set: true, value: true}, 356 | }, 357 | OutputPathSet: true, 358 | DraftSet: true, 359 | IndentSet: true, 360 | K8sSchemaURLSet: true, 361 | }, 362 | expectedConfig: &Config{ 363 | Input: multiStringFlag{"flagInput.yaml"}, 364 | OutputPath: "flagOutput.json", 365 | Draft: 2019, 366 | Indent: 2, 367 | NoAdditionalProperties: BoolFlag{set: true, value: false}, 368 | K8sSchemaURL: "flagURL", 369 | K8sSchemaVersion: "flagVersion", 370 | SchemaRoot: SchemaRoot{ 371 | ID: "flagID", 372 | Ref: "flagRef", 373 | Title: "flagTitle", 374 | Description: "flagDescription", 375 | AdditionalProperties: BoolFlag{set: true, value: true}, 376 | }, 377 | }, 378 | }, 379 | { 380 | name: "FileConfigDefaultsUsed", 381 | fileConfig: &Config{ 382 | Input: multiStringFlag{"fileInput.yaml"}, 383 | OutputPath: "fileOutput.json", 384 | Draft: 2020, 385 | Indent: 4, 386 | K8sSchemaURL: "fileURL", 387 | K8sSchemaVersion: "fileVersion", 388 | SchemaRoot: SchemaRoot{ 389 | ID: "fileID", 390 | Ref: "fileRef", 391 | Title: "fileTitle", 392 | Description: "fileDescription", 393 | AdditionalProperties: BoolFlag{set: true, value: false}, 394 | }, 395 | }, 396 | flagConfig: &Config{}, 397 | expectedConfig: &Config{ 398 | Input: multiStringFlag{"fileInput.yaml"}, 399 | OutputPath: "fileOutput.json", 400 | Draft: 2020, 401 | Indent: 4, 402 | K8sSchemaURL: "fileURL", 403 | K8sSchemaVersion: "fileVersion", 404 | SchemaRoot: SchemaRoot{ 405 | ID: "fileID", 406 | Ref: "fileRef", 407 | Title: "fileTitle", 408 | Description: "fileDescription", 409 | AdditionalProperties: BoolFlag{set: true, value: false}, 410 | }, 411 | }, 412 | }, 413 | { 414 | name: "FlagConfigPartialOverride", 415 | fileConfig: &Config{ 416 | Input: multiStringFlag{"fileInput.yaml"}, 417 | OutputPath: "fileOutput.json", 418 | Draft: 2020, 419 | Indent: 4, 420 | K8sSchemaURL: "fileURL", 421 | K8sSchemaVersion: "fileVersion", 422 | SchemaRoot: SchemaRoot{ 423 | ID: "fileID", 424 | Ref: "fileRef", 425 | Title: "fileTitle", 426 | Description: "fileDescription", 427 | AdditionalProperties: BoolFlag{set: true, value: false}, 428 | }, 429 | }, 430 | flagConfig: &Config{ 431 | OutputPath: "flagOutput.json", 432 | OutputPathSet: true, 433 | }, 434 | expectedConfig: &Config{ 435 | Input: multiStringFlag{"fileInput.yaml"}, 436 | OutputPath: "flagOutput.json", 437 | Draft: 2020, 438 | Indent: 4, 439 | K8sSchemaURL: "fileURL", 440 | K8sSchemaVersion: "fileVersion", 441 | SchemaRoot: SchemaRoot{ 442 | ID: "fileID", 443 | Ref: "fileRef", 444 | Title: "fileTitle", 445 | Description: "fileDescription", 446 | AdditionalProperties: BoolFlag{set: true, value: false}, 447 | }, 448 | }, 449 | }, 450 | { 451 | name: "FlagConfigWithEmptyFileConfig", 452 | fileConfig: &Config{ 453 | Input: multiStringFlag{}, 454 | }, 455 | flagConfig: &Config{ 456 | Input: multiStringFlag{"flagInput.yaml"}, 457 | OutputPath: "flagOutput.json", 458 | Draft: 2019, 459 | Indent: 2, 460 | K8sSchemaURL: "flagURL", 461 | K8sSchemaVersion: "flagVersion", 462 | SchemaRoot: SchemaRoot{ 463 | ID: "flagID", 464 | Ref: "flagRef", 465 | Title: "flagTitle", 466 | Description: "flagDescription", 467 | AdditionalProperties: BoolFlag{set: true, value: true}, 468 | }, 469 | OutputPathSet: true, 470 | DraftSet: true, 471 | IndentSet: true, 472 | K8sSchemaURLSet: true, 473 | }, 474 | expectedConfig: &Config{ 475 | Input: multiStringFlag{"flagInput.yaml"}, 476 | OutputPath: "flagOutput.json", 477 | Draft: 2019, 478 | Indent: 2, 479 | K8sSchemaURL: "flagURL", 480 | K8sSchemaVersion: "flagVersion", 481 | SchemaRoot: SchemaRoot{ 482 | ID: "flagID", 483 | Ref: "flagRef", 484 | Title: "flagTitle", 485 | Description: "flagDescription", 486 | AdditionalProperties: BoolFlag{set: true, value: true}, 487 | }, 488 | }, 489 | }, 490 | { 491 | name: "FlagConfigWithBundleOverride", 492 | fileConfig: &Config{ 493 | Bundle: BoolFlag{set: true, value: false}, 494 | BundleRoot: "root/from/file", 495 | BundleWithoutID: BoolFlag{set: true, value: false}, 496 | }, 497 | flagConfig: &Config{ 498 | Bundle: BoolFlag{set: true, value: true}, 499 | BundleRoot: "root/from/flags", 500 | BundleWithoutID: BoolFlag{set: true, value: true}, 501 | }, 502 | expectedConfig: &Config{ 503 | Bundle: BoolFlag{set: true, value: true}, 504 | BundleRoot: "root/from/flags", 505 | BundleWithoutID: BoolFlag{set: true, value: true}, 506 | }, 507 | }, 508 | } 509 | 510 | for _, tt := range tests { 511 | t.Run(tt.name, func(t *testing.T) { 512 | mergedConfig := MergeConfig(tt.fileConfig, tt.flagConfig) 513 | assert.Equal(t, tt.expectedConfig, mergedConfig) 514 | }) 515 | } 516 | } 517 | 518 | func TestErrCompletionRequested(t *testing.T) { 519 | tests := []struct { 520 | name string 521 | err func() ErrCompletionRequested 522 | want string 523 | }{ 524 | { 525 | name: "empty", 526 | err: func() ErrCompletionRequested { 527 | flagSet := flag.NewFlagSet("", flag.ContinueOnError) 528 | return ErrCompletionRequested{flagSet} 529 | }, 530 | want: "", 531 | }, 532 | { 533 | name: "single flag", 534 | err: func() ErrCompletionRequested { 535 | flagSet := flag.NewFlagSet("", flag.ContinueOnError) 536 | flagSet.String("foo", "", "docs string") 537 | return ErrCompletionRequested{flagSet} 538 | }, 539 | want: "--foo\tdocs string\n", 540 | }, 541 | { 542 | name: "multiple types of args", 543 | err: func() ErrCompletionRequested { 544 | flagSet := flag.NewFlagSet("", flag.ContinueOnError) 545 | flagSet.Int("int", 0, "my int usage") 546 | flagSet.String("str", "", "my str usage") 547 | flagSet.Var(&BoolFlag{}, "bool", "my BoolFlag usage") 548 | return ErrCompletionRequested{flagSet} 549 | }, 550 | want: "--bool=true\tmy BoolFlag usage\n" + 551 | "--int\tmy int usage\n" + 552 | "--str\tmy str usage\n", 553 | }, 554 | { 555 | name: "skip output when completing flag value", 556 | err: func() ErrCompletionRequested { 557 | flagSet := flag.NewFlagSet("", flag.ContinueOnError) 558 | flagSet.String("foo", "", "docs string") 559 | if err := flagSet.Parse([]string{"myCmdName", "__complete", "--", "--foo", ""}); err != nil { 560 | panic(err) 561 | } 562 | return ErrCompletionRequested{flagSet} 563 | }, 564 | want: "", 565 | }, 566 | } 567 | 568 | for _, tt := range tests { 569 | t.Run(tt.name, func(t *testing.T) { 570 | err := tt.err() 571 | assert.EqualError(t, err, "completion requested") 572 | var buf bytes.Buffer 573 | err.Fprint(&buf) 574 | assert.Equal(t, tt.want, buf.String()) 575 | }) 576 | } 577 | } 578 | -------------------------------------------------------------------------------- /pkg/generator.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // Generate JSON schema 17 | func GenerateJsonSchema(config *Config) error { 18 | // Check if the input flag is set 19 | if len(config.Input) == 0 { 20 | return errors.New("input flag is required") 21 | } 22 | 23 | // Determine the schema URL based on the draft version 24 | schemaURL, err := getSchemaURL(config.Draft) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | // Determine the indentation string based on the number of spaces 30 | if config.Indent <= 0 { 31 | return errors.New("indentation must be a positive number") 32 | } 33 | if config.Indent%2 != 0 { 34 | return errors.New("indentation must be an even number") 35 | } 36 | indentString := strings.Repeat(" ", config.Indent) 37 | 38 | // Initialize a Schema to hold the merged YAML data 39 | mergedSchema := &Schema{} 40 | 41 | bundleRoot := config.BundleRoot 42 | if bundleRoot == "" { 43 | bundleRoot = "." 44 | } 45 | root, err := os.OpenRoot(bundleRoot) 46 | if err != nil { 47 | return fmt.Errorf("open bundle root: %w", err) 48 | } 49 | defer closeIgnoreError(root) 50 | 51 | // Iterate over the input YAML files 52 | for _, filePath := range config.Input { 53 | content, err := os.ReadFile(filepath.Clean(filePath)) 54 | if err != nil { 55 | return errors.New("error reading YAML file(s)") 56 | } 57 | 58 | var node yaml.Node 59 | if err := yaml.Unmarshal(content, &node); err != nil { 60 | return errors.New("error unmarshaling YAML") 61 | } 62 | 63 | if len(node.Content) == 0 { 64 | continue // Skip empty files 65 | } 66 | 67 | rootNode := node.Content[0] 68 | properties := make(map[string]*Schema) 69 | required := []string{} 70 | 71 | for i := 0; i < len(rootNode.Content); i += 2 { 72 | keyNode := rootNode.Content[i] 73 | valNode := rootNode.Content[i+1] 74 | schema, isRequired := parseNode(keyNode, valNode) 75 | 76 | // Exclude hidden nodes 77 | if schema != nil && !schema.Hidden { 78 | if schema.SkipProperties && schema.Type == "object" { 79 | schema.Properties = nil 80 | } 81 | properties[keyNode.Value] = schema 82 | if isRequired { 83 | required = append(required, keyNode.Value) 84 | } 85 | } 86 | } 87 | 88 | // Create a temporary Schema to merge from the nodes 89 | tempSchema := &Schema{ 90 | Type: "object", 91 | Properties: properties, 92 | Required: required, 93 | Title: config.SchemaRoot.Title, 94 | Description: config.SchemaRoot.Description, 95 | ID: config.SchemaRoot.ID, 96 | Ref: config.SchemaRoot.Ref, 97 | } 98 | 99 | // Apply "$ref: $k8s/..." transformation 100 | if err := updateRefK8sAlias(tempSchema, config.K8sSchemaURL, config.K8sSchemaVersion); err != nil { 101 | return err 102 | } 103 | 104 | if config.Bundle.Value() { 105 | ctx := context.Background() 106 | basePath, err := filepath.Rel(bundleRoot, filepath.Dir(filePath)) 107 | if err != nil { 108 | return fmt.Errorf("get relative path from bundle root to file %q: %w", filePath, err) 109 | } 110 | loader := NewDefaultLoader(http.DefaultClient, root, basePath) 111 | if err := BundleSchema(ctx, loader, tempSchema); err != nil { 112 | return fmt.Errorf("bundle schemas on %q: %w", filePath, err) 113 | } 114 | } 115 | 116 | // Merge with existing data 117 | mergedSchema = mergeSchemas(mergedSchema, tempSchema) 118 | mergedSchema.Required = uniqueStringAppend(mergedSchema.Required, required...) 119 | } 120 | 121 | if config.Bundle.Value() && config.BundleWithoutID.Value() { 122 | if err := BundleRemoveIDs(mergedSchema); err != nil { 123 | return fmt.Errorf("remove bundled $id: %w", err) 124 | } 125 | 126 | // Cleanup unused $defs after all other bundling tasks 127 | RemoveUnusedDefs(mergedSchema) 128 | } 129 | 130 | // Ensure merged Schema is JSON Schema compliant 131 | if err := ensureCompliant(mergedSchema, config.NoAdditionalProperties.value, config.Draft); err != nil { 132 | return err 133 | } 134 | mergedSchema.Schema = schemaURL // Include the schema draft version 135 | mergedSchema.Type = "object" 136 | 137 | if config.SchemaRoot.AdditionalProperties.IsSet() { 138 | mergedSchema.AdditionalProperties = SchemaBool(config.SchemaRoot.AdditionalProperties.Value()) 139 | } else if config.NoAdditionalProperties.value { 140 | mergedSchema.AdditionalProperties = &SchemaFalse 141 | } 142 | 143 | // If validation is successful, marshal the schema and save to the file 144 | jsonBytes, err := json.MarshalIndent(mergedSchema, "", indentString) 145 | if err != nil { 146 | return err 147 | } 148 | jsonBytes = append(jsonBytes, '\n') 149 | 150 | // Write the JSON schema to the output file 151 | outputPath := config.OutputPath 152 | if err := os.WriteFile(outputPath, jsonBytes, 0600); err != nil { 153 | return errors.New("error writing schema to file") 154 | } 155 | 156 | fmt.Println("JSON schema successfully generated") 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /pkg/generator_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestGenerateJsonSchema(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | config *Config 19 | templateSchemaFile string 20 | }{ 21 | { 22 | name: "full json schema", 23 | config: &Config{ 24 | Input: []string{ 25 | "../testdata/full.yaml", 26 | "../testdata/empty.yaml", 27 | }, 28 | OutputPath: "../testdata/output.json", 29 | Draft: 2020, 30 | Indent: 4, 31 | SchemaRoot: SchemaRoot{ 32 | ID: "https://example.com/schema", 33 | Ref: "schema/product.json", 34 | Title: "Helm Values Schema", 35 | Description: "Schema for Helm values", 36 | AdditionalProperties: BoolFlag{set: true, value: true}, 37 | }, 38 | }, 39 | templateSchemaFile: "../testdata/full.schema.json", 40 | }, 41 | { 42 | name: "noAdditionalProperties", 43 | config: &Config{ 44 | Draft: 2020, 45 | Indent: 4, 46 | NoAdditionalProperties: BoolFlag{set: true, value: true}, 47 | Input: []string{ 48 | "../testdata/noAdditionalProperties.yaml", 49 | }, 50 | OutputPath: "../testdata/output1.json", 51 | }, 52 | templateSchemaFile: "../testdata/noAdditionalProperties.schema.json", 53 | }, 54 | 55 | { 56 | name: "k8s ref alias", 57 | config: &Config{ 58 | Draft: 2020, 59 | Indent: 4, 60 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 61 | K8sSchemaVersion: "v1.33.1", 62 | Input: []string{ 63 | "../testdata/k8sRef.yaml", 64 | }, 65 | OutputPath: "../testdata/k8sRef_output.json", 66 | }, 67 | templateSchemaFile: "../testdata/k8sRef.schema.json", 68 | }, 69 | 70 | { 71 | name: "ref draft 7", 72 | config: &Config{ 73 | Draft: 7, 74 | Indent: 4, 75 | Input: []string{ 76 | "../testdata/ref.yaml", 77 | }, 78 | OutputPath: "../testdata/ref-draft7_output.json", 79 | }, 80 | templateSchemaFile: "../testdata/ref-draft7.schema.json", 81 | }, 82 | { 83 | name: "ref draft 2020", 84 | config: &Config{ 85 | Draft: 2020, 86 | Indent: 4, 87 | Input: []string{ 88 | "../testdata/ref.yaml", 89 | }, 90 | OutputPath: "../testdata/ref-draft2020_output.json", 91 | }, 92 | templateSchemaFile: "../testdata/ref-draft2020.schema.json", 93 | }, 94 | 95 | { 96 | name: "bundle/simple", 97 | config: &Config{ 98 | Draft: 2020, 99 | Indent: 4, 100 | Bundle: BoolFlag{set: true, value: true}, 101 | BundleRoot: "../", 102 | Input: []string{ 103 | "../testdata/bundle/simple.yaml", 104 | }, 105 | OutputPath: "../testdata/bundle/simple_output.json", 106 | }, 107 | templateSchemaFile: "../testdata/bundle/simple.schema.json", 108 | }, 109 | { 110 | name: "bundle/simple-disabled", 111 | config: &Config{ 112 | Draft: 2020, 113 | Indent: 4, 114 | Bundle: BoolFlag{set: true, value: false}, 115 | Input: []string{ 116 | "../testdata/bundle/simple.yaml", 117 | }, 118 | OutputPath: "../testdata/bundle/simple-disabled_output.json", 119 | }, 120 | templateSchemaFile: "../testdata/bundle/simple-disabled.schema.json", 121 | }, 122 | { 123 | name: "bundle/without-id", 124 | config: &Config{ 125 | Draft: 2020, 126 | Indent: 4, 127 | Bundle: BoolFlag{set: true, value: true}, 128 | BundleWithoutID: BoolFlag{set: true, value: true}, 129 | BundleRoot: "../", 130 | Input: []string{ 131 | "../testdata/bundle/simple.yaml", 132 | }, 133 | OutputPath: "../testdata/bundle_output.json", 134 | }, 135 | templateSchemaFile: "../testdata/bundle/simple-without-id.schema.json", 136 | }, 137 | { 138 | name: "bundle/nested", 139 | config: &Config{ 140 | Draft: 2020, 141 | Indent: 4, 142 | Bundle: BoolFlag{set: true, value: true}, 143 | BundleRoot: "..", 144 | Input: []string{ 145 | "../testdata/bundle/nested.yaml", 146 | }, 147 | OutputPath: "../testdata/bundle/nested_output.json", 148 | }, 149 | templateSchemaFile: "../testdata/bundle/nested.schema.json", 150 | }, 151 | { 152 | name: "bundle/nested-without-id", 153 | config: &Config{ 154 | Draft: 2020, 155 | Indent: 4, 156 | Bundle: BoolFlag{set: true, value: true}, 157 | BundleWithoutID: BoolFlag{set: true, value: true}, 158 | BundleRoot: "..", 159 | Input: []string{ 160 | "../testdata/bundle/nested.yaml", 161 | }, 162 | OutputPath: "../testdata/bundle/nested-without-id_output.json", 163 | }, 164 | templateSchemaFile: "../testdata/bundle/nested-without-id.schema.json", 165 | }, 166 | { 167 | name: "bundle/fragment", 168 | config: &Config{ 169 | Draft: 2020, 170 | Indent: 4, 171 | Bundle: BoolFlag{set: true, value: true}, 172 | BundleRoot: "..", 173 | Input: []string{ 174 | "../testdata/bundle/fragment.yaml", 175 | }, 176 | OutputPath: "../testdata/bundle/fragment_output.json", 177 | }, 178 | templateSchemaFile: "../testdata/bundle/fragment.schema.json", 179 | }, 180 | { 181 | name: "bundle/fragment-without-id", 182 | config: &Config{ 183 | Draft: 2020, 184 | Indent: 4, 185 | Bundle: BoolFlag{set: true, value: true}, 186 | BundleWithoutID: BoolFlag{set: true, value: true}, 187 | BundleRoot: "..", 188 | Input: []string{ 189 | "../testdata/bundle/fragment.yaml", 190 | }, 191 | OutputPath: "../testdata/bundle/fragment-without-id_output.json", 192 | }, 193 | templateSchemaFile: "../testdata/bundle/fragment-without-id.schema.json", 194 | }, 195 | { 196 | name: "bundle/namecollision", 197 | config: &Config{ 198 | Draft: 2020, 199 | Indent: 4, 200 | Bundle: BoolFlag{set: true, value: true}, 201 | BundleRoot: "..", 202 | Input: []string{ 203 | "../testdata/bundle/namecollision.yaml", 204 | }, 205 | OutputPath: "../testdata/bundle/namecollision_output.json", 206 | }, 207 | templateSchemaFile: "../testdata/bundle/namecollision.schema.json", 208 | }, 209 | { 210 | name: "bundle/yaml", 211 | config: &Config{ 212 | Draft: 2020, 213 | Indent: 4, 214 | Bundle: BoolFlag{set: true, value: true}, 215 | BundleRoot: "..", 216 | Input: []string{ 217 | "../testdata/bundle/yaml.yaml", 218 | }, 219 | OutputPath: "../testdata/bundle/yaml_output.json", 220 | }, 221 | templateSchemaFile: "../testdata/bundle/yaml.schema.json", 222 | }, 223 | } 224 | 225 | for _, tt := range tests { 226 | t.Run(tt.name, func(t *testing.T) { 227 | err := GenerateJsonSchema(tt.config) 228 | require.NoError(t, err) 229 | 230 | generatedBytes, err := os.ReadFile(tt.config.OutputPath) 231 | require.NoError(t, err) 232 | 233 | templateBytes, err := os.ReadFile(tt.templateSchemaFile) 234 | require.NoError(t, err) 235 | 236 | t.Logf("Generated output:\n%s\n", generatedBytes) 237 | 238 | assert.JSONEqf(t, string(templateBytes), string(generatedBytes), "Generated JSON schema %q does not match the template", tt.templateSchemaFile) 239 | 240 | if err := os.Remove(tt.config.OutputPath); err != nil && !os.IsNotExist(err) { 241 | t.Errorf("failed to remove values.schema.json: %v", err) 242 | } 243 | }) 244 | } 245 | } 246 | 247 | func TestGenerateJsonSchema_Errors(t *testing.T) { 248 | tests := []struct { 249 | name string 250 | config *Config 251 | setupFunc func() error 252 | cleanupFunc func() error 253 | expectedErr error 254 | }{ 255 | { 256 | name: "Missing input flag", 257 | config: &Config{ 258 | Input: nil, 259 | Draft: 2020, 260 | Indent: 0, 261 | }, 262 | expectedErr: errors.New("input flag is required"), 263 | }, 264 | { 265 | name: "Invalid draft version", 266 | config: &Config{ 267 | Input: []string{"../testdata/basic.yaml"}, 268 | Draft: 5, 269 | }, 270 | expectedErr: errors.New("invalid draft version"), 271 | }, 272 | { 273 | name: "Negative indentation number", 274 | config: &Config{ 275 | Input: []string{"../testdata/basic.yaml"}, 276 | Draft: 2020, 277 | OutputPath: "testdata/failure/output_readonly_schema.json", 278 | Indent: 0, 279 | }, 280 | expectedErr: errors.New("indentation must be a positive number"), 281 | }, 282 | { 283 | name: "Odd indentation number", 284 | config: &Config{ 285 | Input: []string{"../testdata/basic.yaml"}, 286 | Draft: 2020, 287 | OutputPath: "testdata/failure/output_readonly_schema.json", 288 | Indent: 1, 289 | }, 290 | expectedErr: errors.New("indentation must be an even number"), 291 | }, 292 | { 293 | name: "Missing file", 294 | config: &Config{ 295 | Input: []string{"missing.yaml"}, 296 | Draft: 2020, 297 | Indent: 4, 298 | }, 299 | expectedErr: errors.New("error reading YAML file(s)"), 300 | }, 301 | { 302 | name: "Fail Unmarshal", 303 | config: &Config{ 304 | Input: []string{"../testdata/fail"}, 305 | OutputPath: "testdata/failure/output_readonly_schema.json", 306 | Draft: 2020, 307 | Indent: 4, 308 | }, 309 | expectedErr: errors.New("error unmarshaling YAML"), 310 | }, 311 | { 312 | name: "Read-only filesystem", 313 | config: &Config{ 314 | Input: []string{"../testdata/basic.yaml"}, 315 | OutputPath: "testdata/failure/output_readonly_schema.json", 316 | Draft: 2020, 317 | Indent: 4, 318 | }, 319 | expectedErr: errors.New("error writing schema to file"), 320 | }, 321 | { 322 | name: "bundle invalid root path", 323 | config: &Config{ 324 | Draft: 2020, 325 | Indent: 4, 326 | Bundle: BoolFlag{set: true, value: true}, 327 | BundleRoot: "\000", // null byte is invalid in both linux & windows 328 | Input: []string{ 329 | "../testdata/bundle/simple.yaml", 330 | }, 331 | OutputPath: "../testdata/bundle_output.json", 332 | }, 333 | expectedErr: errors.New("open bundle root: open \x00: invalid argument"), 334 | }, 335 | { 336 | name: "bundle wrong root path", 337 | config: &Config{ 338 | Draft: 2020, 339 | Indent: 4, 340 | Bundle: BoolFlag{set: true, value: true}, 341 | BundleRoot: ".", 342 | Input: []string{ 343 | "../testdata/bundle/simple.yaml", 344 | }, 345 | OutputPath: "../testdata/bundle_output.json", 346 | }, 347 | expectedErr: errors.New("path escapes from parent"), 348 | }, 349 | { 350 | name: "bundle fail to get relative path", 351 | config: &Config{ 352 | Draft: 2020, 353 | Indent: 4, 354 | Bundle: BoolFlag{set: true, value: true}, 355 | BundleRoot: filepath.Clean("/"), 356 | Input: []string{ 357 | "../testdata/bundle/simple.yaml", 358 | }, 359 | OutputPath: "../testdata/bundle_output.json", 360 | }, 361 | expectedErr: errors.New("get relative path from bundle root to file"), 362 | }, 363 | { 364 | name: "invalid k8s ref alias", 365 | config: &Config{ 366 | Draft: 2020, 367 | Indent: 4, 368 | K8sSchemaURL: "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/{{ .K8sSchemaVersion }}/", 369 | K8sSchemaVersion: "", 370 | Input: []string{ 371 | "../testdata/k8sRef.yaml", 372 | }, 373 | OutputPath: "../testdata/k8sRef_output.json", 374 | }, 375 | expectedErr: errors.New("/properties/memory: must set k8sSchemaVersion config when using \"$ref: $k8s/...\""), 376 | }, 377 | { 378 | name: "invalid subschema type", 379 | config: &Config{ 380 | Draft: 2020, 381 | Indent: 4, 382 | Input: []string{ 383 | "../testdata/fail-type.yaml", 384 | }, 385 | OutputPath: "../testdata/fail-type_output.json", 386 | }, 387 | expectedErr: errors.New("/properties/nameOverride/type/0: invalid type \"foobar\", must be one of: array, boolean, integer, null, number, object, string"), 388 | }, 389 | } 390 | 391 | for _, tt := range tests { 392 | t.Run(tt.name, func(t *testing.T) { 393 | if tt.setupFunc != nil { 394 | err := tt.setupFunc() 395 | assert.NoError(t, err) 396 | } 397 | 398 | err := GenerateJsonSchema(tt.config) 399 | assert.Error(t, err) 400 | if err != nil { 401 | assert.Contains(t, err.Error(), tt.expectedErr.Error()) 402 | } 403 | 404 | if tt.cleanupFunc != nil { 405 | err := tt.cleanupFunc() 406 | assert.NoError(t, err) 407 | } 408 | }) 409 | } 410 | } 411 | 412 | func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) { 413 | tests := []struct { 414 | name string 415 | additionalPropertiesSet bool 416 | additionalProperties bool 417 | noAdditionalProperties bool 418 | expected interface{} 419 | }{ 420 | { 421 | name: "AdditionalProperties set to true", 422 | additionalPropertiesSet: true, 423 | additionalProperties: true, 424 | expected: true, 425 | }, 426 | { 427 | name: "AdditionalProperties set to false", 428 | additionalPropertiesSet: true, 429 | additionalProperties: false, 430 | expected: false, 431 | }, 432 | { 433 | name: "AdditionalProperties not set", 434 | additionalPropertiesSet: false, 435 | expected: nil, 436 | }, 437 | { 438 | name: "AdditionalProperties not set, but NoAdditionalProperties set", 439 | additionalPropertiesSet: false, 440 | noAdditionalProperties: true, 441 | expected: false, 442 | }, 443 | { 444 | name: "NoAdditionalProperties set, but AdditionalProperties set to true", 445 | additionalPropertiesSet: true, 446 | additionalProperties: true, 447 | noAdditionalProperties: true, 448 | expected: true, 449 | }, 450 | } 451 | 452 | for _, tt := range tests { 453 | t.Run(tt.name, func(t *testing.T) { 454 | additionalPropertiesFlag := &BoolFlag{} 455 | noAdditionalPropertiesFlag := &BoolFlag{} 456 | if tt.additionalPropertiesSet { 457 | if err := additionalPropertiesFlag.Set(fmt.Sprintf("%t", tt.additionalProperties)); err != nil { 458 | t.Fatalf("Failed to set additionalPropertiesFlag: %v", err) 459 | } 460 | } 461 | if tt.noAdditionalProperties { 462 | if err := noAdditionalPropertiesFlag.Set(fmt.Sprintf("%t", tt.noAdditionalProperties)); err != nil { 463 | t.Fatalf("Failed to set noAdditionalPropertiesFlag: %v", err) 464 | } 465 | } 466 | 467 | config := &Config{ 468 | Input: []string{"../testdata/empty.yaml"}, 469 | OutputPath: "../testdata/empty.schema.json", 470 | Draft: 2020, 471 | Indent: 4, 472 | NoAdditionalProperties: *noAdditionalPropertiesFlag, 473 | SchemaRoot: SchemaRoot{ 474 | ID: "", 475 | Title: "", 476 | Description: "", 477 | AdditionalProperties: *additionalPropertiesFlag, 478 | }, 479 | } 480 | 481 | err := GenerateJsonSchema(config) 482 | assert.NoError(t, err) 483 | 484 | generatedBytes, err := os.ReadFile(config.OutputPath) 485 | assert.NoError(t, err) 486 | 487 | var generatedSchema map[string]interface{} 488 | err = json.Unmarshal(generatedBytes, &generatedSchema) 489 | assert.NoError(t, err) 490 | 491 | if tt.expected == nil { 492 | _, exists := generatedSchema["additionalProperties"] 493 | assert.False(t, exists, "additionalProperties should not be present in the generated schema") 494 | } else { 495 | assert.Equal(t, tt.expected, generatedSchema["additionalProperties"], "additionalProperties value mismatch") 496 | } 497 | 498 | if err := os.Remove(config.OutputPath); err != nil && !os.IsNotExist(err) { 499 | t.Errorf("failed to remove values.schema.json: %v", err) 500 | } 501 | }) 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /pkg/parser.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | "sync" 9 | "text/template" 10 | ) 11 | 12 | func mergeSchemas(dest, src *Schema) *Schema { 13 | if dest == nil { 14 | return src 15 | } 16 | if src == nil { 17 | return dest 18 | } 19 | 20 | dest.SetKind(src.Kind()) 21 | 22 | // Resolve simple fields by favoring the fields from 'src' if they're provided 23 | if src.Type != "" { 24 | dest.Type = src.Type 25 | } 26 | if src.MultipleOf != nil { 27 | dest.MultipleOf = src.MultipleOf 28 | } 29 | if src.Maximum != nil { 30 | dest.Maximum = src.Maximum 31 | } 32 | if src.Minimum != nil { 33 | dest.Minimum = src.Minimum 34 | } 35 | if src.MaxLength != nil { 36 | dest.MaxLength = src.MaxLength 37 | } 38 | if src.MinLength != nil { 39 | dest.MinLength = src.MinLength 40 | } 41 | if src.Pattern != "" { 42 | dest.Pattern = src.Pattern 43 | } 44 | if src.MaxItems != nil { 45 | dest.MaxItems = src.MaxItems 46 | } 47 | if src.MinItems != nil { 48 | dest.MinItems = src.MinItems 49 | } 50 | if src.UniqueItems { 51 | dest.UniqueItems = src.UniqueItems 52 | } 53 | if src.MaxProperties != nil { 54 | dest.MaxProperties = src.MaxProperties 55 | } 56 | if src.MinProperties != nil { 57 | dest.MinProperties = src.MinProperties 58 | } 59 | if src.PatternProperties != nil { 60 | dest.PatternProperties = src.PatternProperties 61 | } 62 | if src.Title != "" { 63 | dest.Title = src.Title 64 | } 65 | if src.Description != "" { 66 | dest.Description = src.Description 67 | } 68 | if src.ReadOnly { 69 | dest.ReadOnly = src.ReadOnly 70 | } 71 | if src.Default != nil { 72 | dest.Default = src.Default 73 | } 74 | if src.AdditionalProperties != nil { 75 | dest.AdditionalProperties = mergeSchemas(dest.AdditionalProperties, src.AdditionalProperties) 76 | } 77 | if src.UnevaluatedProperties != nil { 78 | dest.UnevaluatedProperties = src.UnevaluatedProperties 79 | } 80 | if src.ID != "" { 81 | dest.ID = src.ID 82 | } 83 | if src.Ref != "" { 84 | dest.Ref = src.Ref 85 | } 86 | if src.Schema != "" { 87 | dest.Schema = src.Schema 88 | } 89 | if src.Comment != "" { 90 | dest.Comment = src.Comment 91 | } 92 | if src.AllOf != nil { 93 | dest.AllOf = src.AllOf 94 | } 95 | if src.AnyOf != nil { 96 | dest.AnyOf = src.AnyOf 97 | } 98 | if src.OneOf != nil { 99 | dest.OneOf = src.OneOf 100 | } 101 | if src.Not != nil { 102 | dest.Not = src.Not 103 | } 104 | 105 | // Merge 'enum' field (assuming that maintaining order doesn't matter) 106 | dest.Enum = append(dest.Enum, src.Enum...) 107 | 108 | // Recursive calls for nested structures 109 | dest.Properties = mergeSchemasMap(dest.Properties, src.Properties) 110 | dest.Defs = mergeSchemasMap(dest.Defs, src.Defs) 111 | dest.Definitions = mergeSchemasMap(dest.Definitions, src.Definitions) 112 | 113 | // 'required' array is combined uniquely 114 | dest.Required = uniqueStringAppend(dest.Required, src.Required...) 115 | 116 | // Merge 'items' if they exist (assuming they're not arrays) 117 | if src.Items != nil { 118 | dest.Items = mergeSchemas(dest.Items, src.Items) 119 | } 120 | if src.AdditionalItems != nil { 121 | dest.AdditionalItems = mergeSchemas(dest.AdditionalItems, src.AdditionalItems) 122 | } 123 | 124 | return dest 125 | } 126 | 127 | func mergeSchemasMap(dest, src map[string]*Schema) map[string]*Schema { 128 | if src != nil { 129 | if dest == nil { 130 | dest = make(map[string]*Schema) 131 | } 132 | for defName, srcDefSchema := range src { 133 | if destDefSchema, exists := dest[defName]; exists { 134 | dest[defName] = mergeSchemas(destDefSchema, srcDefSchema) 135 | } else { 136 | dest[defName] = srcDefSchema 137 | } 138 | } 139 | } 140 | return dest 141 | } 142 | 143 | func ensureCompliant(schema *Schema, noAdditionalProperties bool, draft int) error { 144 | return ensureCompliantRec(nil, schema, map[*Schema]struct{}{}, noAdditionalProperties, draft) 145 | } 146 | 147 | func ensureCompliantRec(ptr Ptr, schema *Schema, visited map[*Schema]struct{}, noAdditionalProperties bool, draft int) error { 148 | if schema == nil { 149 | return nil 150 | } 151 | 152 | // If we've already visited this schema, we've found a circular reference 153 | if _, ok := visited[schema]; ok { 154 | return fmt.Errorf("%s: circular reference detected in schema", ptr) 155 | } 156 | 157 | // Mark the current schema as visited 158 | visited[schema] = struct{}{} 159 | defer delete(visited, schema) 160 | 161 | for path, sub := range schema.Subschemas() { 162 | // continue recursively 163 | if err := ensureCompliantRec(ptr.Add(path), sub, visited, noAdditionalProperties, draft); err != nil { 164 | return err 165 | } 166 | } 167 | 168 | if schema.Kind().IsBool() { 169 | return nil 170 | } 171 | 172 | if err := validateType(ptr.Prop("type"), schema.Type); err != nil { 173 | return err 174 | } 175 | 176 | if schema.AdditionalProperties == nil && noAdditionalProperties && schema.Type == "object" { 177 | schema.AdditionalProperties = &SchemaFalse 178 | } 179 | 180 | switch { 181 | case len(schema.AllOf) > 0, 182 | len(schema.AnyOf) > 0, 183 | len(schema.OneOf) > 0, 184 | schema.Not != nil: 185 | // These fields collide with "type" 186 | schema.Type = nil 187 | } 188 | 189 | if draft <= 7 && schema.Ref != "" { 190 | schemaClone := *schema 191 | schemaClone.Ref = "" 192 | if !schemaClone.IsZero() { 193 | *schema = Schema{ 194 | AllOf: []*Schema{ 195 | {Ref: schema.Ref}, 196 | &schemaClone, 197 | }, 198 | } 199 | } 200 | } 201 | 202 | return nil 203 | } 204 | 205 | func validateType(ptr Ptr, v any) error { 206 | switch v := v.(type) { 207 | case []any: 208 | var types []string 209 | for i, t := range v { 210 | ptr := ptr.Item(i) 211 | switch t := t.(type) { 212 | case string: 213 | if !isValidTypeString(t) { 214 | return fmt.Errorf("%s: invalid type %q, must be one of: array, boolean, integer, null, number, object, string", ptr, t) 215 | } 216 | if slices.Contains(types, t) { 217 | return fmt.Errorf("%s: type list must be unique, but found %q multiple times", ptr, t) 218 | } 219 | types = append(types, t) 220 | default: 221 | return fmt.Errorf("%s: type list must only contain strings", ptr) 222 | } 223 | } 224 | return nil 225 | case string: 226 | if !isValidTypeString(v) { 227 | return fmt.Errorf("%s: invalid type %q, must be one of: array, boolean, integer, null, number, object, string", ptr, v) 228 | } 229 | return nil 230 | case nil: 231 | return nil 232 | default: 233 | return fmt.Errorf("%s: type only be string or array of strings", ptr) 234 | } 235 | } 236 | 237 | func isValidTypeString(t string) bool { 238 | switch t { 239 | case "array", "boolean", "integer", "null", "number", "object", "string": 240 | return true 241 | default: 242 | return false 243 | } 244 | } 245 | 246 | func updateRefK8sAlias(schema *Schema, urlTemplate, version string) error { 247 | urlFunc := sync.OnceValues(func() (string, error) { 248 | if version == "" { 249 | return "", fmt.Errorf(`must set k8sSchemaVersion config when using "$ref: $k8s/...". For example pass --k8sSchemaVersion=v1.33.1 flag`) 250 | } 251 | tpl, err := template.New("").Parse(urlTemplate) 252 | if err != nil { 253 | return "", fmt.Errorf("parse k8sSchemaURL template: %w", err) 254 | } 255 | var buf bytes.Buffer 256 | if err := tpl.Execute(&buf, struct{ K8sSchemaVersion string }{K8sSchemaVersion: version}); err != nil { 257 | return "", fmt.Errorf("template k8sSchemaURL: %w", err) 258 | } 259 | return buf.String(), nil 260 | }) 261 | return updateRefK8sAliasRec(nil, schema, urlFunc) 262 | } 263 | 264 | func updateRefK8sAliasRec(ptr Ptr, schema *Schema, urlFunc func() (string, error)) error { 265 | for path, sub := range schema.Subschemas() { 266 | // continue recursively 267 | if err := updateRefK8sAliasRec(ptr.Add(path), sub, urlFunc); err != nil { 268 | return err 269 | } 270 | } 271 | 272 | withoutFragment, _, _ := strings.Cut(schema.Ref, "#") 273 | if withoutFragment == "$k8s" || withoutFragment == "$k8s/" { 274 | return fmt.Errorf("%s: invalid $k8s schema alias: must have a path but only got %q", ptr, schema.Ref) 275 | } 276 | 277 | withoutAlias, ok := strings.CutPrefix(schema.Ref, "$k8s/") 278 | if !ok { 279 | return nil 280 | } 281 | 282 | urlPrefix, err := urlFunc() 283 | if err != nil { 284 | return fmt.Errorf("%s: %w", ptr, err) 285 | } 286 | 287 | schema.Ref = fmt.Sprintf("%s/%s", strings.TrimSuffix(urlPrefix, "/"), withoutAlias) 288 | return nil 289 | } 290 | -------------------------------------------------------------------------------- /pkg/parser_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func uint64Ptr(i uint64) *uint64 { 12 | return &i 13 | } 14 | 15 | func float64Ptr(f float64) *float64 { 16 | return &f 17 | } 18 | 19 | func boolPtr(b bool) *bool { 20 | return &b 21 | } 22 | 23 | // schemasEqual is a helper function to compare two Schema objects. 24 | func schemasEqual(a, b *Schema) bool { 25 | if a == nil || b == nil { 26 | return a == b 27 | } 28 | // Compare simple fields 29 | if a.Type != b.Type || a.Pattern != b.Pattern || a.UniqueItems != b.UniqueItems || a.Title != b.Title || a.Description != b.Description || a.ReadOnly != b.ReadOnly { 30 | return false 31 | } 32 | // Compare pointer fields 33 | if !comparePointer(a.MultipleOf, b.MultipleOf) || 34 | !comparePointer(a.Maximum, b.Maximum) || 35 | !comparePointer(a.Minimum, b.Minimum) || 36 | !comparePointer(a.MaxLength, b.MaxLength) || 37 | !comparePointer(a.MinLength, b.MinLength) || 38 | !comparePointer(a.MaxItems, b.MaxItems) || 39 | !comparePointer(a.MinItems, b.MinItems) || 40 | !comparePointer(a.MaxProperties, b.MaxProperties) || 41 | !comparePointer(a.MinProperties, b.MinProperties) { 42 | return false 43 | } 44 | // Compare slice fields 45 | if !reflect.DeepEqual(a.Enum, b.Enum) || !reflect.DeepEqual(a.Required, b.Required) { 46 | return false 47 | } 48 | // Recursively check nested fields 49 | if !schemasEqual(a.Items, b.Items) { 50 | return false 51 | } 52 | // Compare map fields (Properties) 53 | return reflect.DeepEqual(a.Properties, b.Properties) 54 | } 55 | 56 | // comparePointer is a helper function for comparing pointer fields 57 | func comparePointer[T comparable](a, b *T) bool { 58 | if a == nil && b == nil { 59 | return true 60 | } 61 | if a != nil && b != nil { 62 | return *a == *b 63 | } 64 | return false 65 | } 66 | 67 | func TestMergeSchemas(t *testing.T) { 68 | tests := []struct { 69 | name string 70 | dest *Schema 71 | src *Schema 72 | want *Schema 73 | }{ 74 | { 75 | name: "dest nil", 76 | dest: nil, 77 | src: &Schema{Type: "string"}, 78 | want: &Schema{Type: "string"}, 79 | }, 80 | { 81 | name: "src nil", 82 | dest: &Schema{Type: "string"}, 83 | src: nil, 84 | want: &Schema{Type: "string"}, 85 | }, 86 | { 87 | name: "both non-nil same type", 88 | dest: &Schema{Type: "object"}, 89 | src: &Schema{Type: "object"}, 90 | want: &Schema{Type: "object"}, 91 | }, 92 | { 93 | name: "both non-nil different type", 94 | dest: &Schema{Type: "object"}, 95 | src: &Schema{Type: "string"}, 96 | want: &Schema{Type: "string"}, 97 | }, 98 | { 99 | name: "nested properties", 100 | dest: &Schema{Type: "object", Properties: map[string]*Schema{ 101 | "foo": {Type: "integer"}, 102 | }}, 103 | src: &Schema{Type: "object", PatternProperties: map[string]*Schema{ 104 | "^[a-z]$": {Type: "integer"}, 105 | }}, 106 | want: &Schema{Type: "object", Properties: map[string]*Schema{ 107 | "foo": {Type: "integer"}, 108 | }, PatternProperties: map[string]*Schema{ 109 | "^[a-z]$": {Type: "integer"}, 110 | }}, 111 | }, 112 | { 113 | name: "items", 114 | dest: &Schema{Type: "array", Items: &Schema{Type: "string"}}, 115 | src: &Schema{Type: "array", Items: &Schema{Type: "string"}}, 116 | want: &Schema{Type: "array", Items: &Schema{Type: "string"}}, 117 | }, 118 | { 119 | name: "merge existing properties", 120 | dest: &Schema{ 121 | Type: "object", 122 | Properties: map[string]*Schema{ 123 | "shared": {Type: "integer"}, 124 | }, 125 | }, 126 | src: &Schema{ 127 | Type: "object", 128 | Properties: map[string]*Schema{ 129 | "shared": {Type: "string", MinLength: uint64Ptr(1)}, 130 | }, 131 | }, 132 | want: &Schema{ 133 | Type: "object", 134 | Properties: map[string]*Schema{ 135 | "shared": {Type: "string", MinLength: uint64Ptr(1)}, 136 | }, 137 | }, 138 | }, 139 | { 140 | name: "merge multiple defs", 141 | dest: &Schema{ 142 | Type: "object", 143 | Defs: map[string]*Schema{ 144 | "file:a.json": {Type: "object"}, 145 | }, 146 | }, 147 | src: &Schema{ 148 | Type: "object", 149 | Defs: map[string]*Schema{ 150 | "file:b.json": {Type: "integer"}, 151 | }, 152 | }, 153 | want: &Schema{ 154 | Type: "object", 155 | Defs: map[string]*Schema{ 156 | "file:a.json": {Type: "object"}, 157 | "file:b.json": {Type: "integer"}, 158 | }, 159 | }, 160 | }, 161 | { 162 | name: "merge existing defs", 163 | dest: &Schema{ 164 | Type: "object", 165 | Defs: map[string]*Schema{ 166 | "shared": {Type: "integer"}, 167 | }, 168 | }, 169 | src: &Schema{ 170 | Type: "object", 171 | Defs: map[string]*Schema{ 172 | "shared": {Type: "string", MinLength: uint64Ptr(1)}, 173 | }, 174 | }, 175 | want: &Schema{ 176 | Type: "object", 177 | Defs: map[string]*Schema{ 178 | "shared": {Type: "string", MinLength: uint64Ptr(1)}, 179 | }, 180 | }, 181 | }, 182 | { 183 | name: "numeric properties", 184 | dest: &Schema{Type: "integer", MultipleOf: float64Ptr(2), Minimum: float64Ptr(1), Maximum: float64Ptr(10)}, 185 | src: &Schema{Type: "integer", MultipleOf: float64Ptr(2), Minimum: float64Ptr(1), Maximum: float64Ptr(10)}, 186 | want: &Schema{Type: "integer", MultipleOf: float64Ptr(2), Minimum: float64Ptr(1), Maximum: float64Ptr(10)}, 187 | }, 188 | { 189 | name: "string properties", 190 | dest: &Schema{Type: "string", Pattern: "^abc", MinLength: uint64Ptr(1), MaxLength: uint64Ptr(10)}, 191 | src: &Schema{Type: "string", Pattern: "^abc", MinLength: uint64Ptr(1), MaxLength: uint64Ptr(10)}, 192 | want: &Schema{Type: "string", Pattern: "^abc", MinLength: uint64Ptr(1), MaxLength: uint64Ptr(10)}, 193 | }, 194 | { 195 | name: "array properties", 196 | dest: &Schema{Type: "array", Items: &Schema{Type: "string"}, AdditionalItems: &Schema{Type: "string"}, MinItems: uint64Ptr(1), MaxItems: uint64Ptr(10), UniqueItems: true}, 197 | src: &Schema{Type: "array", Items: &Schema{Type: "string"}, AdditionalItems: &Schema{Type: "string"}, MinItems: uint64Ptr(1), MaxItems: uint64Ptr(10), UniqueItems: true}, 198 | want: &Schema{Type: "array", Items: &Schema{Type: "string"}, AdditionalItems: &Schema{Type: "string"}, MinItems: uint64Ptr(1), MaxItems: uint64Ptr(10), UniqueItems: true}, 199 | }, 200 | { 201 | name: "object properties", 202 | dest: &Schema{Type: "object", MinProperties: uint64Ptr(1), MaxProperties: uint64Ptr(10), PatternProperties: map[string]*Schema{"^.$": {Type: "string"}}, AdditionalProperties: &SchemaFalse, UnevaluatedProperties: boolPtr(false)}, 203 | src: &Schema{Type: "object", MinProperties: uint64Ptr(1), MaxProperties: uint64Ptr(10), PatternProperties: map[string]*Schema{"^.$": {Type: "string"}}, AdditionalProperties: &SchemaFalse, UnevaluatedProperties: boolPtr(false)}, 204 | want: &Schema{Type: "object", MinProperties: uint64Ptr(1), MaxProperties: uint64Ptr(10), PatternProperties: map[string]*Schema{"^.$": {Type: "string"}}, AdditionalProperties: &SchemaFalse, UnevaluatedProperties: boolPtr(false)}, 205 | }, 206 | { 207 | name: "meta-data properties", 208 | dest: &Schema{Type: "object", Title: "My Title", Description: "My description", ReadOnly: true, Default: "default value", ID: "http://example.com/schema", Ref: "schema/product.json", Schema: "https://my-schema", Comment: "Lorem ipsum"}, 209 | src: &Schema{Type: "object", Title: "My Title", Description: "My description", ReadOnly: true, Default: "default value", ID: "http://example.com/schema", Ref: "schema/product.json", Schema: "https://my-schema", Comment: "Lorem ipsum"}, 210 | want: &Schema{Type: "object", Title: "My Title", Description: "My description", ReadOnly: true, Default: "default value", ID: "http://example.com/schema", Ref: "schema/product.json", Schema: "https://my-schema", Comment: "Lorem ipsum"}, 211 | }, 212 | { 213 | name: "allOf", 214 | dest: &Schema{Type: "object"}, 215 | src: &Schema{Type: "object", AllOf: []*Schema{{Type: "string"}}}, 216 | want: &Schema{Type: "object", AllOf: []*Schema{{Type: "string"}}}, 217 | }, 218 | { 219 | name: "anyOf", 220 | dest: &Schema{Type: "object"}, 221 | src: &Schema{Type: "object", AnyOf: []*Schema{{Type: "string"}}}, 222 | want: &Schema{Type: "object", AnyOf: []*Schema{{Type: "string"}}}, 223 | }, 224 | { 225 | name: "oneOf", 226 | dest: &Schema{Type: "object"}, 227 | src: &Schema{Type: "object", OneOf: []*Schema{{Type: "string"}}}, 228 | want: &Schema{Type: "object", OneOf: []*Schema{{Type: "string"}}}, 229 | }, 230 | { 231 | name: "not", 232 | dest: &Schema{Type: "object"}, 233 | src: &Schema{Type: "object", Not: &Schema{Type: "string"}}, 234 | want: &Schema{Type: "object", Not: &Schema{Type: "string"}}, 235 | }, 236 | } 237 | 238 | for _, tt := range tests { 239 | t.Run(tt.name, func(t *testing.T) { 240 | got := mergeSchemas(tt.dest, tt.src) 241 | if !schemasEqual(got, tt.want) { 242 | t.Errorf("mergeSchemas() got = %v, want %v", got, tt.want) 243 | } 244 | }) 245 | } 246 | } 247 | 248 | func TestEnsureCompliant(t *testing.T) { 249 | tests := []struct { 250 | name string 251 | schema *Schema 252 | noAdditionalProperties bool 253 | draft int 254 | want *Schema 255 | wantErr string 256 | }{ 257 | { 258 | name: "nil schema", 259 | schema: nil, 260 | }, 261 | 262 | { 263 | name: "bool schema", 264 | schema: &SchemaTrue, 265 | want: &SchemaTrue, 266 | }, 267 | 268 | { 269 | name: "invalid type string", 270 | schema: &Schema{Type: "foobar"}, 271 | wantErr: "/type: invalid type \"foobar\", must be one of: array, boolean, integer, null, number, object, string", 272 | }, 273 | { 274 | name: "duplicate type", 275 | schema: &Schema{Type: []any{"string", "string"}}, 276 | wantErr: "/type/1: type list must be unique, but found \"string\" multiple times", 277 | }, 278 | { 279 | name: "invalid type array", 280 | schema: &Schema{Type: []any{true}}, 281 | wantErr: "/type/0: type list must only contain strings", 282 | }, 283 | { 284 | name: "invalid type value", 285 | schema: &Schema{Type: true}, 286 | wantErr: "/type: type only be string or array of strings", 287 | }, 288 | 289 | { 290 | name: "override additionalProperties", 291 | schema: &Schema{ 292 | Type: "object", 293 | AdditionalProperties: nil, 294 | }, 295 | noAdditionalProperties: true, 296 | want: &Schema{ 297 | Type: "object", 298 | AdditionalProperties: &SchemaFalse, 299 | }, 300 | }, 301 | 302 | { 303 | name: "keep additionalProperties when not object", 304 | schema: &Schema{ 305 | Type: "array", 306 | AdditionalProperties: &Schema{ID: "foo"}, 307 | }, 308 | noAdditionalProperties: true, 309 | want: &Schema{ 310 | Type: "array", 311 | AdditionalProperties: &Schema{ID: "foo"}, 312 | }, 313 | }, 314 | 315 | { 316 | name: "keep additionalProperties when config not enabled", 317 | schema: &Schema{ 318 | Type: "object", 319 | AdditionalProperties: &Schema{ID: "foo"}, 320 | }, 321 | noAdditionalProperties: false, 322 | want: &Schema{ 323 | Type: "object", 324 | AdditionalProperties: &Schema{ID: "foo"}, 325 | }, 326 | }, 327 | 328 | { 329 | name: "unset type when allOf", 330 | schema: &Schema{Type: "object", AllOf: []*Schema{{ID: "foo"}}}, 331 | want: &Schema{AllOf: []*Schema{{ID: "foo"}}}, 332 | }, 333 | { 334 | name: "unset type when anyOf", 335 | schema: &Schema{Type: "object", AnyOf: []*Schema{{ID: "foo"}}}, 336 | want: &Schema{AnyOf: []*Schema{{ID: "foo"}}}, 337 | }, 338 | { 339 | name: "unset type when oneOf", 340 | schema: &Schema{Type: "object", OneOf: []*Schema{{ID: "foo"}}}, 341 | want: &Schema{OneOf: []*Schema{{ID: "foo"}}}, 342 | }, 343 | { 344 | name: "unset type when not", 345 | schema: &Schema{Type: "object", Not: &Schema{ID: "foo"}}, 346 | want: &Schema{Not: &Schema{ID: "foo"}}, 347 | }, 348 | 349 | { 350 | name: "keep ref with other fields when draft 2019", 351 | schema: &Schema{Ref: "#", Type: "object"}, 352 | draft: 2019, 353 | want: &Schema{Ref: "#", Type: "object"}, 354 | }, 355 | { 356 | name: "keep ref without fields when draft 7", 357 | schema: &Schema{Ref: "#"}, 358 | draft: 7, 359 | want: &Schema{Ref: "#"}, 360 | }, 361 | { 362 | name: "change ref with other fields to allOf when draft 7", 363 | schema: &Schema{Ref: "#", Type: "object"}, 364 | draft: 7, 365 | want: &Schema{ 366 | AllOf: []*Schema{ 367 | {Ref: "#"}, 368 | {Type: "object"}, 369 | }, 370 | }, 371 | }, 372 | } 373 | 374 | for _, tt := range tests { 375 | t.Run(tt.name, func(t *testing.T) { 376 | if tt.draft == 0 { 377 | tt.draft = 2020 378 | } 379 | err := ensureCompliant(tt.schema, tt.noAdditionalProperties, tt.draft) 380 | if tt.wantErr != "" { 381 | require.ErrorContains(t, err, tt.wantErr) 382 | return 383 | } 384 | require.NoError(t, err) 385 | assert.Equal(t, tt.want, tt.schema) 386 | }) 387 | } 388 | } 389 | 390 | func TestEnsureCompliant_recursive(t *testing.T) { 391 | recursiveSchema := &Schema{} 392 | recursiveSchema.Properties = map[string]*Schema{ 393 | "circular": recursiveSchema, 394 | } 395 | 396 | tests := []struct { 397 | name string 398 | schema *Schema 399 | }{ 400 | { 401 | name: "recursive items", 402 | schema: &Schema{Items: recursiveSchema}, 403 | }, 404 | { 405 | name: "recursive properties", 406 | schema: &Schema{Properties: map[string]*Schema{"circular": recursiveSchema}}, 407 | }, 408 | { 409 | name: "recursive patternProperties", 410 | schema: &Schema{PatternProperties: map[string]*Schema{"circular": recursiveSchema}}, 411 | }, 412 | { 413 | name: "recursive defs", 414 | schema: &Schema{Defs: map[string]*Schema{"circular": recursiveSchema}}, 415 | }, 416 | { 417 | name: "recursive definitions", 418 | schema: &Schema{Definitions: map[string]*Schema{"circular": recursiveSchema}}, 419 | }, 420 | { 421 | name: "recursive allOf", 422 | schema: &Schema{AllOf: []*Schema{recursiveSchema}}, 423 | }, 424 | { 425 | name: "recursive anyOf", 426 | schema: &Schema{AnyOf: []*Schema{recursiveSchema}}, 427 | }, 428 | { 429 | name: "recursive oneOf", 430 | schema: &Schema{OneOf: []*Schema{recursiveSchema}}, 431 | }, 432 | { 433 | name: "recursive not", 434 | schema: &Schema{Not: recursiveSchema}, 435 | }, 436 | } 437 | 438 | for _, tc := range tests { 439 | t.Run(tc.name, func(t *testing.T) { 440 | err := ensureCompliant(tc.schema, false, 2020) 441 | assert.Error(t, err) 442 | }) 443 | } 444 | } 445 | 446 | func TestUpdateRefK8sAlias(t *testing.T) { 447 | tests := []struct { 448 | name string 449 | schema *Schema 450 | urlTemplate string 451 | version string 452 | wantErr string 453 | want *Schema 454 | }{ 455 | { 456 | name: "empty schema", 457 | schema: &Schema{}, 458 | want: &Schema{}, 459 | }, 460 | { 461 | name: "with trailing slash", 462 | urlTemplate: "http://example.com/{{ .K8sSchemaVersion }}/", 463 | version: "v1.2.3", 464 | schema: &Schema{ 465 | Items: &Schema{Ref: "$k8s/foobar.json"}, 466 | }, 467 | want: &Schema{ 468 | Items: &Schema{Ref: "http://example.com/v1.2.3/foobar.json"}, 469 | }, 470 | }, 471 | { 472 | name: "without trailing slash", 473 | urlTemplate: "http://example.com/{{ .K8sSchemaVersion }}", 474 | version: "v1.2.3", 475 | schema: &Schema{ 476 | Items: &Schema{Ref: "$k8s/foobar.json"}, 477 | }, 478 | want: &Schema{ 479 | Items: &Schema{Ref: "http://example.com/v1.2.3/foobar.json"}, 480 | }, 481 | }, 482 | { 483 | name: "with fragment", 484 | urlTemplate: "http://example.com/{{ .K8sSchemaVersion }}", 485 | version: "v1.2.3", 486 | schema: &Schema{ 487 | Items: &Schema{Ref: "$k8s/foobar.json#/properties/foo"}, 488 | }, 489 | want: &Schema{ 490 | Items: &Schema{Ref: "http://example.com/v1.2.3/foobar.json#/properties/foo"}, 491 | }, 492 | }, 493 | 494 | { 495 | name: "missing version", 496 | urlTemplate: "http://example.com/{{ .K8sSchemaVersion }}", 497 | version: "", 498 | schema: &Schema{ 499 | Items: &Schema{Ref: "$k8s/foobar.json#/properties/foo"}, 500 | }, 501 | wantErr: "/items: must set k8sSchemaVersion config", 502 | }, 503 | { 504 | name: "invalid template", 505 | urlTemplate: "http://example.com/{{", 506 | version: "v1.2.3", 507 | schema: &Schema{ 508 | Items: &Schema{Ref: "$k8s/foobar.json#/properties/foo"}, 509 | }, 510 | wantErr: "/items: parse k8sSchemaURL template: template: :1: unclosed action", 511 | }, 512 | { 513 | name: "invalid variable", 514 | urlTemplate: "http://example.com/{{ .Foobar }}", 515 | version: "v1.2.3", 516 | schema: &Schema{ 517 | Items: &Schema{Ref: "$k8s/foobar.json#/properties/foo"}, 518 | }, 519 | wantErr: "can't evaluate field Foobar in type", 520 | }, 521 | { 522 | name: "invalid ref", 523 | urlTemplate: "http://example.com/{{ .K8sSchemaVersion }}", 524 | version: "v1.2.3", 525 | schema: &Schema{ 526 | Items: &Schema{Ref: "$k8s/#/properties/foo"}, 527 | }, 528 | wantErr: "/items: invalid $k8s schema alias: must have a path but only got \"$k8s/#/properties/foo\"", 529 | }, 530 | } 531 | 532 | for _, tt := range tests { 533 | t.Run(tt.name, func(t *testing.T) { 534 | err := updateRefK8sAlias(tt.schema, tt.urlTemplate, tt.version) 535 | if tt.wantErr != "" { 536 | require.ErrorContains(t, err, tt.wantErr) 537 | return 538 | } 539 | require.NoError(t, err) 540 | assert.Equal(t, tt.want, tt.schema) 541 | }) 542 | } 543 | } 544 | -------------------------------------------------------------------------------- /pkg/pointer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "slices" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Ptr is a JSON Ptr [https://datatracker.ietf.org/doc/html/rfc6901]. 10 | // 11 | // The type is meant to be used in an immutable way, where all methods 12 | // return a new pointer with the appropriate changes. 13 | // 14 | // You don't have to initialize this struct with the [NewPtr] function. 15 | // A nil value is equivalent to an empty path: "/" 16 | type Ptr []string 17 | 18 | // NewPtr returns a new [Ptr] with optionally provided property names 19 | func NewPtr(name ...string) Ptr { 20 | return (Ptr{}).Prop(name...) 21 | } 22 | 23 | func ParsePtr(path string) Ptr { 24 | path = strings.TrimPrefix(path, "#") 25 | path = strings.TrimPrefix(path, "/") 26 | if path == "" { 27 | return nil 28 | } 29 | split := strings.Split(path, "/") 30 | for i := range split { 31 | split[i] = pointerReplacer.Replace(pointerReplacerReverse.Replace(split[i])) 32 | } 33 | return Ptr(split) 34 | } 35 | 36 | // pointerReplacer contains the replcements defined in https://datatracker.ietf.org/doc/html/rfc6901#section-3 37 | var pointerReplacer = strings.NewReplacer( 38 | "~", "~0", 39 | "/", "~1", 40 | ) 41 | 42 | var pointerReplacerReverse = strings.NewReplacer( 43 | "~0", "~", 44 | "~1", "/", 45 | ) 46 | 47 | func (p Ptr) Prop(name ...string) Ptr { 48 | for _, s := range name { 49 | p = append(p, pointerReplacer.Replace(s)) 50 | } 51 | return p 52 | } 53 | 54 | func (p Ptr) Item(index ...int) Ptr { 55 | for _, i := range index { 56 | p = append(p, strconv.Itoa(i)) 57 | } 58 | return p 59 | } 60 | 61 | func (p Ptr) Add(other ...Ptr) Ptr { 62 | for _, o := range other { 63 | p = append(p, o...) 64 | } 65 | return p 66 | } 67 | 68 | func (p Ptr) HasPrefix(prefix Ptr) bool { 69 | if len(prefix) > len(p) { 70 | return false 71 | } 72 | return slices.Equal(p[:len(prefix)], prefix) 73 | } 74 | 75 | // String returns a slash-delimited string of the pointer. 76 | // 77 | // Example: 78 | // 79 | // NewPtr("foo", "bar").String() 80 | // // => "/foo/bar" 81 | func (p Ptr) String() string { 82 | return "/" + strings.Join(p, "/") 83 | } 84 | -------------------------------------------------------------------------------- /pkg/pointer_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPtr(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | ptr Ptr 13 | want string 14 | }{ 15 | { 16 | name: "empty", 17 | ptr: Ptr{}, 18 | want: "/", 19 | }, 20 | 21 | { 22 | name: "single prop", 23 | ptr: Ptr{}.Prop("foo"), 24 | want: "/foo", 25 | }, 26 | { 27 | name: "multiple props in different calls", 28 | ptr: Ptr{}.Prop("foo").Prop("bar"), 29 | want: "/foo/bar", 30 | }, 31 | { 32 | name: "multiple props in the same call", 33 | ptr: Ptr{}.Prop("foo", "bar"), 34 | want: "/foo/bar", 35 | }, 36 | 37 | { 38 | name: "single item", 39 | ptr: Ptr{}.Item(1), 40 | want: "/1", 41 | }, 42 | { 43 | name: "multiple items in different calls", 44 | ptr: Ptr{}.Item(1).Item(2), 45 | want: "/1/2", 46 | }, 47 | { 48 | name: "multiple items in the same call", 49 | ptr: Ptr{}.Item(1, 2), 50 | want: "/1/2", 51 | }, 52 | 53 | { 54 | name: "adding other pointers", 55 | ptr: Ptr{"foo", "bar"}.Add(Ptr{"moo", "doo"}), 56 | want: "/foo/bar/moo/doo", 57 | }, 58 | 59 | { 60 | name: "escapes slash", 61 | ptr: Ptr{}.Prop("foo/bar"), 62 | want: "/foo~1bar", 63 | }, 64 | { 65 | name: "escapes tilde", 66 | ptr: Ptr{}.Prop("foo~bar"), 67 | want: "/foo~0bar", 68 | }, 69 | { 70 | name: "escapes both", 71 | ptr: Ptr{}.Prop("foo/bar~moo/doo"), 72 | want: "/foo~1bar~0moo~1doo", 73 | }, 74 | } 75 | 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | got := tt.ptr.String() 79 | if got != tt.want { 80 | t.Fatalf("wrong result\nwant: %q\ngot: %q", tt.want, got) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestParsePtr(t *testing.T) { 87 | tests := []struct { 88 | name string 89 | path string 90 | want Ptr 91 | }{ 92 | { 93 | name: "empty", 94 | path: "", 95 | want: nil, 96 | }, 97 | 98 | { 99 | name: "single prop", 100 | path: "/foo", 101 | want: NewPtr("foo"), 102 | }, 103 | { 104 | name: "multiple props", 105 | path: "/foo/bar/12/lorem", 106 | want: NewPtr("foo", "bar").Item(12).Prop("lorem"), 107 | }, 108 | { 109 | name: "special chars", 110 | path: "/foo~1bar/moo~0doo", 111 | want: NewPtr("foo/bar", "moo~doo"), 112 | }, 113 | { 114 | name: "invalid syntax", 115 | path: "/foo~bar", 116 | want: NewPtr("foo~bar"), 117 | }, 118 | { 119 | name: "with pound prefix", 120 | path: "#/foo", 121 | want: NewPtr("foo"), 122 | }, 123 | { 124 | name: "without slash prefix", 125 | path: "foo/bar", 126 | want: NewPtr("foo", "bar"), 127 | }, 128 | } 129 | 130 | for _, tt := range tests { 131 | t.Run(tt.name, func(t *testing.T) { 132 | got := ParsePtr(tt.path) 133 | assert.Equal(t, tt.want, got) 134 | }) 135 | } 136 | } 137 | 138 | func TestPtr_HasPrefix(t *testing.T) { 139 | tests := []struct { 140 | name string 141 | ptr Ptr 142 | prefix Ptr 143 | want bool 144 | }{ 145 | { 146 | name: "empty both", 147 | ptr: nil, 148 | prefix: nil, 149 | want: true, 150 | }, 151 | { 152 | name: "empty prefix", 153 | ptr: Ptr{"foo"}, 154 | prefix: nil, 155 | want: true, 156 | }, 157 | { 158 | name: "empty ptr", 159 | ptr: nil, 160 | prefix: NewPtr("foo"), 161 | want: false, 162 | }, 163 | { 164 | name: "longer prefix than ptr", 165 | ptr: NewPtr("foo"), 166 | prefix: NewPtr("foo", "bar"), 167 | want: false, 168 | }, 169 | { 170 | name: "match", 171 | ptr: NewPtr("foo", "bar"), 172 | prefix: NewPtr("foo"), 173 | want: true, 174 | }, 175 | { 176 | name: "match plain", 177 | ptr: NewPtr("foo", "bar"), 178 | prefix: NewPtr("foo"), 179 | want: true, 180 | }, 181 | { 182 | name: "match with special chars", 183 | ptr: NewPtr("foo/bar"), 184 | prefix: NewPtr("foo/bar"), 185 | want: true, 186 | }, 187 | { 188 | name: "no match because of special chars in ptr", 189 | ptr: NewPtr("foo/bar"), 190 | prefix: NewPtr("foo"), 191 | want: false, 192 | }, 193 | { 194 | name: "no match because of special chars in prefix", 195 | ptr: NewPtr("foo", "bar"), 196 | prefix: NewPtr("foo/bar"), 197 | want: false, 198 | }, 199 | } 200 | 201 | for _, tt := range tests { 202 | t.Run(tt.name, func(t *testing.T) { 203 | got := tt.ptr.HasPrefix(tt.prefix) 204 | if got != tt.want { 205 | t.Fatalf("wrong result\nptr: %q\nprefix: %q\nwant: %t\ngot: %t", 206 | tt.ptr, tt.prefix, tt.want, got) 207 | } 208 | }) 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /pkg/schema.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "cmp" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "iter" 10 | "maps" 11 | "slices" 12 | "strconv" 13 | "strings" 14 | 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | // SchemaKind is an internal enum used to be able to parse 19 | // an entire schema as a boolean, which is used on fields like 20 | // "additionalProperties". 21 | // 22 | // The zero value is "treat this as an object". 23 | type SchemaKind byte 24 | 25 | const ( 26 | SchemaKindObject SchemaKind = iota 27 | SchemaKindTrue 28 | SchemaKindFalse 29 | ) 30 | 31 | var ( 32 | SchemaTrue = Schema{kind: SchemaKindTrue} 33 | SchemaFalse = Schema{kind: SchemaKindFalse} 34 | ) 35 | 36 | func SchemaBool(value bool) *Schema { 37 | if value { 38 | return &SchemaTrue 39 | } 40 | return &SchemaFalse 41 | } 42 | 43 | // IsBool returns true when the [Schema] represents a boolean value 44 | // instead of an object. 45 | func (k SchemaKind) IsBool() bool { 46 | switch k { 47 | case SchemaKindTrue, SchemaKindFalse: 48 | return true 49 | default: 50 | return false 51 | } 52 | } 53 | 54 | // String implements [fmt.Stringer]. 55 | func (k SchemaKind) String() string { 56 | switch k { 57 | case SchemaKindTrue: 58 | return "true" 59 | case SchemaKindFalse: 60 | return "false" 61 | case SchemaKindObject: 62 | return "object" 63 | default: 64 | return fmt.Sprintf("SchemaKind(%d)", k) 65 | } 66 | } 67 | 68 | // GoString implements [fmt.GoStringer], 69 | // and is used in debug output such as: 70 | // 71 | // fmt.Sprint("%#v", kind) 72 | func (k SchemaKind) GoString() string { 73 | return k.String() 74 | } 75 | 76 | type Schema struct { 77 | kind SchemaKind 78 | 79 | // Field ordering is taken from https://github.com/sourcemeta/core/blob/429eb970f3e303c3f61ba3cf066c7bd766453e15/src/core/jsonschema/jsonschema.cc#L459-L546 80 | Schema string `json:"$schema,omitempty" yaml:"$schema,omitempty"` 81 | ID string `json:"$id,omitempty" yaml:"$id,omitempty"` 82 | Title string `json:"title,omitempty" yaml:"title,omitempty"` 83 | Description string `json:"description,omitempty" yaml:"description,omitempty"` 84 | Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` 85 | ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` 86 | Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` 87 | Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` 88 | Type interface{} `json:"type,omitempty" yaml:"type,omitempty"` 89 | Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"` 90 | AllOf []*Schema `json:"allOf,omitempty" yaml:"allOf,omitempty"` 91 | AnyOf []*Schema `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` 92 | OneOf []*Schema `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` 93 | Not *Schema `json:"not,omitempty" yaml:"not,omitempty"` 94 | Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` 95 | Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` 96 | MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` 97 | Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` 98 | MaxLength *uint64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` 99 | MinLength *uint64 `json:"minLength,omitempty" yaml:"minLength,omitempty"` 100 | MaxItems *uint64 `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` 101 | MinItems *uint64 `json:"minItems,omitempty" yaml:"minItems,omitempty"` 102 | UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` 103 | Items *Schema `json:"items,omitempty" yaml:"items,omitempty"` 104 | AdditionalItems *Schema `json:"additionalItems,omitempty" yaml:"additionalItems,omitempty"` 105 | Required []string `json:"required,omitempty" yaml:"required,omitempty"` 106 | MaxProperties *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` 107 | MinProperties *uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` 108 | Properties map[string]*Schema `json:"properties,omitempty" yaml:"properties,omitempty"` 109 | PatternProperties map[string]*Schema `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` 110 | AdditionalProperties *Schema `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` 111 | UnevaluatedProperties *bool `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"` 112 | 113 | Defs map[string]*Schema `json:"$defs,omitempty" yaml:"$defs,omitempty"` 114 | // Deprecated: This field was renamed to "$defs" in draft 2019-09, 115 | // but the field is kept in this struct to allow bundled schemas to use them. 116 | Definitions map[string]*Schema `json:"definitions,omitempty" yaml:"definitions,omitempty"` 117 | 118 | SkipProperties bool `json:"-" yaml:"-"` 119 | Hidden bool `json:"-" yaml:"-"` 120 | } 121 | 122 | func (s *Schema) IsZero() bool { 123 | if s == nil { 124 | return true 125 | } 126 | switch { 127 | case s.kind != 0, 128 | len(s.Schema) > 0, 129 | len(s.ID) > 0, 130 | len(s.Title) > 0, 131 | len(s.Description) > 0, 132 | len(s.Comment) > 0, 133 | s.ReadOnly, 134 | s.Default != nil, 135 | len(s.Ref) > 0, 136 | s.Type != nil, 137 | len(s.Enum) > 0, 138 | len(s.AllOf) > 0, 139 | len(s.AnyOf) > 0, 140 | len(s.OneOf) > 0, 141 | s.Not != nil, 142 | s.Maximum != nil, 143 | s.Minimum != nil, 144 | s.MultipleOf != nil, 145 | len(s.Pattern) > 0, 146 | s.MaxLength != nil, 147 | s.MinLength != nil, 148 | s.MaxItems != nil, 149 | s.MinItems != nil, 150 | s.UniqueItems, 151 | s.Items != nil, 152 | s.AdditionalItems != nil, 153 | len(s.Required) > 0, 154 | s.MaxProperties != nil, 155 | s.MinProperties != nil, 156 | len(s.Properties) > 0, 157 | len(s.PatternProperties) > 0, 158 | s.AdditionalProperties != nil, 159 | s.UnevaluatedProperties != nil, 160 | len(s.Defs) > 0, 161 | len(s.Definitions) > 0: 162 | return false 163 | default: 164 | return true 165 | } 166 | } 167 | 168 | var ( 169 | _ json.Unmarshaler = &Schema{} 170 | _ json.Marshaler = &Schema{} 171 | _ yaml.Unmarshaler = &Schema{} 172 | _ yaml.Marshaler = &Schema{} 173 | ) 174 | 175 | // UnmarshalJSON implements [json.Unmarshaler]. 176 | func (s *Schema) UnmarshalJSON(data []byte) error { 177 | trimmed := bytes.TrimSpace(data) 178 | // checking length to not create too many intermediate strings 179 | if len(trimmed) <= 5 { 180 | switch string(trimmed) { 181 | case "true": 182 | s.SetKind(SchemaKindTrue) 183 | return nil 184 | case "false": 185 | s.SetKind(SchemaKindFalse) 186 | return nil 187 | } 188 | } 189 | 190 | // Unmarshal using a new type to not cause infinite recursion when unmarshalling 191 | type SchemaWithoutUnmarshaler Schema 192 | model := (*SchemaWithoutUnmarshaler)(s) 193 | return json.Unmarshal(data, model) 194 | } 195 | 196 | // MarshalJSON implements [json.Marshaler]. 197 | func (s *Schema) MarshalJSON() ([]byte, error) { 198 | switch s.Kind() { 199 | case SchemaKindTrue: 200 | return []byte("true"), nil 201 | case SchemaKindFalse: 202 | return []byte("false"), nil 203 | default: 204 | type SchemaWithoutMarshaler Schema 205 | return json.Marshal((*SchemaWithoutMarshaler)(s)) 206 | } 207 | } 208 | 209 | // UnmarshalYAML implements [yaml.Unmarshaler]. 210 | func (s *Schema) UnmarshalYAML(value *yaml.Node) error { 211 | if value.Kind == yaml.ScalarNode && value.ShortTag() == "!!bool" { 212 | var b bool 213 | if err := value.Decode(&b); err != nil { 214 | return err 215 | } 216 | if b { 217 | s.SetKind(SchemaKindTrue) 218 | } else { 219 | s.SetKind(SchemaKindFalse) 220 | } 221 | return nil 222 | } 223 | 224 | // Unmarshal using a new type to not cause infinite recursion when unmarshalling 225 | type SchemaWithoutUnmarshaler Schema 226 | model := (*SchemaWithoutUnmarshaler)(s) 227 | return value.Decode(model) 228 | } 229 | 230 | // MarshalYAML implements [yaml.Marshaler]. 231 | func (s *Schema) MarshalYAML() (interface{}, error) { 232 | switch s.Kind() { 233 | case SchemaKindTrue: 234 | return true, nil 235 | case SchemaKindFalse: 236 | return false, nil 237 | default: 238 | type SchemaWithoutMarshaler Schema 239 | return (*SchemaWithoutMarshaler)(s), nil 240 | } 241 | } 242 | 243 | func (s *Schema) Kind() SchemaKind { 244 | if s == nil { 245 | return SchemaKindObject 246 | } 247 | return s.kind 248 | } 249 | 250 | func (s *Schema) SetKind(kind SchemaKind) { 251 | if s == nil { 252 | panic(fmt.Errorf("Schema.SetKind(%#v): method reciever must not be nil", kind)) 253 | } 254 | switch kind { 255 | case SchemaKindTrue: 256 | *s = SchemaTrue // will implicitly reset all other fields to zero 257 | case SchemaKindFalse: 258 | *s = SchemaFalse // will implicitly reset all other fields to zero 259 | case SchemaKindObject: 260 | s.kind = SchemaKindObject 261 | default: 262 | panic(fmt.Errorf("Schema.SetKind(%#v): unexpected kind", kind)) 263 | } 264 | } 265 | 266 | func getYAMLKind(value string) string { 267 | if _, err := strconv.ParseInt(value, 10, 64); err == nil { 268 | return "integer" 269 | } 270 | if _, err := strconv.ParseFloat(value, 64); err == nil { 271 | return "number" 272 | } 273 | if _, err := strconv.ParseBool(value); err == nil { 274 | return "boolean" 275 | } 276 | if value != "" { 277 | return "string" 278 | } 279 | return "null" 280 | } 281 | 282 | func getSchemaURL(draft int) (string, error) { 283 | switch draft { 284 | case 4: 285 | return "http://json-schema.org/draft-04/schema#", nil 286 | case 6: 287 | return "http://json-schema.org/draft-06/schema#", nil 288 | case 7: 289 | return "http://json-schema.org/draft-07/schema#", nil 290 | case 2019: 291 | return "https://json-schema.org/draft/2019-09/schema", nil 292 | case 2020: 293 | return "https://json-schema.org/draft/2020-12/schema", nil 294 | default: 295 | return "", errors.New("invalid draft version. Please use one of: 4, 6, 7, 2019, 2020") 296 | } 297 | } 298 | 299 | func getComment(keyNode, valNode *yaml.Node) string { 300 | if valNode.LineComment != "" { 301 | return valNode.LineComment 302 | } 303 | if keyNode != nil { 304 | return keyNode.LineComment 305 | } 306 | return "" 307 | } 308 | 309 | func processList(comment string, stringsOnly bool) []interface{} { 310 | comment = strings.Trim(comment, "[]") 311 | items := strings.Split(comment, ",") 312 | 313 | var list []interface{} 314 | for _, item := range items { 315 | trimmedItem := strings.TrimSpace(item) 316 | if !stringsOnly && trimmedItem == "null" { 317 | list = append(list, nil) 318 | } else { 319 | trimmedItem = strings.Trim(trimmedItem, "\"") 320 | list = append(list, trimmedItem) 321 | } 322 | } 323 | return list 324 | } 325 | 326 | func processComment(schema *Schema, comment string) (isRequired, isHidden bool) { 327 | isRequired = false 328 | isHidden = false 329 | 330 | for key, value := range splitCommentByParts(comment) { 331 | switch key { 332 | case "enum": 333 | schema.Enum = processList(value, false) 334 | case "multipleOf": 335 | if v, err := strconv.ParseFloat(value, 64); err == nil { 336 | if v > 0 { 337 | schema.MultipleOf = &v 338 | } 339 | } 340 | case "maximum": 341 | if v, err := strconv.ParseFloat(value, 64); err == nil { 342 | schema.Maximum = &v 343 | } 344 | case "skipProperties": 345 | if v, err := strconv.ParseBool(value); err == nil && v { 346 | schema.SkipProperties = true 347 | } 348 | case "minimum": 349 | if v, err := strconv.ParseFloat(value, 64); err == nil { 350 | schema.Minimum = &v 351 | } 352 | case "maxLength": 353 | if v, err := strconv.ParseUint(value, 10, 64); err == nil { 354 | schema.MaxLength = &v 355 | } 356 | case "minLength": 357 | if v, err := strconv.ParseUint(value, 10, 64); err == nil { 358 | schema.MinLength = &v 359 | } 360 | case "pattern": 361 | schema.Pattern = value 362 | case "maxItems": 363 | if v, err := strconv.ParseUint(value, 10, 64); err == nil { 364 | schema.MaxItems = &v 365 | } 366 | case "minItems": 367 | if v, err := strconv.ParseUint(value, 10, 64); err == nil { 368 | schema.MinItems = &v 369 | } 370 | case "uniqueItems": 371 | if v, err := strconv.ParseBool(value); err == nil { 372 | schema.UniqueItems = v 373 | } 374 | case "maxProperties": 375 | if v, err := strconv.ParseUint(value, 10, 64); err == nil { 376 | schema.MaxProperties = &v 377 | } 378 | case "minProperties": 379 | if v, err := strconv.ParseUint(value, 10, 64); err == nil { 380 | schema.MinProperties = &v 381 | } 382 | case "patternProperties": 383 | var jsonObject map[string]*Schema 384 | if err := json.Unmarshal([]byte(value), &jsonObject); err == nil { 385 | schema.PatternProperties = jsonObject 386 | } 387 | case "required": 388 | if strings.TrimSpace(value) == "true" { 389 | isRequired = true 390 | } 391 | case "type": 392 | schema.Type = processList(value, true) 393 | case "title": 394 | schema.Title = value 395 | case "description": 396 | schema.Description = value 397 | case "readOnly": 398 | if v, err := strconv.ParseBool(value); err == nil { 399 | schema.ReadOnly = v 400 | } 401 | case "default": 402 | var jsonObject interface{} 403 | if err := json.Unmarshal([]byte(value), &jsonObject); err == nil { 404 | schema.Default = jsonObject 405 | } 406 | case "item": 407 | schema.Items = &Schema{ 408 | Type: value, 409 | } 410 | case "itemProperties": 411 | if schema.Items.Type == "object" { 412 | var itemProps map[string]*Schema 413 | if err := json.Unmarshal([]byte(value), &itemProps); err == nil { 414 | schema.Items.Properties = itemProps 415 | } 416 | } 417 | case "itemEnum": 418 | if schema.Items == nil { 419 | schema.Items = &Schema{} 420 | } 421 | schema.Items.Enum = processList(value, false) 422 | case "additionalProperties": 423 | if v, err := strconv.ParseBool(value); err == nil { 424 | if v { 425 | schema.AdditionalProperties = &SchemaTrue 426 | } else { 427 | schema.AdditionalProperties = &SchemaFalse 428 | } 429 | } 430 | case "unevaluatedProperties": 431 | if v, err := strconv.ParseBool(value); err == nil { 432 | schema.UnevaluatedProperties = &v 433 | } 434 | case "$id": 435 | schema.ID = value 436 | case "$ref": 437 | schema.Ref = value 438 | case "hidden": 439 | if v, err := strconv.ParseBool(value); err == nil && v { 440 | isHidden = true 441 | } 442 | case "allOf": 443 | var jsonObject []*Schema 444 | if err := json.Unmarshal([]byte(value), &jsonObject); err == nil { 445 | schema.AllOf = jsonObject 446 | } 447 | case "anyOf": 448 | var jsonObject []*Schema 449 | if err := json.Unmarshal([]byte(value), &jsonObject); err == nil { 450 | schema.AnyOf = jsonObject 451 | } 452 | case "oneOf": 453 | var jsonObject []*Schema 454 | if err := json.Unmarshal([]byte(value), &jsonObject); err == nil { 455 | schema.OneOf = jsonObject 456 | } 457 | case "not": 458 | var jsonObject *Schema 459 | if err := json.Unmarshal([]byte(value), &jsonObject); err == nil { 460 | schema.Not = jsonObject 461 | } 462 | } 463 | } 464 | 465 | return isRequired, isHidden 466 | } 467 | 468 | func splitCommentByParts(comment string) iter.Seq2[string, string] { 469 | return func(yield func(string, string) bool) { 470 | withoutPound := strings.TrimSpace(strings.TrimPrefix(comment, "#")) 471 | withoutSchema, ok := strings.CutPrefix(withoutPound, "@schema") 472 | if !ok { 473 | return 474 | } 475 | trimmed := strings.TrimSpace(withoutSchema) 476 | if len(trimmed) == len(withoutSchema) { 477 | // this checks if we had "# @schemafoo" instead of "# @schema foo" 478 | // which works as we trimmed space before. 479 | // So the check is if len("foo") == len(" foo") 480 | return 481 | } 482 | 483 | parts := strings.Split(trimmed, ";") 484 | for _, part := range parts { 485 | key, value, _ := strings.Cut(part, ":") 486 | key = strings.TrimSpace(key) 487 | value = strings.TrimSpace(value) 488 | 489 | if !yield(key, value) { 490 | return 491 | } 492 | } 493 | } 494 | } 495 | 496 | func parseNode(keyNode, valNode *yaml.Node) (*Schema, bool) { 497 | schema := &Schema{} 498 | 499 | switch valNode.Kind { 500 | case yaml.MappingNode: 501 | properties := make(map[string]*Schema) 502 | required := []string{} 503 | for i := 0; i < len(valNode.Content); i += 2 { 504 | childKeyNode := valNode.Content[i] 505 | childValNode := valNode.Content[i+1] 506 | childSchema, childRequired := parseNode(childKeyNode, childValNode) 507 | 508 | // Exclude hidden child schemas 509 | if childSchema != nil && !childSchema.Hidden { 510 | if childSchema.SkipProperties && childSchema.Type == "object" { 511 | childSchema.Properties = nil 512 | } 513 | properties[childKeyNode.Value] = childSchema 514 | if childRequired { 515 | required = append(required, childKeyNode.Value) 516 | } 517 | } 518 | } 519 | 520 | schema.Type = "object" 521 | schema.Properties = properties 522 | 523 | if len(required) > 0 { 524 | schema.Required = required 525 | } 526 | 527 | case yaml.SequenceNode: 528 | schema.Type = "array" 529 | 530 | mergedItemSchema := &Schema{} 531 | hasItems := false 532 | 533 | for _, itemNode := range valNode.Content { 534 | itemSchema, _ := parseNode(nil, itemNode) 535 | if itemSchema != nil && !itemSchema.Hidden { 536 | mergedItemSchema = mergeSchemas(mergedItemSchema, itemSchema) 537 | hasItems = true 538 | } 539 | } 540 | 541 | if hasItems { 542 | schema.Items = mergedItemSchema 543 | } 544 | 545 | case yaml.ScalarNode: 546 | if valNode.Style == yaml.DoubleQuotedStyle || valNode.Style == yaml.SingleQuotedStyle { 547 | schema.Type = "string" 548 | } else { 549 | schema.Type = getYAMLKind(valNode.Value) 550 | } 551 | } 552 | 553 | propIsRequired, isHidden := processComment(schema, getComment(keyNode, valNode)) 554 | if isHidden { 555 | return nil, false 556 | } 557 | 558 | if schema.SkipProperties && schema.Type == "object" { 559 | schema.Properties = nil 560 | } 561 | 562 | return schema, propIsRequired 563 | } 564 | 565 | func (schema *Schema) Subschemas() iter.Seq2[Ptr, *Schema] { 566 | return func(yield func(Ptr, *Schema) bool) { 567 | for key, subSchema := range iterMapOrdered(schema.Properties) { 568 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("properties", key), subSchema) { 569 | return 570 | } 571 | } 572 | if schema.AdditionalProperties != nil && schema.AdditionalProperties.Kind() == SchemaKindObject { 573 | if !yield(NewPtr("additionalProperties"), schema.AdditionalProperties) { 574 | return 575 | } 576 | } 577 | for key, subSchema := range iterMapOrdered(schema.PatternProperties) { 578 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("patternProperties", key), subSchema) { 579 | return 580 | } 581 | } 582 | if schema.Items != nil && schema.Items.Kind() == SchemaKindObject { 583 | if !yield(NewPtr("items"), schema.Items) { 584 | return 585 | } 586 | } 587 | if schema.AdditionalItems != nil && schema.AdditionalItems.Kind() == SchemaKindObject { 588 | if !yield(NewPtr("additionalItems"), schema.AdditionalItems) { 589 | return 590 | } 591 | } 592 | for key, subSchema := range iterMapOrdered(schema.Defs) { 593 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("$defs", key), subSchema) { 594 | return 595 | } 596 | } 597 | for key, subSchema := range iterMapOrdered(schema.Definitions) { 598 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("definitions", key), subSchema) { 599 | return 600 | } 601 | } 602 | for index, subSchema := range schema.AllOf { 603 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("allOf").Item(index), subSchema) { 604 | return 605 | } 606 | } 607 | for index, subSchema := range schema.AnyOf { 608 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("anyOf").Item(index), subSchema) { 609 | return 610 | } 611 | } 612 | for index, subSchema := range schema.OneOf { 613 | if subSchema.Kind() == SchemaKindObject && !yield(NewPtr("anyOf").Item(index), subSchema) { 614 | return 615 | } 616 | } 617 | if schema.Not != nil { 618 | if schema.Not.Kind() == SchemaKindObject && !yield(NewPtr("not"), schema.Not) { 619 | return 620 | } 621 | } 622 | } 623 | } 624 | 625 | func iterMapOrdered[K cmp.Ordered, V any](m map[K]V) iter.Seq2[K, V] { 626 | return func(yield func(K, V) bool) { 627 | for _, k := range slices.Sorted(maps.Keys(m)) { 628 | if !yield(k, m[k]) { 629 | return 630 | } 631 | } 632 | } 633 | } 634 | -------------------------------------------------------------------------------- /pkg/utils.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | // SchemaRoot struct defines root object of schema 11 | type SchemaRoot struct { 12 | ID string `yaml:"id"` 13 | Ref string `yaml:"ref"` 14 | Title string `yaml:"title"` 15 | Description string `yaml:"description"` 16 | AdditionalProperties BoolFlag `yaml:"additionalProperties"` 17 | } 18 | 19 | // Save values of parsed flags in Config 20 | type Config struct { 21 | Input multiStringFlag `yaml:"input"` 22 | OutputPath string `yaml:"output"` 23 | Draft int `yaml:"draft"` 24 | Indent int `yaml:"indent"` 25 | NoAdditionalProperties BoolFlag `yaml:"noAdditionalProperties"` 26 | Bundle BoolFlag `yaml:"bundle"` 27 | BundleRoot string `yaml:"bundleRoot"` 28 | BundleWithoutID BoolFlag `yaml:"bundleWithoutID"` 29 | 30 | K8sSchemaURL string `yaml:"k8sSchemaURL"` 31 | K8sSchemaVersion string `yaml:"k8sSchemaVersion"` 32 | 33 | SchemaRoot SchemaRoot `yaml:"schemaRoot"` 34 | 35 | Args []string `yaml:"-"` 36 | 37 | OutputPathSet bool `yaml:"-"` 38 | DraftSet bool `yaml:"-"` 39 | IndentSet bool `yaml:"-"` 40 | K8sSchemaURLSet bool `yaml:"-"` 41 | } 42 | 43 | // Define a custom flag type to accept multiple yaml files 44 | type multiStringFlag []string 45 | 46 | func (m *multiStringFlag) String() string { 47 | return strings.Join(*m, ", ") 48 | } 49 | 50 | func (m *multiStringFlag) Set(value string) error { 51 | values := strings.SplitSeq(value, ",") 52 | for v := range values { 53 | *m = append(*m, v) 54 | } 55 | return nil 56 | } 57 | 58 | // Custom BoolFlag type that tracks if it was explicitly set 59 | type BoolFlag struct { 60 | set bool 61 | value bool 62 | } 63 | 64 | func (b *BoolFlag) String() string { 65 | if b.set { 66 | return fmt.Sprintf("%t", b.value) 67 | } 68 | return "not set" 69 | } 70 | 71 | func (b *BoolFlag) Set(value string) error { 72 | switch value { 73 | case "true": 74 | b.value = true 75 | case "false": 76 | b.value = false 77 | default: 78 | return errors.New("invalid boolean value") 79 | } 80 | b.set = true 81 | return nil 82 | } 83 | 84 | func (b *BoolFlag) IsSet() bool { 85 | return b.set 86 | } 87 | 88 | func (b *BoolFlag) Value() bool { 89 | return b.value 90 | } 91 | 92 | func (b *BoolFlag) UnmarshalYAML(unmarshal func(interface{}) error) error { 93 | var boolValue bool 94 | if err := unmarshal(&boolValue); err != nil { 95 | return err 96 | } 97 | b.value = boolValue 98 | b.set = true 99 | return nil 100 | } 101 | 102 | func uniqueStringAppend(dest []string, src ...string) []string { 103 | existingItems := make(map[string]bool) 104 | for _, item := range dest { 105 | existingItems[item] = true 106 | } 107 | 108 | for _, item := range src { 109 | if _, exists := existingItems[item]; !exists { 110 | dest = append(dest, item) 111 | existingItems[item] = true 112 | } 113 | } 114 | 115 | return dest 116 | } 117 | 118 | func closeIgnoreError(closer io.Closer) { 119 | _ = closer.Close() 120 | } 121 | -------------------------------------------------------------------------------- /pkg/utils_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func TestMultiStringFlagString(t *testing.T) { 13 | tests := []struct { 14 | input multiStringFlag 15 | expected string 16 | }{ 17 | { 18 | input: multiStringFlag{}, 19 | expected: "", 20 | }, 21 | { 22 | input: multiStringFlag{"value1"}, 23 | expected: "value1", 24 | }, 25 | { 26 | input: multiStringFlag{"value1", "value2", "value3"}, 27 | expected: "value1, value2, value3", 28 | }, 29 | } 30 | 31 | for i, test := range tests { 32 | result := test.input.String() 33 | if result != test.expected { 34 | t.Errorf("Test case %d: Expected %q, but got %q", i+1, test.expected, result) 35 | } 36 | } 37 | } 38 | 39 | func TestMultiStringFlagSet(t *testing.T) { 40 | tests := []struct { 41 | input string 42 | initial multiStringFlag 43 | expected multiStringFlag 44 | errorFlag bool 45 | }{ 46 | { 47 | input: "value1,value2,value3", 48 | initial: multiStringFlag{}, 49 | expected: multiStringFlag{"value1", "value2", "value3"}, 50 | errorFlag: false, 51 | }, 52 | { 53 | input: "", 54 | initial: multiStringFlag{"existingValue"}, 55 | expected: multiStringFlag{"existingValue"}, 56 | errorFlag: false, 57 | }, 58 | { 59 | input: "value1, value2, value3", 60 | initial: multiStringFlag{}, 61 | expected: multiStringFlag{"value1", "value2", "value3"}, 62 | errorFlag: false, 63 | }, 64 | } 65 | 66 | for i, test := range tests { 67 | err := test.initial.Set(test.input) 68 | if err != nil && !test.errorFlag { 69 | t.Errorf("Test case %d: Expected no error, but got: %v", i+1, err) 70 | } else if err == nil && test.errorFlag { 71 | t.Errorf("Test case %d: Expected an error, but got none", i+1) 72 | } 73 | } 74 | } 75 | 76 | func TestUniqueStringAppend(t *testing.T) { 77 | tests := []struct { 78 | name string 79 | dest []string 80 | src []string 81 | want []string 82 | }{ 83 | { 84 | name: "empty slices", 85 | dest: []string{}, 86 | src: []string{}, 87 | want: []string{}, 88 | }, 89 | { 90 | name: "unique items", 91 | dest: []string{"a", "b"}, 92 | src: []string{"c", "d"}, 93 | want: []string{"a", "b", "c", "d"}, 94 | }, 95 | { 96 | name: "duplicate items", 97 | dest: []string{"a", "b"}, 98 | src: []string{"b", "c"}, 99 | want: []string{"a", "b", "c"}, 100 | }, 101 | { 102 | name: "all duplicate items", 103 | dest: []string{"a", "b"}, 104 | src: []string{"a", "b"}, 105 | want: []string{"a", "b"}, 106 | }, 107 | { 108 | name: "src only has duplicates", 109 | dest: []string{"c", "d"}, 110 | src: []string{"c", "c", "d", "d"}, 111 | want: []string{"c", "d"}, 112 | }, 113 | { 114 | name: "empty dest slice", 115 | dest: []string{}, 116 | src: []string{"a", "b"}, 117 | want: []string{"a", "b"}, 118 | }, 119 | { 120 | name: "empty src slice", 121 | dest: []string{"a", "b"}, 122 | src: []string{}, 123 | want: []string{"a", "b"}, 124 | }, 125 | } 126 | 127 | for _, tt := range tests { 128 | t.Run(tt.name, func(t *testing.T) { 129 | // Make a copy of the 'dest' slice to preserve the original. 130 | destCopy := make([]string, len(tt.dest)) 131 | copy(destCopy, tt.dest) 132 | 133 | // Call the function with the copy. 134 | got := uniqueStringAppend(destCopy, tt.src...) 135 | 136 | if !reflect.DeepEqual(got, tt.want) { 137 | t.Errorf("uniqueStringAppend() = %v, want %v", got, tt.want) 138 | } 139 | 140 | // Verify that the original 'dest' slice is not modified. 141 | if !reflect.DeepEqual(tt.dest, destCopy) { 142 | t.Errorf("uniqueStringAppend() unexpectedly modified the original dest slice") 143 | } 144 | }) 145 | } 146 | } 147 | 148 | func TestBoolFlag_String(t *testing.T) { 149 | tests := []struct { 150 | name string 151 | set bool 152 | value bool 153 | expected string 154 | }{ 155 | {"Unset flag", false, false, "not set"}, 156 | {"Set flag to true", true, true, "true"}, 157 | {"Set flag to false", true, false, "false"}, 158 | } 159 | 160 | for _, tt := range tests { 161 | t.Run(tt.name, func(t *testing.T) { 162 | b := &BoolFlag{ 163 | set: tt.set, 164 | value: tt.value, 165 | } 166 | if got := b.String(); got != tt.expected { 167 | t.Errorf("BoolFlag.String() = %v, want %v", got, tt.expected) 168 | } 169 | }) 170 | } 171 | } 172 | 173 | func TestBoolFlag_Set(t *testing.T) { 174 | tests := []struct { 175 | name string 176 | input string 177 | expectedSet bool 178 | expectedValue bool 179 | expectedErr error 180 | }{ 181 | {"Set true", "true", true, true, nil}, 182 | {"Set false", "false", true, false, nil}, 183 | {"Set invalid", "invalid", false, false, errors.New("invalid boolean value")}, 184 | } 185 | 186 | for _, tt := range tests { 187 | t.Run(tt.name, func(t *testing.T) { 188 | b := &BoolFlag{} 189 | err := b.Set(tt.input) 190 | if (err != nil) != (tt.expectedErr != nil) { 191 | t.Errorf("BoolFlag.Set() error = %v, expectedErr %v", err, tt.expectedErr) 192 | return 193 | } 194 | if err != nil && err.Error() != tt.expectedErr.Error() { 195 | t.Errorf("BoolFlag.Set() error = %v, expectedErr %v", err, tt.expectedErr) 196 | } 197 | if b.set != tt.expectedSet { 198 | t.Errorf("BoolFlag.set = %v, expected %v", b.set, tt.expectedSet) 199 | } 200 | if b.value != tt.expectedValue { 201 | t.Errorf("BoolFlag.value = %v, expected %v", b.value, tt.expectedValue) 202 | } 203 | }) 204 | } 205 | } 206 | 207 | func TestBoolFlag_IsSet(t *testing.T) { 208 | tests := []struct { 209 | name string 210 | set bool 211 | expected bool 212 | }{ 213 | {"Flag is not set", false, false}, 214 | {"Flag is set", true, true}, 215 | } 216 | 217 | for _, tt := range tests { 218 | t.Run(tt.name, func(t *testing.T) { 219 | b := &BoolFlag{ 220 | set: tt.set, 221 | } 222 | if got := b.IsSet(); got != tt.expected { 223 | t.Errorf("BoolFlag.IsSet() = %v, want %v", got, tt.expected) 224 | } 225 | }) 226 | } 227 | } 228 | 229 | func TestBoolFlag_GetValue(t *testing.T) { 230 | tests := []struct { 231 | name string 232 | value bool 233 | expected bool 234 | }{ 235 | {"Flag value is false", false, false}, 236 | {"Flag value is true", true, true}, 237 | } 238 | 239 | for _, tt := range tests { 240 | t.Run(tt.name, func(t *testing.T) { 241 | b := &BoolFlag{ 242 | value: tt.value, 243 | } 244 | if got := b.Value(); got != tt.expected { 245 | t.Errorf("BoolFlag.GetValue() = %v, want %v", got, tt.expected) 246 | } 247 | }) 248 | } 249 | } 250 | 251 | func TestBoolFlag_UnmarshalYAML(t *testing.T) { 252 | tests := []struct { 253 | name string 254 | yamlData string 255 | expectedValue bool 256 | expectedSet bool 257 | expectedErr string 258 | }{ 259 | { 260 | name: "Unmarshal true", 261 | yamlData: "true", 262 | expectedValue: true, 263 | expectedSet: true, 264 | expectedErr: "", 265 | }, 266 | { 267 | name: "Unmarshal false", 268 | yamlData: "false", 269 | expectedValue: false, 270 | expectedSet: true, 271 | expectedErr: "", 272 | }, 273 | { 274 | name: "Unmarshal invalid", 275 | yamlData: "invalid", 276 | expectedValue: false, 277 | expectedSet: false, 278 | expectedErr: "cannot unmarshal !!str", 279 | }, 280 | } 281 | 282 | for _, tt := range tests { 283 | t.Run(tt.name, func(t *testing.T) { 284 | var b BoolFlag 285 | 286 | err := yaml.Unmarshal([]byte(tt.yamlData), &b) 287 | 288 | if tt.expectedErr == "" && err != nil { 289 | t.Errorf("BoolFlag.UnmarshalYAML() unexpected error: %v", err) 290 | return 291 | } 292 | if tt.expectedErr != "" && !strings.Contains(err.Error(), tt.expectedErr) { 293 | t.Errorf("BoolFlag.UnmarshalYAML() error = %v, expected to contain %q", err, tt.expectedErr) 294 | } 295 | if b.value != tt.expectedValue { 296 | t.Errorf("BoolFlag.UnmarshalYAML() value = %v, expected %v", b.value, tt.expectedValue) 297 | } 298 | if b.set != tt.expectedSet { 299 | t.Errorf("BoolFlag.UnmarshalYAML() set = %v, expected %v", b.set, tt.expectedSet) 300 | } 301 | }) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /plugin.complete: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | "$HELM_PLUGIN_DIR/schema" __complete -- "$@" 4 | -------------------------------------------------------------------------------- /plugin.yaml: -------------------------------------------------------------------------------- 1 | name: "schema" 2 | version: "1.8.0" 3 | usage: "generate values.schema.json from values yaml" 4 | description: "Helm plugin for generating values.schema.json from multiple values files." 5 | ignoreFlags: false 6 | command: "$HELM_PLUGIN_DIR/schema" 7 | hooks: 8 | install: "cd $HELM_PLUGIN_DIR; ./scripts/install.sh" 9 | update: "cd $HELM_PLUGIN_DIR; HELM_PLUGIN_UPDATE=1 ./scripts/install.sh" 10 | -------------------------------------------------------------------------------- /scripts/git-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | if ! 'helm plugin list | grep -q "schema"' > /dev/null 2>&1; then 6 | echo "Please install helm-values-schema-json plugin! https://github.com/losisin/helm-values-schema-json#install" 7 | fi 8 | 9 | helm schema "${@}" 10 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # borrowed from https://github.com/technosophos/helm-template 4 | 5 | PROJECT_NAME="helm-values-schema-json" 6 | PROJECT_GH="losisin/$PROJECT_NAME" 7 | HELM_PLUGIN_PATH="$HELM_PLUGIN_DIR" 8 | 9 | # Convert the HELM_PLUGIN_PATH to unix if cygpath is 10 | # available. This is the case when using MSYS2 or Cygwin 11 | # on Windows where helm returns a Windows path but we 12 | # need a Unix path 13 | if type cygpath >/dev/null 2>&1; then 14 | echo Use Sygpath 15 | HELM_PLUGIN_PATH=$(cygpath -u "$HELM_PLUGIN_PATH") 16 | fi 17 | 18 | if [ "$SKIP_BIN_INSTALL" = "1" ]; then 19 | echo "Skipping binary install" 20 | exit 21 | fi 22 | 23 | # initArch discovers the architecture for this system. 24 | initArch() { 25 | ARCH=$(uname -m) 26 | case "$ARCH" in 27 | armv5*) ARCH="armv5";; 28 | armv6*) ARCH="armv6";; 29 | armv7*) ARCH="armv7";; 30 | aarch64) ARCH="arm64";; 31 | x86) ARCH="386";; 32 | x86_64) ARCH="amd64";; 33 | i686) ARCH="386";; 34 | i386) ARCH="386";; 35 | esac 36 | } 37 | 38 | # initOS discovers the operating system for this system. 39 | initOS() { 40 | OS=$(uname | tr '[:upper:]' '[:lower:]') 41 | 42 | case "$OS" in 43 | # Msys support 44 | msys*) OS='windows';; 45 | # Minimalist GNU for Windows 46 | mingw*) OS='windows';; 47 | darwin) OS='darwin';; 48 | esac 49 | } 50 | 51 | # verifySupported checks that the os/arch combination is supported for 52 | # binary builds. 53 | verifySupported() { 54 | supported="linux_arm64\nlinux_amd64\ndarwin_amd64\ndarwin_arm64\nwindows_amd64\nwindows_arm64" 55 | if ! echo "$supported" | grep -q "${OS}_${ARCH}"; then 56 | echo "No prebuild binary for ${OS}_${ARCH}." 57 | exit 1 58 | fi 59 | 60 | if ! type "curl" >/dev/null 2>&1 && ! type "wget" >/dev/null 2>&1; then 61 | echo "Either curl or wget is required" 62 | exit 1 63 | fi 64 | echo "Support ${OS}_${ARCH}" 65 | } 66 | 67 | # getDownloadURL checks the latest available version. 68 | getDownloadURL() { 69 | # Determine last tag based on VCS download 70 | version=$(git describe --tags --abbrev=0 >/dev/null 2>&1) 71 | # If no version found (because of no git), try fetch from plugin 72 | if [ -z "$version" ]; then 73 | echo "No version found" 74 | version=v$(sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' plugin.yaml) 75 | fi 76 | 77 | # Setup Download Url 78 | DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/download/${version}/${PROJECT_NAME}_${version#v}_${OS}_${ARCH}.tgz" 79 | } 80 | 81 | # downloadFile downloads the latest binary package and also the checksum 82 | # for that binary. 83 | downloadFile() { 84 | PLUGIN_TMP_FOLDER="/tmp/_dist/" 85 | [ -d "$PLUGIN_TMP_FOLDER" ] && rm -r "$PLUGIN_TMP_FOLDER" >/dev/null 86 | mkdir -p "$PLUGIN_TMP_FOLDER" 87 | echo "Downloading $DOWNLOAD_URL to location $PLUGIN_TMP_FOLDER" 88 | if type "curl" >/dev/null 2>&1; then 89 | (cd "$PLUGIN_TMP_FOLDER" && curl -LO "$DOWNLOAD_URL") 90 | elif type "wget" >/dev/null 2>&1; then 91 | wget -P "$PLUGIN_TMP_FOLDER" "$DOWNLOAD_URL" 92 | fi 93 | } 94 | 95 | # installFile unpacks and installs the file 96 | installFile() { 97 | cd "/tmp" 98 | DOWNLOAD_FILE=$(find ./_dist -name "*.tgz") 99 | HELM_TMP="/tmp/$PROJECT_NAME" 100 | mkdir -p "$HELM_TMP" 101 | tar xf "$DOWNLOAD_FILE" -C "$HELM_TMP" 102 | HELM_TMP_BIN="$HELM_TMP/schema" 103 | echo "Preparing to install into ${HELM_PLUGIN_PATH}" 104 | # Use * to also copy the file with the exe suffix on Windows 105 | cp "$HELM_TMP_BIN"* "$HELM_PLUGIN_PATH" 106 | rm -r "$HELM_TMP" 107 | rm -r "$PLUGIN_TMP_FOLDER" 108 | echo "$PROJECT_NAME installed into $HELM_PLUGIN_PATH" 109 | } 110 | 111 | # fail_trap is executed if an error occurs. 112 | fail_trap() { 113 | result=$? 114 | if [ "$result" != "0" ]; then 115 | echo "Failed to install $PROJECT_NAME" 116 | printf "\tFor support, go to https://github.com/%s.\n" "$PROJECT_GH" 117 | fi 118 | exit $result 119 | } 120 | 121 | # testVersion tests the installed client to make sure it is working. 122 | testVersion() { 123 | # To avoid to keep track of the Windows suffix, 124 | # call the plugin assuming it is in the PATH 125 | PATH=$PATH:$HELM_PLUGIN_PATH 126 | schema -help 127 | } 128 | 129 | # Execution 130 | 131 | #Stop execution on any error 132 | trap "fail_trap" EXIT 133 | set -e 134 | initArch 135 | initOS 136 | verifySupported 137 | getDownloadURL 138 | downloadFile 139 | installFile 140 | testVersion 141 | -------------------------------------------------------------------------------- /testdata/anchors.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "app": { 6 | "type": "object", 7 | "properties": { 8 | "settings": { 9 | "type": "object", 10 | "properties": { 11 | "namespace": { 12 | "type": "array", 13 | "items": { 14 | "type": [ 15 | "string" 16 | ] 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /testdata/anchors.yaml: -------------------------------------------------------------------------------- 1 | app: &app 2 | settings: 3 | namespace: 4 | - *app # @schema type:[string] 5 | -------------------------------------------------------------------------------- /testdata/basic.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "empty": { 6 | "type": "null" 7 | }, 8 | "fullnameOverride": { 9 | "type": "string" 10 | }, 11 | "image": { 12 | "type": "object", 13 | "properties": { 14 | "pullPolicy": { 15 | "type": "string" 16 | } 17 | } 18 | }, 19 | "imagePullSecrets": { 20 | "type": "array" 21 | }, 22 | "ingress": { 23 | "type": "object", 24 | "properties": { 25 | "enabled": { 26 | "type": "boolean" 27 | } 28 | } 29 | }, 30 | "nameOverride": { 31 | "type": "string" 32 | }, 33 | "replicas": { 34 | "type": "integer" 35 | }, 36 | "resources": { 37 | "type": "object" 38 | }, 39 | "tolerations": { 40 | "type": "array", 41 | "items": { 42 | "type": "object", 43 | "properties": { 44 | "effect": { 45 | "type": "string" 46 | }, 47 | "key": { 48 | "type": "string" 49 | }, 50 | "operator": { 51 | "type": "string" 52 | }, 53 | "value": { 54 | "type": "string" 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /testdata/basic.yaml: -------------------------------------------------------------------------------- 1 | replicas: 2 2 | 3 | nameOverride: "foo" 4 | 5 | image: 6 | pullPolicy: Always 7 | 8 | tolerations: 9 | - key: "foo" 10 | operator: "Equal" 11 | value: "bar" 12 | effect: "NoSchedule" 13 | 14 | empty: 15 | 16 | fullnameOverride: "" 17 | 18 | imagePullSecrets: [] 19 | 20 | resources: {} 21 | 22 | ingress: 23 | enabled: true 24 | -------------------------------------------------------------------------------- /testdata/bundle/dir1/namecollision-subschema.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "repository": { 8 | "type": "string" 9 | }, 10 | "pullPolicy": { 11 | "type": "string", 12 | "description": "This sets the pull policy for images.", 13 | "enum": ["IfNotPresent", "Always", "Never"] 14 | }, 15 | "tag": { 16 | "type": ["string", "null"], 17 | "description": "Overrides the image tag whose default is the chart appVersion." 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /testdata/bundle/dir2/namecollision-subschema.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "repository": { 8 | "type": "string" 9 | }, 10 | "pullPolicy": { 11 | "type": "string", 12 | "description": "This sets the pull policy for images.", 13 | "enum": ["IfNotPresent", "Always", "Never"] 14 | }, 15 | "tag": { 16 | "type": ["string", "null"], 17 | "description": "Overrides the image tag whose default is the chart appVersion." 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /testdata/bundle/fragment-without-id.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "image": { 6 | "type": "object", 7 | "properties": { 8 | "pullPolicy": { 9 | "$ref": "#/$defs/simple-subschema.schema.json/properties/pullPolicy", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | }, 15 | "$defs": { 16 | "simple-subschema.schema.json": { 17 | "$schema": "https://json-schema.org/draft/2020-12/schema", 18 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 19 | "type": "object", 20 | "properties": { 21 | "pullPolicy": { 22 | "description": "This sets the pull policy for images.", 23 | "type": "string", 24 | "enum": [ 25 | "IfNotPresent", 26 | "Always", 27 | "Never" 28 | ] 29 | }, 30 | "repository": { 31 | "type": "string" 32 | }, 33 | "tag": { 34 | "description": "Overrides the image tag whose default is the chart appVersion.", 35 | "type": [ 36 | "string", 37 | "null" 38 | ] 39 | } 40 | }, 41 | "additionalProperties": false 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /testdata/bundle/fragment.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "image": { 6 | "type": "object", 7 | "properties": { 8 | "pullPolicy": { 9 | "$ref": "./simple-subschema.schema.json#/properties/pullPolicy", 10 | "type": "string" 11 | } 12 | } 13 | } 14 | }, 15 | "$defs": { 16 | "simple-subschema.schema.json": { 17 | "$schema": "https://json-schema.org/draft/2020-12/schema", 18 | "$id": "./simple-subschema.schema.json", 19 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 20 | "type": "object", 21 | "properties": { 22 | "pullPolicy": { 23 | "description": "This sets the pull policy for images.", 24 | "type": "string", 25 | "enum": [ 26 | "IfNotPresent", 27 | "Always", 28 | "Never" 29 | ] 30 | }, 31 | "repository": { 32 | "type": "string" 33 | }, 34 | "tag": { 35 | "description": "Overrides the image tag whose default is the chart appVersion.", 36 | "type": [ 37 | "string", 38 | "null" 39 | ] 40 | } 41 | }, 42 | "additionalProperties": false 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /testdata/bundle/fragment.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | # This sets the pull policy for images. 3 | pullPolicy: IfNotPresent # @schema $ref: ./simple-subschema.schema.json#/properties/pullPolicy 4 | -------------------------------------------------------------------------------- /testdata/bundle/invalid-schema.json: -------------------------------------------------------------------------------- 1 | this is not JSON, intended to see that the tests fail as expected 2 | -------------------------------------------------------------------------------- /testdata/bundle/invalid-schema.yaml: -------------------------------------------------------------------------------- 1 | : this is not JSON, intended to see that the tests fail as expected: 2 | -------------------------------------------------------------------------------- /testdata/bundle/namecollision.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "dir1": { 6 | "$ref": "./dir1/namecollision-subschema.schema.json", 7 | "type": "object" 8 | }, 9 | "dir2": { 10 | "$ref": "./dir2/namecollision-subschema.schema.json", 11 | "type": "object" 12 | } 13 | }, 14 | "$defs": { 15 | "namecollision-subschema.schema.json": { 16 | "$schema": "https://json-schema.org/draft/2020-12/schema", 17 | "$id": "./dir1/namecollision-subschema.schema.json", 18 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 19 | "type": "object", 20 | "properties": { 21 | "pullPolicy": { 22 | "description": "This sets the pull policy for images.", 23 | "type": "string", 24 | "enum": [ 25 | "IfNotPresent", 26 | "Always", 27 | "Never" 28 | ] 29 | }, 30 | "repository": { 31 | "type": "string" 32 | }, 33 | "tag": { 34 | "description": "Overrides the image tag whose default is the chart appVersion.", 35 | "type": [ 36 | "string", 37 | "null" 38 | ] 39 | } 40 | }, 41 | "additionalProperties": false 42 | }, 43 | "namecollision-subschema.schema.json_2": { 44 | "$schema": "https://json-schema.org/draft/2020-12/schema", 45 | "$id": "./dir2/namecollision-subschema.schema.json", 46 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 47 | "type": "object", 48 | "properties": { 49 | "pullPolicy": { 50 | "description": "This sets the pull policy for images.", 51 | "type": "string", 52 | "enum": [ 53 | "IfNotPresent", 54 | "Always", 55 | "Never" 56 | ] 57 | }, 58 | "repository": { 59 | "type": "string" 60 | }, 61 | "tag": { 62 | "description": "Overrides the image tag whose default is the chart appVersion.", 63 | "type": [ 64 | "string", 65 | "null" 66 | ] 67 | } 68 | }, 69 | "additionalProperties": false 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /testdata/bundle/namecollision.yaml: -------------------------------------------------------------------------------- 1 | dir1: {} # @schema $ref: ./dir1/namecollision-subschema.schema.json 2 | dir2: {} # @schema $ref: ./dir2/namecollision-subschema.schema.json 3 | -------------------------------------------------------------------------------- /testdata/bundle/nested-subschema.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "$comment": "This subschema references some other schemas", 5 | "additionalProperties": false, 6 | "properties": { 7 | "image": { 8 | "$ref": "./simple-subschema.schema.json" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /testdata/bundle/nested-without-id.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "subchart": { 6 | "$ref": "#/$defs/nested-subschema.schema.json", 7 | "type": "object", 8 | "properties": { 9 | "replicas": { 10 | "type": "integer" 11 | } 12 | } 13 | } 14 | }, 15 | "$defs": { 16 | "nested-subschema.schema.json": { 17 | "$schema": "https://json-schema.org/draft/2020-12/schema", 18 | "$comment": "This subschema references some other schemas", 19 | "type": "object", 20 | "properties": { 21 | "image": { 22 | "$ref": "#/$defs/simple-subschema.schema.json" 23 | } 24 | }, 25 | "additionalProperties": false 26 | }, 27 | "simple-subschema.schema.json": { 28 | "$schema": "https://json-schema.org/draft/2020-12/schema", 29 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 30 | "type": "object", 31 | "properties": { 32 | "pullPolicy": { 33 | "description": "This sets the pull policy for images.", 34 | "type": "string", 35 | "enum": [ 36 | "IfNotPresent", 37 | "Always", 38 | "Never" 39 | ] 40 | }, 41 | "repository": { 42 | "type": "string" 43 | }, 44 | "tag": { 45 | "description": "Overrides the image tag whose default is the chart appVersion.", 46 | "type": [ 47 | "string", 48 | "null" 49 | ] 50 | } 51 | }, 52 | "additionalProperties": false 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /testdata/bundle/nested.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "subchart": { 6 | "$ref": "./nested-subschema.schema.json", 7 | "type": "object", 8 | "properties": { 9 | "replicas": { 10 | "type": "integer" 11 | } 12 | } 13 | } 14 | }, 15 | "$defs": { 16 | "nested-subschema.schema.json": { 17 | "$schema": "https://json-schema.org/draft/2020-12/schema", 18 | "$id": "./nested-subschema.schema.json", 19 | "$comment": "This subschema references some other schemas", 20 | "type": "object", 21 | "properties": { 22 | "image": { 23 | "$ref": "./simple-subschema.schema.json" 24 | } 25 | }, 26 | "additionalProperties": false 27 | }, 28 | "simple-subschema.schema.json": { 29 | "$schema": "https://json-schema.org/draft/2020-12/schema", 30 | "$id": "./simple-subschema.schema.json", 31 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 32 | "type": "object", 33 | "properties": { 34 | "pullPolicy": { 35 | "description": "This sets the pull policy for images.", 36 | "type": "string", 37 | "enum": [ 38 | "IfNotPresent", 39 | "Always", 40 | "Never" 41 | ] 42 | }, 43 | "repository": { 44 | "type": "string" 45 | }, 46 | "tag": { 47 | "description": "Overrides the image tag whose default is the chart appVersion.", 48 | "type": [ 49 | "string", 50 | "null" 51 | ] 52 | } 53 | }, 54 | "additionalProperties": false 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /testdata/bundle/nested.yaml: -------------------------------------------------------------------------------- 1 | subchart: # @schema $ref: ./nested-subschema.schema.json 2 | replicas: 1 3 | -------------------------------------------------------------------------------- /testdata/bundle/simple-disabled.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "image": { 6 | "$ref": "./simple-subschema.schema.json", 7 | "type": "object", 8 | "properties": { 9 | "pullPolicy": { 10 | "type": "string" 11 | }, 12 | "repository": { 13 | "type": "string" 14 | }, 15 | "tag": { 16 | "type": "string" 17 | } 18 | } 19 | }, 20 | "imageToo": { 21 | "$ref": "#/properties/image", 22 | "type": "object" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /testdata/bundle/simple-subschema.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "repository": { 8 | "type": "string" 9 | }, 10 | "pullPolicy": { 11 | "type": "string", 12 | "description": "This sets the pull policy for images.", 13 | "enum": ["IfNotPresent", "Always", "Never"] 14 | }, 15 | "tag": { 16 | "type": ["string", "null"], 17 | "description": "Overrides the image tag whose default is the chart appVersion." 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /testdata/bundle/simple-without-id.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "image": { 6 | "$ref": "#/$defs/simple-subschema.schema.json", 7 | "type": "object", 8 | "properties": { 9 | "pullPolicy": { 10 | "type": "string" 11 | }, 12 | "repository": { 13 | "type": "string" 14 | }, 15 | "tag": { 16 | "type": "string" 17 | } 18 | } 19 | }, 20 | "imageToo": { 21 | "$ref": "#/properties/image", 22 | "type": "object" 23 | } 24 | }, 25 | "$defs": { 26 | "simple-subschema.schema.json": { 27 | "$schema": "https://json-schema.org/draft/2020-12/schema", 28 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 29 | "type": "object", 30 | "properties": { 31 | "pullPolicy": { 32 | "description": "This sets the pull policy for images.", 33 | "type": "string", 34 | "enum": [ 35 | "IfNotPresent", 36 | "Always", 37 | "Never" 38 | ] 39 | }, 40 | "repository": { 41 | "type": "string" 42 | }, 43 | "tag": { 44 | "description": "Overrides the image tag whose default is the chart appVersion.", 45 | "type": [ 46 | "string", 47 | "null" 48 | ] 49 | } 50 | }, 51 | "additionalProperties": false 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /testdata/bundle/simple.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "image": { 6 | "$ref": "./simple-subschema.schema.json", 7 | "type": "object", 8 | "properties": { 9 | "pullPolicy": { 10 | "type": "string" 11 | }, 12 | "repository": { 13 | "type": "string" 14 | }, 15 | "tag": { 16 | "type": "string" 17 | } 18 | } 19 | }, 20 | "imageToo": { 21 | "$ref": "#/properties/image", 22 | "type": "object" 23 | } 24 | }, 25 | "$defs": { 26 | "simple-subschema.schema.json": { 27 | "$schema": "https://json-schema.org/draft/2020-12/schema", 28 | "$id": "./simple-subschema.schema.json", 29 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 30 | "type": "object", 31 | "properties": { 32 | "pullPolicy": { 33 | "description": "This sets the pull policy for images.", 34 | "type": "string", 35 | "enum": [ 36 | "IfNotPresent", 37 | "Always", 38 | "Never" 39 | ] 40 | }, 41 | "repository": { 42 | "type": "string" 43 | }, 44 | "tag": { 45 | "description": "Overrides the image tag whose default is the chart appVersion.", 46 | "type": [ 47 | "string", 48 | "null" 49 | ] 50 | } 51 | }, 52 | "additionalProperties": false 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /testdata/bundle/simple.yaml: -------------------------------------------------------------------------------- 1 | image: # @schema $ref: ./simple-subschema.schema.json 2 | repository: nginx 3 | # This sets the pull policy for images. 4 | pullPolicy: IfNotPresent 5 | # Overrides the image tag whose default is the chart appVersion. 6 | tag: "" 7 | 8 | imageToo: {} # @schema $ref: #/properties/image 9 | -------------------------------------------------------------------------------- /testdata/bundle/yaml-subschema.schema.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://json-schema.org/draft/2020-12/schema 2 | $comment: Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated 3 | type: object 4 | additionalProperties: false 5 | properties: 6 | repository: 7 | type: string 8 | pullPolicy: 9 | type: string 10 | description: This sets the pull policy for images. 11 | enum: 12 | - IfNotPresent 13 | - Always 14 | - Never 15 | tag: 16 | type: 17 | - string 18 | - "null" 19 | description: Overrides the image tag whose default is the chart appVersion. 20 | -------------------------------------------------------------------------------- /testdata/bundle/yaml.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "image": { 6 | "$ref": "./yaml-subschema.schema.yaml", 7 | "type": "object", 8 | "properties": { 9 | "pullPolicy": { 10 | "type": "string" 11 | }, 12 | "repository": { 13 | "type": "string" 14 | }, 15 | "tag": { 16 | "type": "string" 17 | } 18 | } 19 | } 20 | }, 21 | "$defs": { 22 | "yaml-subschema.schema.yaml": { 23 | "$schema": "https://json-schema.org/draft/2020-12/schema", 24 | "$id": "./yaml-subschema.schema.yaml", 25 | "$comment": "Sample schema referenced by other schemas. This file is meant to be manually created and not automatically generated", 26 | "type": "object", 27 | "properties": { 28 | "pullPolicy": { 29 | "description": "This sets the pull policy for images.", 30 | "type": "string", 31 | "enum": [ 32 | "IfNotPresent", 33 | "Always", 34 | "Never" 35 | ] 36 | }, 37 | "repository": { 38 | "type": "string" 39 | }, 40 | "tag": { 41 | "description": "Overrides the image tag whose default is the chart appVersion.", 42 | "type": [ 43 | "string", 44 | "null" 45 | ] 46 | } 47 | }, 48 | "additionalProperties": false 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /testdata/bundle/yaml.yaml: -------------------------------------------------------------------------------- 1 | image: # @schema $ref: ./yaml-subschema.schema.yaml 2 | repository: nginx 3 | # This sets the pull policy for images. 4 | pullPolicy: IfNotPresent 5 | # Overrides the image tag whose default is the chart appVersion. 6 | tag: "" 7 | -------------------------------------------------------------------------------- /testdata/doc.go: -------------------------------------------------------------------------------- 1 | // Package testdata contains files used by tests. 2 | // 3 | // To regenerate the test data output files, run the following: 4 | // 5 | // go generate ./testdata 6 | package testdata 7 | 8 | //go:generate go run .. --input anchors.yaml --output anchors.schema.json 9 | //go:generate go run .. --input basic.yaml --output basic.schema.json 10 | //go:generate go run .. --input full.yaml --output full.schema.json --schemaRoot.id https://example.com/schema --schemaRoot.ref schema/product.json --schemaRoot.title "Helm Values Schema" --schemaRoot.description "Schema for Helm values" --schemaRoot.additionalProperties=true 11 | //go:generate go run .. --input k8sRef.yaml --output k8sRef.schema.json --k8sSchemaVersion v1.33.1 12 | //go:generate go run .. --input meta.yaml --output meta.schema.json 13 | //go:generate go run .. --input noAdditionalProperties.yaml --output noAdditionalProperties.schema.json --noAdditionalProperties=true 14 | //go:generate go run .. --input ref.yaml --output ref-draft2020.schema.json --draft 2020 15 | //go:generate go run .. --input ref.yaml --output ref-draft7.schema.json --draft 7 16 | //go:generate go run .. --input subschema.yaml --output subschema.schema.json 17 | 18 | //go:generate go run .. --bundle=true --input bundle/fragment.yaml --output bundle/fragment.schema.json 19 | //go:generate go run .. --bundle=true --input bundle/fragment.yaml --output bundle/fragment-without-id.schema.json --bundleWithoutID=true 20 | //go:generate go run .. --bundle=true --input bundle/namecollision.yaml --output bundle/namecollision.schema.json 21 | //go:generate go run .. --bundle=true --input bundle/nested.yaml --output bundle/nested.schema.json 22 | //go:generate go run .. --bundle=true --input bundle/nested.yaml --output bundle/nested-without-id.schema.json --bundleWithoutID=true 23 | //go:generate go run .. --bundle=true --input bundle/simple.yaml --output bundle/simple.schema.json 24 | //go:generate go run .. --bundle=false --input bundle/simple.yaml --output bundle/simple-disabled.schema.json 25 | //go:generate go run .. --bundle=true --input bundle/simple.yaml --output bundle/simple-without-id.schema.json --bundleWithoutID=true 26 | //go:generate go run .. --bundle=true --input bundle/yaml.yaml --output bundle/yaml.schema.json 27 | -------------------------------------------------------------------------------- /testdata/empty.yaml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /testdata/fail: -------------------------------------------------------------------------------- 1 | { 2 | -------------------------------------------------------------------------------- /testdata/fail-type.yaml: -------------------------------------------------------------------------------- 1 | nameOverride: "" # @schema type:foobar 2 | -------------------------------------------------------------------------------- /testdata/full.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://example.com/schema", 4 | "title": "Helm Values Schema", 5 | "description": "Schema for Helm values", 6 | "$ref": "schema/product.json", 7 | "type": "object", 8 | "required": [ 9 | "nameOverride" 10 | ], 11 | "properties": { 12 | "affinity": { 13 | "type": "object", 14 | "properties": { 15 | "nodeAffinity": { 16 | "type": "object", 17 | "maxProperties": 2, 18 | "minProperties": 1, 19 | "properties": { 20 | "preferredDuringSchedulingIgnoredDuringExecution": { 21 | "type": "array", 22 | "items": { 23 | "type": "object", 24 | "properties": { 25 | "preference": { 26 | "$id": "https://example.com/schema.json", 27 | "type": "object", 28 | "properties": { 29 | "matchExpressions": { 30 | "type": "array", 31 | "items": { 32 | "type": "object", 33 | "properties": { 34 | "key": { 35 | "type": "string" 36 | }, 37 | "operator": { 38 | "type": "string" 39 | }, 40 | "values": { 41 | "type": "array", 42 | "items": { 43 | "type": "string" 44 | } 45 | } 46 | } 47 | } 48 | } 49 | }, 50 | "patternProperties": { 51 | "^[a-z]$": { 52 | "type": "string" 53 | } 54 | } 55 | }, 56 | "weight": { 57 | "type": "integer" 58 | } 59 | } 60 | } 61 | }, 62 | "requiredDuringSchedulingIgnoredDuringExecution": { 63 | "type": "object", 64 | "properties": { 65 | "nodeSelectorTerms": { 66 | "type": "array", 67 | "items": { 68 | "type": "object", 69 | "properties": { 70 | "matchExpressions": { 71 | "type": "array", 72 | "items": { 73 | "type": "object", 74 | "properties": { 75 | "key": { 76 | "type": "string" 77 | }, 78 | "operator": { 79 | "type": "string" 80 | }, 81 | "values": { 82 | "type": "array", 83 | "items": { 84 | "type": "string" 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | }, 96 | "additionalProperties": false 97 | } 98 | } 99 | }, 100 | "empty": { 101 | "type": [ 102 | "string", 103 | "null" 104 | ] 105 | }, 106 | "fullnameOverride": { 107 | "title": "My title", 108 | "description": "My description", 109 | "type": "string", 110 | "pattern": "^[a-z]$" 111 | }, 112 | "image": { 113 | "type": "object", 114 | "required": [ 115 | "repository" 116 | ], 117 | "properties": { 118 | "pullPolicy": { 119 | "type": "string" 120 | }, 121 | "repository": { 122 | "type": "string" 123 | }, 124 | "tag": { 125 | "readOnly": true, 126 | "type": "string" 127 | } 128 | } 129 | }, 130 | "imagePullSecrets": { 131 | "type": [ 132 | "array", 133 | "null" 134 | ], 135 | "items": { 136 | "type": "object", 137 | "properties": { 138 | "key": { 139 | "type": "string" 140 | } 141 | } 142 | } 143 | }, 144 | "labels": { 145 | "type": "object" 146 | }, 147 | "nameOverride": { 148 | "type": "string" 149 | }, 150 | "replicas": { 151 | "type": "integer", 152 | "maximum": 10, 153 | "minimum": 1, 154 | "multipleOf": 2 155 | }, 156 | "service": { 157 | "type": "string", 158 | "enum": [ 159 | "ClusterIP", 160 | "LoadBalancer", 161 | null 162 | ] 163 | }, 164 | "subchart": { 165 | "$ref": "https://example.com/schema.json", 166 | "type": "object", 167 | "properties": { 168 | "enabled": { 169 | "type": "boolean" 170 | }, 171 | "name": { 172 | "type": "string" 173 | }, 174 | "values": { 175 | "type": "object", 176 | "properties": { 177 | "bar": { 178 | "type": "string" 179 | }, 180 | "foo": { 181 | "type": "string" 182 | } 183 | } 184 | } 185 | }, 186 | "unevaluatedProperties": false 187 | }, 188 | "tolerations": { 189 | "type": "array", 190 | "maxItems": 10, 191 | "minItems": 1, 192 | "uniqueItems": true, 193 | "items": { 194 | "type": "object", 195 | "properties": { 196 | "effect": { 197 | "type": "string" 198 | }, 199 | "key": { 200 | "type": "string" 201 | }, 202 | "operator": { 203 | "type": "string" 204 | }, 205 | "value": { 206 | "type": "string" 207 | } 208 | } 209 | } 210 | } 211 | }, 212 | "additionalProperties": true 213 | } 214 | -------------------------------------------------------------------------------- /testdata/full.yaml: -------------------------------------------------------------------------------- 1 | # Required 2 | image: 3 | repository: nginx # @schema required: true 4 | tag: latest # @schema readOnly: true 5 | pullPolicy: Always 6 | nameOverride: foo # @schema required: true 7 | 8 | # Enum 9 | service: ClusterIP # @schema enum: [ClusterIP, LoadBalancer, null] 10 | 11 | # NULL 12 | empty: # @schema type: [string, null] 13 | 14 | # Numbers 15 | replicas: 2 # @schema minimum: 1 ; maximum: 10 ; multipleOf: 2 16 | 17 | # Strings 18 | fullnameOverride: bar # @schema pattern: ^[a-z]$ ; title: My title ; description: My description 19 | 20 | # Arrays 21 | imagePullSecrets: [] # @schema type:[array, null]; item: object ; itemProperties: {"key": {"type": "string"}} 22 | 23 | tolerations: # @schema minItems: 1 ; maxItems: 10 ; uniqueItems: true 24 | - key: "bar" 25 | operator: "Equal" 26 | value: "baz" 27 | effect: "NoSchedule" 28 | 29 | labels: # @schema skipProperties:true 30 | hello: world 31 | foo: bar 32 | 33 | # Objects 34 | affinity: 35 | nodeAffinity: # @schema minProperties: 1 ; maxProperties: 2 ; additionalProperties: false 36 | requiredDuringSchedulingIgnoredDuringExecution: 37 | nodeSelectorTerms: 38 | - matchExpressions: 39 | - key: topology.kubernetes.io/zone 40 | operator: In 41 | values: 42 | - antarctica-east1 43 | - antarctica-west1 44 | preferredDuringSchedulingIgnoredDuringExecution: 45 | - weight: 1 46 | preference: # @schema patternProperties: {"^[a-z]$": {"type": "string"}} ; $id: https://example.com/schema.json 47 | matchExpressions: 48 | - key: another-node-label-key 49 | operator: In 50 | values: 51 | - another-node-label-value 52 | 53 | subchart: # @schema $ref: https://example.com/schema.json ; unevaluatedProperties: false 54 | enabled: true 55 | name: subchart 56 | values: 57 | foo: bar 58 | bar: baz 59 | 60 | monitoring: # @schema hidden: true 61 | enabled: true 62 | -------------------------------------------------------------------------------- /testdata/k8sRef.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "memory": { 6 | "$ref": "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/refs/heads/master/v1.33.1/_definitions.json#/definitions/io.k8s.apimachinery.pkg.api.resource.Quantity", 7 | "type": "string" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /testdata/k8sRef.yaml: -------------------------------------------------------------------------------- 1 | memory: "1M" # @schema $ref: $k8s/_definitions.json#/definitions/io.k8s.apimachinery.pkg.api.resource.Quantity 2 | -------------------------------------------------------------------------------- /testdata/meta.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "fullnameOverride": { 6 | "title": "Full name override", 7 | "type": "string" 8 | }, 9 | "image": { 10 | "type": "object", 11 | "properties": { 12 | "repository": { 13 | "default": "nginx", 14 | "type": "string" 15 | }, 16 | "tag": { 17 | "readOnly": true, 18 | "type": "string" 19 | } 20 | } 21 | }, 22 | "metrics": { 23 | "type": "object", 24 | "properties": { 25 | "enabled": { 26 | "default": true, 27 | "type": "boolean" 28 | } 29 | } 30 | }, 31 | "nodeSelector": { 32 | "default": { 33 | "cloud.google.com/gke-nodepool": "e2-standard-8-spot" 34 | }, 35 | "type": "object" 36 | }, 37 | "replicas": { 38 | "default": 2, 39 | "type": "integer" 40 | }, 41 | "tolerations": { 42 | "default": [ 43 | { 44 | "effect": "NoSchedule", 45 | "key": "foo", 46 | "operator": "Equal", 47 | "value": "bar" 48 | } 49 | ], 50 | "type": "array" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /testdata/meta.yaml: -------------------------------------------------------------------------------- 1 | fullnameOverride: "" # @schema title: Full name override ; default: baz 2 | 3 | image: 4 | repository: "" # @schema default: "nginx" 5 | tag: latest # @schema readOnly: true 6 | 7 | metrics: 8 | enabled: false # @schema default: true 9 | 10 | replicas: 1 # @schema default: 2 11 | 12 | tolerations: [] # @schema default: [{"key":"foo","operator":"Equal","value":"bar","effect":"NoSchedule"}] 13 | 14 | nodeSelector: {} # @schema default: {"cloud.google.com/gke-nodepool": "e2-standard-8-spot"} 15 | -------------------------------------------------------------------------------- /testdata/noAdditionalProperties.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "object": { 6 | "type": "object", 7 | "additionalProperties": false 8 | }, 9 | "objectOfObjects": { 10 | "type": "object", 11 | "patternProperties": { 12 | "^.*$": { 13 | "type": "object", 14 | "additionalProperties": false 15 | } 16 | }, 17 | "additionalProperties": false 18 | }, 19 | "objectOfObjectsWithInnerAdditionalPropertiesAllowed": { 20 | "type": "object", 21 | "patternProperties": { 22 | "^.*$": { 23 | "type": "object", 24 | "additionalProperties": true 25 | } 26 | }, 27 | "additionalProperties": false 28 | }, 29 | "objectWithAdditionalPropertiesAllowed": { 30 | "type": "object", 31 | "additionalProperties": true 32 | } 33 | }, 34 | "additionalProperties": false 35 | } 36 | -------------------------------------------------------------------------------- /testdata/noAdditionalProperties.yaml: -------------------------------------------------------------------------------- 1 | object: {} 2 | objectWithAdditionalPropertiesAllowed: {} # @schema additionalProperties: true 3 | objectOfObjects: {} # @schema patternProperties: { "^.*$": {"type": "object" } } 4 | objectOfObjectsWithInnerAdditionalPropertiesAllowed: {} # @schema patternProperties: { "^.*$": {"type": "object", "additionalProperties": true }} 5 | -------------------------------------------------------------------------------- /testdata/ref-draft2020.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "resources": { 6 | "$ref": "foo.json", 7 | "type": "object", 8 | "properties": { 9 | "limits": { 10 | "type": "object" 11 | }, 12 | "requests": { 13 | "type": "object" 14 | } 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /testdata/ref-draft7.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "resources": { 6 | "allOf": [ 7 | { 8 | "$ref": "foo.json" 9 | }, 10 | { 11 | "type": "object", 12 | "properties": { 13 | "limits": { 14 | "type": "object" 15 | }, 16 | "requests": { 17 | "type": "object" 18 | } 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /testdata/ref.yaml: -------------------------------------------------------------------------------- 1 | # In draft 7 using "$ref" would make other settings ignored. 2 | # But running helm-values-schema-json with "--draft 7" will cause it 3 | # to put "$ref" inside "allOf" 4 | 5 | resources: # @schema $ref: foo.json 6 | limits: {} 7 | requests: {} 8 | -------------------------------------------------------------------------------- /testdata/subschema.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "MY_SECRET": { 6 | "type": "object", 7 | "properties": { 8 | "ref": { 9 | "type": "string" 10 | }, 11 | "version": { 12 | "oneOf": [ 13 | { 14 | "type": "string" 15 | }, 16 | { 17 | "type": "number" 18 | } 19 | ] 20 | } 21 | } 22 | }, 23 | "cluster": { 24 | "type": "object", 25 | "properties": { 26 | "enabled": { 27 | "type": "boolean" 28 | }, 29 | "nodes": { 30 | "anyOf": [ 31 | { 32 | "type": "number", 33 | "multipleOf": 3 34 | }, 35 | { 36 | "type": "number", 37 | "multipleOf": 5 38 | } 39 | ] 40 | } 41 | } 42 | }, 43 | "image": { 44 | "type": "object", 45 | "properties": { 46 | "digest": { 47 | "allOf": [ 48 | { 49 | "type": "string" 50 | }, 51 | { 52 | "minLength": 14 53 | } 54 | ] 55 | }, 56 | "repository": { 57 | "type": "string" 58 | }, 59 | "tag": { 60 | "not": { 61 | "type": "object" 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /testdata/subschema.yaml: -------------------------------------------------------------------------------- 1 | # Schema composition 2 | MY_SECRET: 3 | ref: "secret-reference-in-manager" 4 | version: # @schema oneOf: [{"type": "string"}, {"type": "number"}] 5 | 6 | image: 7 | repository: nginx 8 | tag: latest # @schema not: {"type": "object"} 9 | digest: sha256:1234567890 # @schema allOf: [{"type": "string"}, {"minLength": 14}] 10 | 11 | cluster: 12 | enabled: true 13 | nodes: 3 # @schema anyOf: [{"type": "number", "multipleOf": 3}, {"type": "number", "multipleOf": 5}] 14 | -------------------------------------------------------------------------------- /testdata/values.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "nodeSelector": { 6 | "type": "object", 7 | "properties": { 8 | "kubernetes.io/hostname": { 9 | "type": "string" 10 | } 11 | } 12 | } 13 | } 14 | } 15 | --------------------------------------------------------------------------------