mirror of
https://github.com/yannh/kubeconform.git
synced 2026-06-28 16:00:44 +00:00
Openapi2jsonschema-go (#357)
* Go implementation of openapi2jsonschema * Add go version of openapi2jsonschema to container
This commit is contained in:
parent
8e634e18c0
commit
b83bf792b2
13 changed files with 720 additions and 7 deletions
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ LABEL org.opencontainers.image.authors="Yann Hamon <yann@mandragor.org>" \
|
|||
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"]
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ LABEL org.opencontainers.image.authors="Yann Hamon <yann@mandragor.org>" \
|
|||
org.opencontainers.image.url="https://github.com/yannh/kubeconform/"
|
||||
RUN apk add ca-certificates
|
||||
COPY kubeconform /
|
||||
COPY openapi2jsonschema /
|
||||
ENTRYPOINT ["/kubeconform"]
|
||||
|
|
|
|||
2
Makefile
2
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:
|
||||
|
|
|
|||
15
openapi2jsonschema-go/Dockerfile.bats
Normal file
15
openapi2jsonschema-go/Dockerfile.bats
Normal file
|
|
@ -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
|
||||
41
openapi2jsonschema-go/Makefile
Normal file
41
openapi2jsonschema-go/Makefile
Normal file
|
|
@ -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
|
||||
81
openapi2jsonschema-go/acceptance.bats
Normal file
81
openapi2jsonschema-go/acceptance.bats
Normal file
|
|
@ -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]' ]
|
||||
}
|
||||
5
openapi2jsonschema-go/go.mod
Normal file
5
openapi2jsonschema-go/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module github.com/yannh/kubeconform/scripts/go
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
openapi2jsonschema-go/go.sum
Normal file
4
openapi2jsonschema-go/go.sum
Normal file
|
|
@ -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=
|
||||
BIN
openapi2jsonschema-go/openapi2jsonschema
Executable file
BIN
openapi2jsonschema-go/openapi2jsonschema
Executable file
Binary file not shown.
405
openapi2jsonschema-go/openapi2jsonschema.go
Normal file
405
openapi2jsonschema-go/openapi2jsonschema.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
127
openapi2jsonschema-go/openapi2jsonschema_test.go
Normal file
127
openapi2jsonschema-go/openapi2jsonschema_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue