mirror of
https://github.com/outbackdingo/cozystack.git
synced 2026-01-27 10:18:39 +00:00
[cozystack-api] Refactor OpenAPI Schema
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
This commit is contained in:
201
pkg/cmd/server/openapi.go
Normal file
201
pkg/cmd/server/openapi.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// shared helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
baseRef = "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application"
|
||||
baseListRef = baseRef + "List"
|
||||
)
|
||||
|
||||
func deepCopySchema(in *spec.Schema) *spec.Schema {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
// Log error or panic since this is unexpected
|
||||
panic(fmt.Sprintf("failed to marshal schema: %v", err))
|
||||
}
|
||||
var out spec.Schema
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
panic(fmt.Sprintf("failed to unmarshal schema: %v", err))
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// find the object that already owns ".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 _, ok := s.Properties["spec"]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for _, branch := range [][]spec.Schema{s.AllOf, s.OneOf, s.AnyOf} {
|
||||
for i := range branch {
|
||||
if res := findSpecContainer(&branch[i]); res != nil {
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply user-supplied schema; when raw == "" turn the field into a 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{},
|
||||
}
|
||||
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{},
|
||||
}
|
||||
}
|
||||
|
||||
if target.Properties == nil {
|
||||
target.Properties = map[string]spec.Schema{}
|
||||
}
|
||||
target.Properties["spec"] = custom
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// OpenAPI **v3** post-processor
|
||||
// -----------------------------------------------------------------------------
|
||||
func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
|
||||
return func(doc *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err := patchSpec(container, raw); err != nil {
|
||||
return nil, fmt.Errorf("kind %s: %w", kind, err)
|
||||
}
|
||||
}
|
||||
|
||||
delete(doc.Components.Schemas, baseRef)
|
||||
delete(doc.Components.Schemas, baseListRef)
|
||||
return doc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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) {
|
||||
defs := sw.Definitions
|
||||
base, ok := defs[baseRef]
|
||||
if !ok {
|
||||
return sw, fmt.Errorf("base schema %q not found", baseRef)
|
||||
}
|
||||
|
||||
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 {
|
||||
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(defs, baseRef)
|
||||
delete(defs, baseListRef)
|
||||
return sw, nil
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -38,8 +40,6 @@ import (
|
||||
utilversionpkg "k8s.io/apiserver/pkg/util/version"
|
||||
"k8s.io/component-base/featuregate"
|
||||
baseversion "k8s.io/component-base/version"
|
||||
"k8s.io/klog/v2"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
netutils "k8s.io/utils/net"
|
||||
)
|
||||
|
||||
@@ -159,22 +159,6 @@ func (o AppsServerOptions) Validate(args []string) error {
|
||||
return utilerrors.NewAggregate(allErrors)
|
||||
}
|
||||
|
||||
// DeepCopySchema делает глубокую копию структуры spec.Schema
|
||||
func DeepCopySchema(schema *spec.Schema) (*spec.Schema, error) {
|
||||
data, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal schema: %w", err)
|
||||
}
|
||||
|
||||
var newSchema spec.Schema
|
||||
err = json.Unmarshal(data, &newSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal schema: %w", err)
|
||||
}
|
||||
|
||||
return &newSchema, nil
|
||||
}
|
||||
|
||||
// Config returns the configuration for the API server based on AppsServerOptions
|
||||
func (o *AppsServerOptions) Config() (*apiserver.Config, error) {
|
||||
// TODO: set the "real" external address
|
||||
@@ -195,107 +179,34 @@ func (o *AppsServerOptions) Config() (*apiserver.Config, error) {
|
||||
serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(
|
||||
sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme),
|
||||
)
|
||||
serverConfig.OpenAPIConfig.Info.Title = "Apps"
|
||||
serverConfig.OpenAPIConfig.Info.Version = "0.1"
|
||||
|
||||
serverConfig.OpenAPIConfig.PostProcessSpec = func(swagger *spec.Swagger) (*spec.Swagger, error) {
|
||||
defs := swagger.Definitions
|
||||
|
||||
// Verify the presence of the base Application/ApplicationList definitions
|
||||
appDef, exists := defs["com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application"]
|
||||
if !exists {
|
||||
return swagger, fmt.Errorf("Application definition not found")
|
||||
version := "0.1"
|
||||
if o.ResourceConfig != nil {
|
||||
raw, err := json.Marshal(o.ResourceConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal resource config: %v", err)
|
||||
}
|
||||
|
||||
listDef, exists := defs["com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.ApplicationList"]
|
||||
if !exists {
|
||||
return swagger, fmt.Errorf("ApplicationList definition not found")
|
||||
}
|
||||
|
||||
// Iterate over all registered GVKs (e.g., Bucket, Database, etc.)
|
||||
for _, gvk := range v1alpha1.RegisteredGVKs {
|
||||
// This will be something like:
|
||||
// "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Bucket"
|
||||
resourceName := fmt.Sprintf("com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.%s", gvk.Kind)
|
||||
|
||||
// 1. Create a copy of the base Application definition for the new resource
|
||||
newDef, err := DeepCopySchema(&appDef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deepcopy schema for %s: %w", gvk.Kind, err)
|
||||
}
|
||||
|
||||
// 2. Update x-kubernetes-group-version-kind to match the new resource
|
||||
if newDef.Extensions == nil {
|
||||
newDef.Extensions = map[string]interface{}{}
|
||||
}
|
||||
newDef.Extensions["x-kubernetes-group-version-kind"] = []map[string]interface{}{
|
||||
{
|
||||
"group": gvk.Group,
|
||||
"version": gvk.Version,
|
||||
"kind": gvk.Kind,
|
||||
},
|
||||
}
|
||||
|
||||
// make `.spec` schemaless so any keys are accepted
|
||||
if specProp, ok := newDef.Properties["spec"]; ok {
|
||||
specProp.AdditionalProperties = &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{},
|
||||
}
|
||||
newDef.Properties["spec"] = specProp
|
||||
}
|
||||
|
||||
// 3. Save the new resource definition under the correct name
|
||||
defs[resourceName] = *newDef
|
||||
klog.V(6).Infof("PostProcessSpec: Added OpenAPI definition for %s\n", resourceName)
|
||||
|
||||
// 4. Now handle the corresponding List type (e.g., BucketList).
|
||||
// We'll start by copying the ApplicationList definition.
|
||||
listResourceName := fmt.Sprintf("com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.%sList", gvk.Kind)
|
||||
newListDef, err := DeepCopySchema(&listDef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deepcopy schema for %sList: %w", gvk.Kind, err)
|
||||
}
|
||||
|
||||
// 5. Update x-kubernetes-group-version-kind for the List definition
|
||||
if newListDef.Extensions == nil {
|
||||
newListDef.Extensions = map[string]interface{}{}
|
||||
}
|
||||
newListDef.Extensions["x-kubernetes-group-version-kind"] = []map[string]interface{}{
|
||||
{
|
||||
"group": gvk.Group,
|
||||
"version": gvk.Version,
|
||||
"kind": fmt.Sprintf("%sList", gvk.Kind),
|
||||
},
|
||||
}
|
||||
|
||||
// 6. IMPORTANT: Fix the "items" reference so it points to the new resource
|
||||
// rather than to "Application".
|
||||
if itemsProp, found := newListDef.Properties["items"]; found {
|
||||
if itemsProp.Items != nil && itemsProp.Items.Schema != nil {
|
||||
itemsProp.Items.Schema.Ref = spec.MustCreateRef("#/definitions/" + resourceName)
|
||||
newListDef.Properties["items"] = itemsProp
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Finally, save the new List definition
|
||||
defs[listResourceName] = *newListDef
|
||||
klog.V(6).Infof("PostProcessSpec: Added OpenAPI definition for %s\n", listResourceName)
|
||||
}
|
||||
|
||||
// Remove the original Application/ApplicationList from the definitions
|
||||
delete(defs, "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application")
|
||||
delete(defs, "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.ApplicationList")
|
||||
|
||||
swagger.Definitions = defs
|
||||
return swagger, nil
|
||||
sum := sha256.Sum256(raw)
|
||||
version = "0.1-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// capture schemas from config once for fast lookup inside the closure
|
||||
kindSchemas := map[string]string{}
|
||||
for _, r := range o.ResourceConfig.Resources {
|
||||
kindSchemas[r.Application.Kind] = r.Application.OpenAPISchema
|
||||
}
|
||||
|
||||
serverConfig.OpenAPIConfig.Info.Title = "Apps"
|
||||
serverConfig.OpenAPIConfig.Info.Version = version
|
||||
serverConfig.OpenAPIConfig.PostProcessSpec = buildPostProcessV2(kindSchemas)
|
||||
|
||||
serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(
|
||||
sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme),
|
||||
)
|
||||
serverConfig.OpenAPIV3Config.Info.Title = "Apps"
|
||||
serverConfig.OpenAPIV3Config.Info.Version = "0.1"
|
||||
serverConfig.OpenAPIV3Config.Info.Version = version
|
||||
|
||||
serverConfig.OpenAPIV3Config.PostProcessSpec = buildPostProcessV3(kindSchemas)
|
||||
|
||||
serverConfig.FeatureGate = utilversionpkg.DefaultComponentGlobalsRegistry.FeatureGateFor(
|
||||
utilversionpkg.DefaultKubeComponent,
|
||||
|
||||
@@ -36,10 +36,11 @@ type Resource struct {
|
||||
|
||||
// ApplicationConfig contains the application settings.
|
||||
type ApplicationConfig struct {
|
||||
Kind string `yaml:"kind"`
|
||||
Singular string `yaml:"singular"`
|
||||
Plural string `yaml:"plural"`
|
||||
ShortNames []string `yaml:"shortNames"`
|
||||
Kind string `yaml:"kind"`
|
||||
Singular string `yaml:"singular"`
|
||||
Plural string `yaml:"plural"`
|
||||
ShortNames []string `yaml:"shortNames"`
|
||||
OpenAPISchema string `yaml:"openAPISchema"`
|
||||
}
|
||||
|
||||
// ReleaseConfig contains the release settings.
|
||||
|
||||
Reference in New Issue
Block a user