diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 24c9d93..b4a27c0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,21 +16,29 @@ jobs: - name: acceptance-test run: make docker-acceptance - openapi2jsonschema-test: + openapi2jsonschema-go-test: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v4 - name: test - working-directory: ./scripts - run: make docker-test docker-acceptance + working-directory: ./openapi2jsonschema-go + run: make docker-test + + - name: build + working-directory: ./openapi2jsonschema-go + run: make docker-build-static + + - name: acceptance-test + working-directory: ./openapi2jsonschema-go + run: make docker-acceptance goreleaser: runs-on: ubuntu-latest needs: - kubeconform-test - - openapi2jsonschema-test + - openapi2jsonschema-go-test if: startsWith(github.ref, 'refs/tags/v') steps: - name: checkout diff --git a/.goreleaser.yml b/.goreleaser.yml index 3d08bcd..f692fcc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,6 +1,8 @@ project_name: kubeconform builds: - - main: ./cmd/kubeconform + - id: kubeconform + binary: kubeconform + main: ./cmd/kubeconform env: - CGO_ENABLED=0 - GOFLAGS = -mod=vendor @@ -11,7 +13,6 @@ builds: - linux - darwin goarch: - - 386 - amd64 - arm - arm64 @@ -22,9 +23,33 @@ builds: ldflags: - -extldflags "-static" - -X main.version={{.Tag}} + - id: openapi2jsonschema + binary: openapi2jsonschema + dir: ./openapi2jsonschema-go + main: . + env: + - CGO_ENABLED=0 + - GO111MODULE = on + - GIT_OWNER = yannh + goos: + - windows + - linux + - darwin + goarch: + - amd64 + - arm + - arm64 + flags: + - -trimpath + - -tags=netgo + - -a + ldflags: + - -extldflags "-static" archives: - format: tar.gz + builds: + - kubeconform format_overrides: - goos: windows format: zip diff --git a/Dockerfile b/Dockerfile index 527cdb6..791ee24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,4 +11,5 @@ LABEL org.opencontainers.image.authors="Yann Hamon " \ org.opencontainers.image.url="https://github.com/yannh/kubeconform/" COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY kubeconform / +COPY openapi2jsonschema / ENTRYPOINT ["/kubeconform"] diff --git a/Dockerfile-alpine b/Dockerfile-alpine index cb0dd89..f851884 100644 --- a/Dockerfile-alpine +++ b/Dockerfile-alpine @@ -9,4 +9,5 @@ LABEL org.opencontainers.image.authors="Yann Hamon " \ org.opencontainers.image.url="https://github.com/yannh/kubeconform/" RUN apk add ca-certificates COPY kubeconform / +COPY openapi2jsonschema / ENTRYPOINT ["/kubeconform"] diff --git a/Makefile b/Makefile index 14eb546..50418de 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ local-build: go build -o bin/ ./... local-build-static: - CGO_ENABLED=0 GOFLAGS=-mod=vendor GOOS=linux GOARCH=amd64 GO111MODULE=on go build -trimpath -tags=netgo -ldflags "-extldflags=\"-static\"" -a -o bin/ ./... + CGO_ENABLED=0 GOFLAGS=-mod=vendor GOOS=$(shell go env GOOS) GOARCH=$(shell go env GOARCH) GO111MODULE=on go build -trimpath -tags=netgo -ldflags "-extldflags=\"-static\"" -a -o bin/ ./... # These only used for development. Release artifacts and docker images are produced by goreleaser. docker-test: diff --git a/openapi2jsonschema-go/Dockerfile.bats b/openapi2jsonschema-go/Dockerfile.bats new file mode 100644 index 0000000..36e6df5 --- /dev/null +++ b/openapi2jsonschema-go/Dockerfile.bats @@ -0,0 +1,15 @@ +# Build context must be the repo root so that scripts/fixtures/ is reachable. +# See the Makefile in this folder. +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY openapi2jsonschema-go/go.mod openapi2jsonschema-go/go.sum ./ +RUN go mod download +COPY openapi2jsonschema-go/openapi2jsonschema.go openapi2jsonschema-go/openapi2jsonschema_test.go ./ +RUN go build -o /openapi2jsonschema . + +FROM alpine:3.20 +RUN apk --no-cache add bats diffutils +COPY --from=build /openapi2jsonschema /code/openapi2jsonschema-go/openapi2jsonschema +COPY scripts/fixtures /code/fixtures +COPY openapi2jsonschema-go/acceptance.bats /code/openapi2jsonschema-go/ +WORKDIR /code/openapi2jsonschema-go diff --git a/openapi2jsonschema-go/Makefile b/openapi2jsonschema-go/Makefile new file mode 100644 index 0000000..9980ffa --- /dev/null +++ b/openapi2jsonschema-go/Makefile @@ -0,0 +1,41 @@ +#!/usr/bin/make -f + +GO_VERSION ?= 1.24.3 + +.PHONY: test local-test local-build local-build-static docker-test docker-build docker-build-static build-bats docker-acceptance update-deps clean + +test: local-test docker-acceptance + +local-test: + go test -race ./... -count=1 + +local-build: + go build -o bin/openapi2jsonschema . + +local-build-static: + CGO_ENABLED=0 GOOS=$(shell go env GOOS) GOARCH=$(shell go env GOARCH) GO111MODULE=on go build -trimpath -buildvcs=false -tags=netgo -ldflags "-extldflags=\"-static\"" -a -o bin/openapi2jsonschema . + +# Mount the repo root so scripts/fixtures/ is reachable from the container. +docker-test: + docker run -t -v $$PWD/..:/src -w /src/openapi2jsonschema-go golang:$(GO_VERSION) make local-test + +docker-build: + docker run -t -v $$PWD/..:/src -w /src/openapi2jsonschema-go golang:$(GO_VERSION) make local-build + +docker-build-static: + docker run -t -v $$PWD/..:/src -w /src/openapi2jsonschema-go golang:$(GO_VERSION) make local-build-static + +# Build context is the repo root so the image can pull in scripts/fixtures/. +build-bats: + docker build -t openapi2jsonschema-go-bats -f Dockerfile.bats .. + +docker-acceptance: build-bats + docker run --entrypoint "/usr/bin/bats" -t openapi2jsonschema-go-bats /code/openapi2jsonschema-go/acceptance.bats + +update-deps: + go get -u ./... + go mod tidy + +clean: + rm -rf bin + rm -f openapi2jsonschema prometheus_v1.json prometheus-monitoring-v1.json diff --git a/openapi2jsonschema-go/acceptance.bats b/openapi2jsonschema-go/acceptance.bats new file mode 100644 index 0000000..4523ee6 --- /dev/null +++ b/openapi2jsonschema-go/acceptance.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats + +setup() { + rm -f prometheus_v1.json + rm -f prometheus-monitoring-v1.json +} + +@test "Should generate expected prometheus resource while disable ssl env var is set" { + run export DISABLE_SSL_CERT_VALIDATION=true + run ./openapi2jsonschema ../fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus_v1.json" ] + run diff prometheus_v1.json ../fixtures/prometheus_v1-expected.json + [ "$status" -eq 0 ] +} + +@test "Should generate expected prometheus resource from an HTTPS resource while disable ssl env var is set" { + run export DISABLE_SSL_CERT_VALIDATION=true + run ./openapi2jsonschema https://raw.githubusercontent.com/yannh/kubeconform/aebc298047c386116eeeda9b1ada83671a58aedd/scripts/fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus_v1.json" ] + run diff prometheus_v1.json ../fixtures/prometheus_v1-expected.json + [ "$status" -eq 0 ] +} + +@test "Should output filename in {kind}-{group}-{version} format while disable ssl env var is set" { + run export DISABLE_SSL_CERT_VALIDATION=true + FILENAME_FORMAT='{kind}-{group}-{version}' run ./openapi2jsonschema ../fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus-monitoring-v1.json" ] + run diff prometheus-monitoring-v1.json ../fixtures/prometheus_v1-expected.json + [ "$status" -eq 0 ] +} + +@test "Should set 'additionalProperties: false' at the root while disable ssl env var is set" { + run export DISABLE_SSL_CERT_VALIDATION=true + DENY_ROOT_ADDITIONAL_PROPERTIES='true' run ./openapi2jsonschema ../fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus_v1.json" ] + run diff prometheus_v1.json ../fixtures/prometheus_v1-denyRootAdditionalProperties.json + [ "$status" -eq 0 ] +} + +@test "Should generate expected prometheus resource" { + run ./openapi2jsonschema ../fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus_v1.json" ] + run diff prometheus_v1.json ../fixtures/prometheus_v1-expected.json + [ "$status" -eq 0 ] +} + +@test "Should generate expected prometheus resource from an HTTP resource" { + run ./openapi2jsonschema https://raw.githubusercontent.com/yannh/kubeconform/aebc298047c386116eeeda9b1ada83671a58aedd/scripts/fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus_v1.json" ] + run diff prometheus_v1.json ../fixtures/prometheus_v1-expected.json + [ "$status" -eq 0 ] +} + +@test "Should output filename in {kind}-{group}-{version} format" { + FILENAME_FORMAT='{kind}-{group}-{version}' run ./openapi2jsonschema ../fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus-monitoring-v1.json" ] + run diff prometheus-monitoring-v1.json ../fixtures/prometheus_v1-expected.json + [ "$status" -eq 0 ] +} + +@test "Should set 'additionalProperties: false' at the root" { + DENY_ROOT_ADDITIONAL_PROPERTIES='true' run ./openapi2jsonschema ../fixtures/prometheus-operator-0prometheusCustomResourceDefinition.yaml + [ "$status" -eq 0 ] + [ "$output" = "JSON schema written to prometheus_v1.json" ] + run diff prometheus_v1.json ../fixtures/prometheus_v1-denyRootAdditionalProperties.json + [ "$status" -eq 0 ] +} + +@test "Should output an error if no file is passed" { + run ./openapi2jsonschema + [ "$status" -eq 1 ] + [ "${lines[0]}" == 'Missing FILE parameter.' ] + [ "${lines[1]}" == 'Usage: ./openapi2jsonschema [FILE]' ] +} diff --git a/openapi2jsonschema-go/go.mod b/openapi2jsonschema-go/go.mod new file mode 100644 index 0000000..365d6e1 --- /dev/null +++ b/openapi2jsonschema-go/go.mod @@ -0,0 +1,5 @@ +module github.com/yannh/kubeconform/scripts/go + +go 1.24 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/openapi2jsonschema-go/go.sum b/openapi2jsonschema-go/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/openapi2jsonschema-go/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/openapi2jsonschema-go/openapi2jsonschema b/openapi2jsonschema-go/openapi2jsonschema new file mode 100755 index 0000000..6be8cf8 Binary files /dev/null and b/openapi2jsonschema-go/openapi2jsonschema differ diff --git a/openapi2jsonschema-go/openapi2jsonschema.go b/openapi2jsonschema-go/openapi2jsonschema.go new file mode 100644 index 0000000..d27ecdc --- /dev/null +++ b/openapi2jsonschema-go/openapi2jsonschema.go @@ -0,0 +1,405 @@ +// Derived from https://github.com/instrumenta/openapi2jsonschema +// Go port of openapi2jsonschema.py. +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// OrderedMap preserves key insertion order, matching Python dict semantics +// (insertion-ordered) used by PyYAML SafeLoader and json.dumps. +type OrderedMap struct { + keys []string + values map[string]any +} + +func NewOrderedMap() *OrderedMap { + return &OrderedMap{values: map[string]any{}} +} + +func (m *OrderedMap) Has(k string) bool { + _, ok := m.values[k] + return ok +} + +func (m *OrderedMap) Get(k string) (any, bool) { + v, ok := m.values[k] + return v, ok +} + +func (m *OrderedMap) Set(k string, v any) { + if _, ok := m.values[k]; !ok { + m.keys = append(m.keys, k) + } + m.values[k] = v +} + +func (m *OrderedMap) Keys() []string { return m.keys } + +func (m *OrderedMap) Len() int { return len(m.keys) } + +// MarshalJSON emits keys in insertion order with no HTML escaping, matching +// Python's json.dumps default. +func (m *OrderedMap) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range m.keys { + if i > 0 { + buf.WriteByte(',') + } + kb, err := encodeJSON(k) + if err != nil { + return nil, err + } + buf.Write(kb) + buf.WriteByte(':') + vb, err := encodeJSON(m.values[k]) + if err != nil { + return nil, err + } + buf.Write(vb) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func encodeJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + // Encoder always appends a trailing newline; strip it. + out := buf.Bytes() + if n := len(out); n > 0 && out[n-1] == '\n' { + out = out[:n-1] + } + return out, nil +} + +// yamlToData converts a yaml.Node into Go values made of *OrderedMap, []any, +// and primitive types, preserving mapping key order. +func yamlToData(n *yaml.Node) (any, error) { + switch n.Kind { + case yaml.DocumentNode: + if len(n.Content) == 0 { + return nil, nil + } + return yamlToData(n.Content[0]) + case yaml.MappingNode: + m := NewOrderedMap() + for i := 0; i < len(n.Content); i += 2 { + kNode := n.Content[i] + vNode := n.Content[i+1] + var key string + if err := kNode.Decode(&key); err != nil { + key = kNode.Value + } + v, err := yamlToData(vNode) + if err != nil { + return nil, err + } + m.Set(key, v) + } + return m, nil + case yaml.SequenceNode: + out := make([]any, 0, len(n.Content)) + for _, c := range n.Content { + v, err := yamlToData(c) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil + case yaml.ScalarNode: + // Decode preserves YAML scalar typing (bool, int, float, string, null). + var v any + if err := n.Decode(&v); err != nil { + return nil, err + } + return v, nil + case yaml.AliasNode: + return yamlToData(n.Alias) + } + return nil, nil +} + +// additionalProperties recreates the kubectl behaviour: any object with a +// "properties" key gets `additionalProperties: false` set (unless the caller +// asks to skip it for the root). +func additionalProperties(data any, skip bool) any { + m, ok := data.(*OrderedMap) + if !ok { + if list, ok := data.([]any); ok { + for i := range list { + list[i] = additionalProperties(list[i], false) + } + } + return data + } + if m.Has("properties") && !skip { + if !m.Has("additionalProperties") { + m.Set("additionalProperties", false) + } + } + for _, k := range m.Keys() { + v, _ := m.Get(k) + m.Set(k, additionalProperties(v, false)) + } + return m +} + +// replaceIntOrString replaces `format: int-or-string` with the canonical +// oneOf{string,integer} expansion. +func replaceIntOrString(data any) any { + m, ok := data.(*OrderedMap) + if !ok { + if list, ok := data.([]any); ok { + out := make([]any, len(list)) + for i, x := range list { + out[i] = replaceIntOrString(x) + } + return out + } + return data + } + out := NewOrderedMap() + for _, k := range m.Keys() { + v, _ := m.Get(k) + switch vv := v.(type) { + case *OrderedMap: + if f, ok := vv.Get("format"); ok { + if s, ok := f.(string); ok && s == "int-or-string" { + replacement := NewOrderedMap() + strType := NewOrderedMap() + strType.Set("type", "string") + intType := NewOrderedMap() + intType.Set("type", "integer") + replacement.Set("oneOf", []any{strType, intType}) + out.Set(k, replacement) + continue + } + } + out.Set(k, replaceIntOrString(vv)) + case []any: + rep := make([]any, len(vv)) + for i, x := range vv { + rep[i] = replaceIntOrString(x) + } + out.Set(k, rep) + default: + out.Set(k, v) + } + } + return out +} + +func writeSchemaFile(schema any, filename string, denyRootAdditionalProperties bool) error { + schema = additionalProperties(schema, !denyRootAdditionalProperties) + schema = replaceIntOrString(schema) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(schema); err != nil { + return err + } + // Encoder appends "\n"; Python's `print(s, file=f)` also appends one. + // json.dumps itself does not, so the encoder's newline matches print(). + + // Treat the input as a path component only — guard against directory + // traversal in user-supplied filenames. + filename = filepath.Base(filename) + if err := os.WriteFile(filename, buf.Bytes(), 0o644); err != nil { + return err + } + fmt.Printf("JSON schema written to %s\n", filename) + return nil +} + +func formatFilename(format, kind, group, fullgroup, version string) string { + r := strings.NewReplacer( + "{kind}", kind, + "{group}", group, + "{fullgroup}", fullgroup, + "{version}", version, + ) + return strings.ToLower(r.Replace(format)) + ".json" +} + +func openInput(path string) (io.ReadCloser, error) { + if strings.HasPrefix(path, "http") { + client := http.DefaultClient + if os.Getenv("DISABLE_SSL_CERT_VALIDATION") != "" { + client = &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }} + } + resp, err := client.Get(path) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + resp.Body.Close() + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, path) + } + return resp.Body, nil + } + return os.Open(path) +} + +// process consumes one CRD source (file or URL) and writes one JSON schema per +// version found, mirroring the original Python control flow. +func process(path string, filenameFormat string, denyRoot bool) error { + r, err := openInput(path) + if err != nil { + return err + } + defer r.Close() + + dec := yaml.NewDecoder(r) + var defs []*OrderedMap + for { + var node yaml.Node + if err := dec.Decode(&node); err != nil { + if err == io.EOF { + break + } + return err + } + v, err := yamlToData(&node) + if err != nil { + return err + } + m, ok := v.(*OrderedMap) + if !ok || m == nil { + continue + } + if items, ok := m.Get("items"); ok { + if list, ok := items.([]any); ok { + for _, it := range list { + if im, ok := it.(*OrderedMap); ok { + defs = append(defs, im) + } + } + } + } + kind, ok := m.Get("kind") + if !ok { + continue + } + if ks, _ := kind.(string); ks != "CustomResourceDefinition" { + continue + } + defs = append(defs, m) + } + + for _, y := range defs { + spec, ok := getMap(y, "spec") + if !ok { + continue + } + names, _ := getMap(spec, "names") + kind, _ := getString(names, "kind") + fullgroup, _ := getString(spec, "group") + group := strings.SplitN(fullgroup, ".", 2)[0] + + versions, hasVersions := spec.Get("versions") + versionList, _ := versions.([]any) + + if hasVersions && len(versionList) > 0 { + for _, vAny := range versionList { + vMap, ok := vAny.(*OrderedMap) + if !ok { + continue + } + vName, _ := getString(vMap, "name") + schemaMap, hasSchema := getMap(vMap, "schema") + if hasSchema { + if root, ok := schemaMap.Get("openAPIV3Schema"); ok { + filename := formatFilename(filenameFormat, kind, group, fullgroup, vName) + if err := writeSchemaFile(root, filename, denyRoot); err != nil { + return err + } + continue + } + } + validation, hasValidation := getMap(spec, "validation") + if hasValidation { + if root, ok := validation.Get("openAPIV3Schema"); ok { + filename := formatFilename(filenameFormat, kind, group, fullgroup, vName) + if err := writeSchemaFile(root, filename, denyRoot); err != nil { + return err + } + } + } + } + } else if validation, hasValidation := getMap(spec, "validation"); hasValidation { + if root, ok := validation.Get("openAPIV3Schema"); ok { + vName, _ := getString(spec, "version") + filename := formatFilename(filenameFormat, kind, group, fullgroup, vName) + if err := writeSchemaFile(root, filename, denyRoot); err != nil { + return err + } + } + } + } + return nil +} + +func getMap(m *OrderedMap, key string) (*OrderedMap, bool) { + if m == nil { + return nil, false + } + v, ok := m.Get(key) + if !ok { + return nil, false + } + mm, ok := v.(*OrderedMap) + return mm, ok +} + +func getString(m *OrderedMap, key string) (string, bool) { + if m == nil { + return "", false + } + v, ok := m.Get(key) + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} + +func main() { + if len(os.Args) < 2 { + fmt.Printf("Missing FILE parameter.\nUsage: %s [FILE]\n", os.Args[0]) + os.Exit(1) + } + filenameFormat := os.Getenv("FILENAME_FORMAT") + if filenameFormat == "" { + filenameFormat = "{kind}_{version}" + } + denyRoot := os.Getenv("DENY_ROOT_ADDITIONAL_PROPERTIES") != "" + + for _, arg := range os.Args[1:] { + if err := process(arg, filenameFormat, denyRoot); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + os.Exit(0) +} diff --git a/openapi2jsonschema-go/openapi2jsonschema_test.go b/openapi2jsonschema-go/openapi2jsonschema_test.go new file mode 100644 index 0000000..0d463bc --- /dev/null +++ b/openapi2jsonschema-go/openapi2jsonschema_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +// roundTrip serialises and parses through encoding/json so the resulting +// generic structure can be compared with reflect.DeepEqual regardless of how +// the producer chose to nest *OrderedMap vs map[string]any. +func roundTrip(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +func mapOf(kv ...any) *OrderedMap { + m := NewOrderedMap() + for i := 0; i < len(kv); i += 2 { + m.Set(kv[i].(string), kv[i+1]) + } + return m +} + +func TestAdditionalProperties(t *testing.T) { + cases := []struct { + name string + input *OrderedMap + expect string + }{ + { + name: "object with properties gets additionalProperties:false", + input: mapOf("something", mapOf("properties", NewOrderedMap())), + expect: `{"something":{"properties":{},"additionalProperties":false}}`, + }, + { + name: "object without properties is left alone", + input: mapOf("something", mapOf("somethingelse", NewOrderedMap())), + expect: `{"something":{"somethingelse":{}}}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := additionalProperties(tc.input, false) + b, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != tc.expect { + t.Fatalf("got %s want %s", b, tc.expect) + } + }) + } +} + +func TestReplaceIntOrString(t *testing.T) { + cases := []struct { + name string + input *OrderedMap + expect string + }{ + { + name: "int-or-string is expanded to oneOf", + input: mapOf("something", mapOf("format", "int-or-string")), + expect: `{"something":{"oneOf":[{"type":"string"},{"type":"integer"}]}}`, + }, + { + name: "other formats are left alone", + input: mapOf("something", mapOf("format", "string")), + expect: `{"something":{"format":"string"}}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := replaceIntOrString(tc.input) + b, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != tc.expect { + t.Fatalf("got %s want %s", b, tc.expect) + } + }) + } +} + +func TestFormatFilename(t *testing.T) { + got := formatFilename("{kind}_{version}", "Prometheus", "monitoring", "monitoring.coreos.com", "v1") + if got != "prometheus_v1.json" { + t.Fatalf("got %s", got) + } + got = formatFilename("{kind}-{group}-{version}", "Prometheus", "monitoring", "monitoring.coreos.com", "v1") + if got != "prometheus-monitoring-v1.json" { + t.Fatalf("got %s", got) + } +} + +// TestAdditionalProperties_PreservesUnrelatedKeys asserts the recursive walk +// does not lose sibling keys when injecting additionalProperties. +func TestAdditionalProperties_PreservesUnrelatedKeys(t *testing.T) { + input := mapOf( + "properties", mapOf("name", mapOf("type", "string")), + "required", []any{"name"}, + "type", "object", + ) + got := additionalProperties(input, false) + parsed := roundTrip(t, got) + // `additionalProperties: false` is only added at the level that has a + // "properties" key — the nested {type: string} does not. + want := map[string]any{ + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + "required": []any{"name"}, + "type": "object", + "additionalProperties": false, + } + if !reflect.DeepEqual(parsed, want) { + t.Fatalf("got %v want %v", parsed, want) + } +}