diff --git a/pkg/cmd/server/openapi.go b/pkg/cmd/server/openapi.go index 315a0077..6ba18b50 100644 --- a/pkg/cmd/server/openapi.go +++ b/pkg/cmd/server/openapi.go @@ -14,33 +14,36 @@ import ( // ----------------------------------------------------------------------------- const ( - baseRef = "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application" - baseListRef = baseRef + "List" - smp = "application/strategic-merge-patch+json" + apiPrefix = "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1" + baseRef = apiPrefix + ".Application" + baseListRef = apiPrefix + ".ApplicationList" + baseStatusRef = apiPrefix + ".ApplicationStatus" + smp = "application/strategic-merge-patch+json" ) +// deepCopySchema clones *spec.Schema via JSON-marshal/unmarshal. func deepCopySchema(in *spec.Schema) *spec.Schema { if in == nil { return nil } - b, err := json.Marshal(in) + raw, err := json.Marshal(in) if err != nil { - // Log error or panic since this is unexpected - panic(fmt.Sprintf("failed to marshal schema: %v", err)) + panic(fmt.Errorf("failed to marshal schema: %w", err)) } var out spec.Schema - if err := json.Unmarshal(b, &out); err != nil { - panic(fmt.Sprintf("failed to unmarshal schema: %v", err)) + err = json.Unmarshal(raw, &out) + if err != nil { + panic(fmt.Errorf("failed to unmarshal schema: %w", err)) } return &out } -// find the object that already owns ".spec" +// findSpecContainer returns first object owning ".spec". func findSpecContainer(s *spec.Schema) *spec.Schema { if s == nil { return nil } - if len(s.Type) > 0 && s.Type.Contains("object") && s.Properties != nil { + if len(s.Type) > 0 && s.Type.Contains("object") { if _, ok := s.Properties["spec"]; ok { return s } @@ -55,40 +58,25 @@ func findSpecContainer(s *spec.Schema) *spec.Schema { return nil } -// apply user-supplied schema; when raw == "" turn the field into a schemaless object +// patchSpec injects/overrides ".spec" with user JSON (or schemaless object). func patchSpec(target *spec.Schema, raw string) error { - // ------------------------------------------------------------------ - // 1) schema not provided → make ".spec" a fully open object - // ------------------------------------------------------------------ if strings.TrimSpace(raw) == "" { if target.Properties == nil { target.Properties = map[string]spec.Schema{} } prop := target.Properties["spec"] - prop.AdditionalProperties = &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{}, - } + prop.AdditionalProperties = &spec.SchemaOrBool{Allows: true, Schema: &spec.Schema{}} target.Properties["spec"] = prop return nil } - // ------------------------------------------------------------------ - // 2) custom schema provided → keep / inject additionalProperties - // ------------------------------------------------------------------ var custom spec.Schema if err := json.Unmarshal([]byte(raw), &custom); err != nil { return err } - - // if user didn't specify additionalProperties, add a permissive one if custom.AdditionalProperties == nil { - custom.AdditionalProperties = &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{}, - } + custom.AdditionalProperties = &spec.SchemaOrBool{Allows: true, Schema: &spec.Schema{}} } - if target.Properties == nil { target.Properties = map[string]spec.Schema{} } @@ -96,60 +84,190 @@ func patchSpec(target *spec.Schema, raw string) error { return nil } +/* ────────────────────────────────────────────────────────────────────────── */ +/* DRY helpers */ +/* ────────────────────────────────────────────────────────────────────────── */ + +// cloneKindSchemas: from base schemas, create new schemas for a specific kind. +func cloneKindSchemas(kind string, base, baseStatus, baseList *spec.Schema, v3 bool) (obj, status, list *spec.Schema) { + obj = deepCopySchema(base) + status = deepCopySchema(baseStatus) + list = deepCopySchema(baseList) + + // Ensure we have valid clones + if obj == nil || status == nil || list == nil { + return nil, nil, nil + } + + // GVK-extensions + setGVK := func(s *spec.Schema, k string) { + s.Extensions = map[string]interface{}{ + "x-kubernetes-group-version-kind": []interface{}{ + map[string]interface{}{"group": "apps.cozystack.io", "version": "v1alpha1", "kind": k}, + }, + } + } + setGVK(obj, kind) + setGVK(list, kind+"List") + + // fix refs + refPrefix := "#/components/schemas/" // v3 + if !v3 { + refPrefix = "#/definitions/" + } + statusRef := refPrefix + apiPrefix + "." + kind + "Status" + itemRef := refPrefix + apiPrefix + "." + kind + + if prop, ok := obj.Properties["status"]; ok { + prop.Ref = spec.MustCreateRef(statusRef) + obj.Properties["status"] = prop + } + if list.Properties != nil { + if items := list.Properties["items"]; items.Items != nil && items.Items.Schema != nil { + items.Items.Schema.Ref = spec.MustCreateRef(itemRef) + list.Properties["items"] = items + } + } + return +} + +// rewriteDocRefs rewrites all $ref in the OpenAPI document +func rewriteDocRefs(doc interface{}) ([]byte, error) { + raw, err := json.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("failed to marshal OpenAPI document: %w", err) + } + var any interface{} + if err := json.Unmarshal(raw, &any); err != nil { + return nil, err + } + walkAndRewriteRefs(any, "") + return json.Marshal(any) +} + +// walkAndRewriteRefs walks arbitrary JSON (map/array) and +// - when encountering x-kubernetes-group-version-kind, extracts kind, +// updating the currentKind context; +// - rewrites all $ref inside the current context from Application* → kind*. +func walkAndRewriteRefs(node interface{}, currentKind string) { + switch n := node.(type) { + case map[string]interface{}: + if gvk, ok := n["x-kubernetes-group-version-kind"]; ok { + switch g := gvk.(type) { + case map[string]interface{}: + if k, ok := g["kind"].(string); ok { + currentKind = k + } + case []interface{}: + if len(g) > 0 { + if mm, ok := g[0].(map[string]interface{}); ok { + if k, ok := mm["kind"].(string); ok { + currentKind = k + } + } + } + } + } + for k, v := range n { + if k == "$ref" && currentKind != "" { + if s, ok := v.(string); ok { + n[k] = rewriteRefForKind(s, currentKind) + continue + } + } + walkAndRewriteRefs(v, currentKind) + } + case []interface{}: + for _, v := range n { + walkAndRewriteRefs(v, currentKind) + } + } +} + +// rewriteRefForKind rewrites a reference to a specific kind. +func rewriteRefForKind(old, kind string) string { + var base string + switch { + case strings.HasPrefix(old, "#/components/schemas/"): + base = "#/components/schemas/" + case strings.HasPrefix(old, "#/definitions/"): + base = "#/definitions/" + default: + return old + } + switch { + case strings.HasSuffix(old, ".Application"): + return base + apiPrefix + "." + kind + case strings.HasSuffix(old, ".ApplicationList"): + return base + apiPrefix + "." + kind + "List" + case strings.HasSuffix(old, ".ApplicationStatus"): + return base + apiPrefix + "." + kind + "Status" + default: + return old + } +} + // ----------------------------------------------------------------------------- // OpenAPI **v3** post-processor // ----------------------------------------------------------------------------- func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { - return func(doc *spec3.OpenAPI) (*spec3.OpenAPI, error) { - // Replace the basic "Application" schema with the user-supplied kinds. if doc.Components == nil { doc.Components = &spec3.Components{} } if doc.Components.Schemas == nil { doc.Components.Schemas = map[string]*spec.Schema{} } - base, ok := doc.Components.Schemas[baseRef] - if !ok { - return doc, fmt.Errorf("base schema %q not found", baseRef) + + // Get base schemas + base, ok1 := doc.Components.Schemas[baseRef] + list, ok2 := doc.Components.Schemas[baseListRef] + stat, ok3 := doc.Components.Schemas[baseStatusRef] + if !(ok1 && ok2 && ok3) { + return doc, fmt.Errorf("base Application* schemas not found") } + + // Clone base schemas for each kind for kind, raw := range kindSchemas { - ref := fmt.Sprintf("%s.%s", "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1", kind) - s := doc.Components.Schemas[ref] - if s == nil { // first time – clone "Application" - s = deepCopySchema(base) - s.Extensions = map[string]interface{}{ - "x-kubernetes-group-version-kind": []interface{}{ - map[string]interface{}{ - "group": "apps.cozystack.io", "version": "v1alpha1", "kind": kind, - }, - }, - } - doc.Components.Schemas[ref] = s - } - container := findSpecContainer(s) - if container == nil { // fallback: use the root - container = s + ref := apiPrefix + "." + kind + statusRef := ref + "Status" + listRef := ref + "List" + + obj, status, l := cloneKindSchemas(kind, base, stat, list /*v3=*/, true) + doc.Components.Schemas[ref] = obj + doc.Components.Schemas[statusRef] = status + doc.Components.Schemas[listRef] = l + + // patch .spec + container := findSpecContainer(obj) + if container == nil { + container = obj } if err := patchSpec(container, raw); err != nil { return nil, fmt.Errorf("kind %s: %w", kind, err) } } + + // Delete base schemas delete(doc.Components.Schemas, baseRef) delete(doc.Components.Schemas, baseListRef) + delete(doc.Components.Schemas, baseStatusRef) - // Disable strategic-merge-patch+json support in all PATCH operations + // Disable strategic-merge-patch+json for p, pi := range doc.Paths.Paths { - if pi == nil || pi.Patch == nil || pi.Patch.RequestBody == nil { - continue + if pi != nil && pi.Patch != nil && pi.Patch.RequestBody != nil { + delete(pi.Patch.RequestBody.Content, smp) + doc.Paths.Paths[p] = pi } - delete(pi.Patch.RequestBody.Content, smp) - - doc.Paths.Paths[p] = pi } - return doc, nil + // Rewrite all $ref in the document + out, err := rewriteDocRefs(doc) + if err != nil { + return nil, err + } + return doc, json.Unmarshal(out, doc) } } @@ -157,52 +275,39 @@ func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*sp // OpenAPI **v2** (swagger) post-processor // ----------------------------------------------------------------------------- func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) { - return func(sw *spec.Swagger) (*spec.Swagger, error) { - // Replace the basic "Application" schema with the user-supplied kinds. + // Get base schemas defs := sw.Definitions - base, ok := defs[baseRef] - if !ok { - return sw, fmt.Errorf("base schema %q not found", baseRef) + base, ok1 := defs[baseRef] + list, ok2 := defs[baseListRef] + stat, ok3 := defs[baseStatusRef] + if !(ok1 && ok2 && ok3) { + return sw, fmt.Errorf("base Application* schemas not found") } + + // Clone base schemas for each kind for kind, raw := range kindSchemas { - ref := fmt.Sprintf("%s.%s", "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1", kind) - s := deepCopySchema(&base) - s.Extensions = map[string]interface{}{ - "x-kubernetes-group-version-kind": []interface{}{ - map[string]interface{}{ - "group": "apps.cozystack.io", "version": "v1alpha1", "kind": kind, - }, - }, - } - if err := patchSpec(s, raw); err != nil { + ref := apiPrefix + "." + kind + statusRef := ref + "Status" + listRef := ref + "List" + + obj, status, l := cloneKindSchemas(kind, &base, &stat, &list /*v3=*/, false) + defs[ref] = *obj + defs[statusRef] = *status + defs[listRef] = *l + + if err := patchSpec(obj, raw); err != nil { return nil, fmt.Errorf("kind %s: %w", kind, err) } - defs[ref] = *s - // clone the List variant - listName := ref + "List" - listSrc := defs[baseListRef] - listCopy := deepCopySchema(&listSrc) - listCopy.Extensions = map[string]interface{}{ - "x-kubernetes-group-version-kind": []interface{}{ - map[string]interface{}{ - "group": "apps.cozystack.io", - "version": "v1alpha1", - "kind": kind + "List", - }, - }, - } - if items := listCopy.Properties["items"]; items.Items != nil && items.Items.Schema != nil { - items.Items.Schema.Ref = spec.MustCreateRef("#/definitions/" + ref) - listCopy.Properties["items"] = items - } - defs[listName] = *listCopy } + + // Delete base schemas delete(defs, baseRef) delete(defs, baseListRef) + delete(defs, baseStatusRef) - // Disable strategic-merge-patch+json support in all PATCH operations + // Disable strategic-merge-patch+json for p, op := range sw.Paths.Paths { if op.Patch != nil && len(op.Patch.Consumes) > 0 { var out []string @@ -216,6 +321,11 @@ func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spe } } - return sw, nil + // Rewrite all $ref in the document + out, err := rewriteDocRefs(sw) + if err != nil { + return nil, err + } + return sw, json.Unmarshal(out, sw) } }