From 65ae30b383efac7111b01e871048e7fa459f5a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:59:07 +0200 Subject: [PATCH 1/3] fix: do not set additionalProperties: false in empty objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- internal/schemas/generator/json.go | 2 +- internal/schemas/generator/json_test.go | 28 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/schemas/generator/json.go b/internal/schemas/generator/json.go index e9043cf8..61eca295 100644 --- a/internal/schemas/generator/json.go +++ b/internal/schemas/generator/json.go @@ -166,7 +166,7 @@ func CRDsToJSONSchemas(crds []*extv1.CustomResourceDefinition) ([]CRDJSONSchema, // Schema: sets additionalProperties to false on object types and rewrites // component $ref paths to file references. func mutateJSONSchema(s *jsonschema.Schema) *jsonschema.Schema { - if s.Type == "object" && s.AdditionalProperties == nil { + if s.Type == "object" && s.AdditionalProperties == nil && s.Properties.Len() > 0 { s.AdditionalProperties = jsonschema.FalseSchema } diff --git a/internal/schemas/generator/json_test.go b/internal/schemas/generator/json_test.go index b5ea0e26..352eff5b 100644 --- a/internal/schemas/generator/json_test.go +++ b/internal/schemas/generator/json_test.go @@ -75,6 +75,34 @@ func TestGenerateFromCRD(t *testing.T) { } } +func TestMutateJSONSchema(t *testing.T) { + t.Run("ObjectWithProperties", func(t *testing.T) { + s := &jsonschema.Schema{ + Type: "object", + } + s.Properties = jsonschema.NewProperties() + s.Properties.Set("name", &jsonschema.Schema{Type: "string"}) + + mutateJSONSchema(s) + + if s.AdditionalProperties != jsonschema.FalseSchema { + t.Error("expected additionalProperties to be false for object with properties") + } + }) + + t.Run("EmptyObject", func(t *testing.T) { + s := &jsonschema.Schema{ + Type: "object", + } + + mutateJSONSchema(s) + + if s.AdditionalProperties != nil { + t.Error("expected additionalProperties to remain nil for empty object") + } + }) +} + func TestGenerateFromOpenAPI(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") schemaFS, err := jsonGenerator{}.GenerateFromOpenAPI(t.Context(), inputFS, nil) From d2255f7059040cf0008ff7967d4ab50c8010c02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:50:34 +0200 Subject: [PATCH 2/3] fix: pass through crd.ToOpenAPI and embed references as $def MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- internal/schemas/generator/json.go | 82 +++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/internal/schemas/generator/json.go b/internal/schemas/generator/json.go index 61eca295..cfdafd53 100644 --- a/internal/schemas/generator/json.go +++ b/internal/schemas/generator/json.go @@ -23,6 +23,7 @@ import ( "io/fs" "maps" "path/filepath" + "slices" "strings" "github.com/invopop/jsonschema" @@ -35,6 +36,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/crd" "github.com/crossplane/cli/v2/internal/schemas/runner" ) @@ -126,24 +128,58 @@ func ToJSONSchema(s any, gvk runtimeSchema.GroupVersionKind) (*jsonschema.Schema } // CRDsToJSONSchemas converts CRD OpenAPI v3 schemas to marshaled JSON Schemas. +// Referenced component schemas are embedded as $defs so each output is a self-contained document. func CRDsToJSONSchemas(crds []*extv1.CustomResourceDefinition) ([]CRDJSONSchema, error) { var results []CRDJSONSchema - for _, crd := range crds { - group := crd.Spec.Group - kind := crd.Spec.Names.Kind + for _, c := range crds { + oapis, err := crd.ToOpenAPI(c) + if err != nil { + return nil, errors.Wrapf(err, "cannot convert CRD %q to OpenAPI", c.GetName()) + } + + group := c.Spec.Group + kind := c.Spec.Names.Kind + groupParts := strings.Split(group, ".") + slices.Reverse(groupParts) + reverseGroup := strings.Join(groupParts, ".") + + for _, ver := range c.Spec.Versions { + oapi, ok := oapis[ver.Name] + if !ok { + continue + } - for _, ver := range crd.Spec.Versions { - if ver.Schema == nil || ver.Schema.OpenAPIV3Schema == nil { + resourceName := reverseGroup + "." + ver.Name + "." + kind + schema, ok := oapi.Components.Schemas[resourceName] + if !ok { continue } + rewriteComponentRefs(schema) + gvk := runtimeSchema.GroupVersionKind{Group: group, Version: ver.Name, Kind: kind} - s, err := ToJSONSchema(ver.Schema.OpenAPIV3Schema, gvk) + s, err := ToJSONSchema(schema, gvk) if err != nil { return nil, errors.Wrapf(err, "cannot convert schema for %s/%s %s", group, ver.Name, kind) } + s.Definitions = make(jsonschema.Definitions) + for name, comp := range oapi.Components.Schemas { + // Skip the resource itself (already root) and List schemas + // generated by BuildOpenAPIV3 that $ref the root resource, + // which would create unresolvable references in $defs. + if name == resourceName || strings.HasSuffix(name, "List") { + continue + } + rewriteComponentRefs(comp) + defSchema, err := ToJSONSchema(comp, runtimeSchema.GroupVersionKind{}) + if err != nil { + return nil, errors.Wrapf(err, "cannot convert $defs schema %s", name) + } + s.Definitions[name] = defSchema + } + data, err := json.MarshalIndent(s, "", " ") if err != nil { return nil, errors.Wrapf(err, "cannot marshal JSON Schema for %s/%s %s", group, ver.Name, kind) @@ -162,6 +198,40 @@ func CRDsToJSONSchemas(crds []*extv1.CustomResourceDefinition) ([]CRDJSONSchema, return results, nil } +// rewriteComponentRefs rewrites $ref paths from #/components/schemas/X to +// #/$defs/X so they resolve within the same JSON Schema document. +func rewriteComponentRefs(s *spec.Schema) { + if ref := s.Ref.String(); ref != "" { + if after, ok := strings.CutPrefix(ref, "#/components/schemas/"); ok { + s.Ref = spec.MustCreateRef("#/$defs/" + after) + } + return + } + + for name, prop := range s.Properties { + rewriteComponentRefs(&prop) + s.Properties[name] = prop + } + if s.Items != nil && s.Items.Schema != nil { + rewriteComponentRefs(s.Items.Schema) + } + if s.AdditionalProperties != nil && s.AdditionalProperties.Schema != nil { + rewriteComponentRefs(s.AdditionalProperties.Schema) + } + for i := range s.AllOf { + rewriteComponentRefs(&s.AllOf[i]) + } + for i := range s.AnyOf { + rewriteComponentRefs(&s.AnyOf[i]) + } + for i := range s.OneOf { + rewriteComponentRefs(&s.OneOf[i]) + } + if s.Not != nil { + rewriteComponentRefs(s.Not) + } +} + // mutateJSONSchema applies YAML language server compatibility fixes to a JSON // Schema: sets additionalProperties to false on object types and rewrites // component $ref paths to file references. From 10133aa0ad8ddb4a16a4dca2df7d4ef7cc338bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:08:06 +0200 Subject: [PATCH 3/3] chore: add tests to validate added functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- internal/schemas/generator/json_test.go | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/internal/schemas/generator/json_test.go b/internal/schemas/generator/json_test.go index 352eff5b..e65cd088 100644 --- a/internal/schemas/generator/json_test.go +++ b/internal/schemas/generator/json_test.go @@ -19,10 +19,14 @@ package generator import ( "embed" "encoding/json" + "strings" "testing" "github.com/invopop/jsonschema" "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kube-openapi/pkg/validation/spec" ) //go:embed testdata/*.json @@ -103,6 +107,88 @@ func TestMutateJSONSchema(t *testing.T) { }) } +func TestRewriteComponentRefs(t *testing.T) { + cRef := func(n string) spec.Ref { return spec.MustCreateRef("#/components/schemas/" + n) } + + s := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Properties: map[string]spec.Schema{ + "a": {SchemaProps: spec.SchemaProps{Ref: cRef("A")}}, + }, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{SchemaProps: spec.SchemaProps{Ref: cRef("B")}}, + }, + AllOf: []spec.Schema{{SchemaProps: spec.SchemaProps{Ref: cRef("C")}}}, + Not: &spec.Schema{SchemaProps: spec.SchemaProps{Ref: cRef("D")}}, + }, + } + rewriteComponentRefs(s) + + bs, _ := json.Marshal(s) + raw := string(bs) + if strings.Contains(raw, "#/components/schemas/") { + t.Fatalf("refs remain: %s", raw) + } + for _, name := range []string{"A", "B", "C", "D"} { + if !strings.Contains(raw, "#/$defs/"+name) { + t.Errorf("missing #/$defs/%s in output", name) + } + } +} + +func TestCRDsToJSONSchemasRewritesRefs(t *testing.T) { + crd := &extv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "widgets.example.org"}, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Widget", Plural: "widgets", Singular: "widget", ListKind: "WidgetList", + }, + Scope: extv1.NamespaceScoped, + Versions: []extv1.CustomResourceDefinitionVersion{{ + Name: "v1", Served: true, Storage: true, + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{"spec": {Type: "object"}}, + }, + }, + }}, + }, + } + + schemas, err := CRDsToJSONSchemas([]*extv1.CustomResourceDefinition{crd}) + if err != nil { + t.Fatalf("CRDsToJSONSchemas: %v", err) + } + if len(schemas) == 0 { + t.Fatal("expected at least one schema") + } + + raw := string(schemas[0].Data) + if strings.Contains(raw, "#/components/schemas/") { + t.Fatal("output still contains #/components/schemas/ refs") + } + + var parsed map[string]any + if err := json.Unmarshal(schemas[0].Data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + defs, ok := parsed["$defs"].(map[string]any) + if !ok || len(defs) == 0 { + t.Fatal("expected $defs with at least one entry") + } + + // Verify every #/$defs/ ref in the output resolves to an actual $defs entry. + for _, match := range strings.Split(raw, "#/$defs/")[1:] { + name, _, _ := strings.Cut(match, "\"") + if _, ok := defs[name]; !ok { + t.Errorf("#/$defs/%s referenced but not defined in $defs", name) + } + } +} + func TestGenerateFromOpenAPI(t *testing.T) { inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") schemaFS, err := jsonGenerator{}.GenerateFromOpenAPI(t.Context(), inputFS, nil)