Skip to content

Commit 91583a4

Browse files
committed
[cozystack-api] Refactor OpenAPI Schema
Signed-off-by: Andrei Kvapil <[email protected]>
1 parent f628e7d commit 91583a4

3 files changed

Lines changed: 227 additions & 114 deletions

File tree

‎pkg/cmd/server/openapi.go‎

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"k8s.io/kube-openapi/pkg/spec3"
9+
"k8s.io/kube-openapi/pkg/validation/spec"
10+
)
11+
12+
// -----------------------------------------------------------------------------
13+
// shared helpers
14+
// -----------------------------------------------------------------------------
15+
16+
const (
17+
baseRef = "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application"
18+
baseListRef = baseRef + "List"
19+
)
20+
21+
func deepCopySchema(in *spec.Schema) *spec.Schema {
22+
if in == nil {
23+
return nil
24+
}
25+
b, err := json.Marshal(in)
26+
if err != nil {
27+
// Log error or panic since this is unexpected
28+
panic(fmt.Sprintf("failed to marshal schema: %v", err))
29+
}
30+
var out spec.Schema
31+
if err := json.Unmarshal(b, &out); err != nil {
32+
panic(fmt.Sprintf("failed to unmarshal schema: %v", err))
33+
}
34+
return &out
35+
}
36+
37+
// find the object that already owns ".spec"
38+
func findSpecContainer(s *spec.Schema) *spec.Schema {
39+
if s == nil {
40+
return nil
41+
}
42+
if len(s.Type) > 0 && s.Type.Contains("object") && s.Properties != nil {
43+
if _, ok := s.Properties["spec"]; ok {
44+
return s
45+
}
46+
}
47+
for _, branch := range [][]spec.Schema{s.AllOf, s.OneOf, s.AnyOf} {
48+
for i := range branch {
49+
if res := findSpecContainer(&branch[i]); res != nil {
50+
return res
51+
}
52+
}
53+
}
54+
return nil
55+
}
56+
57+
// apply user-supplied schema; when raw == "" turn the field into a schemaless object
58+
func patchSpec(target *spec.Schema, raw string) error {
59+
// ------------------------------------------------------------------
60+
// 1) schema not provided → make ".spec" a fully open object
61+
// ------------------------------------------------------------------
62+
if strings.TrimSpace(raw) == "" {
63+
if target.Properties == nil {
64+
target.Properties = map[string]spec.Schema{}
65+
}
66+
prop := target.Properties["spec"]
67+
prop.AdditionalProperties = &spec.SchemaOrBool{
68+
Allows: true,
69+
Schema: &spec.Schema{},
70+
}
71+
target.Properties["spec"] = prop
72+
return nil
73+
}
74+
75+
// ------------------------------------------------------------------
76+
// 2) custom schema provided → keep / inject additionalProperties
77+
// ------------------------------------------------------------------
78+
var custom spec.Schema
79+
if err := json.Unmarshal([]byte(raw), &custom); err != nil {
80+
return err
81+
}
82+
83+
// if user didn't specify additionalProperties, add a permissive one
84+
if custom.AdditionalProperties == nil {
85+
custom.AdditionalProperties = &spec.SchemaOrBool{
86+
Allows: true,
87+
Schema: &spec.Schema{},
88+
}
89+
}
90+
91+
if target.Properties == nil {
92+
target.Properties = map[string]spec.Schema{}
93+
}
94+
target.Properties["spec"] = custom
95+
return nil
96+
}
97+
98+
// -----------------------------------------------------------------------------
99+
// OpenAPI **v3** post-processor
100+
// -----------------------------------------------------------------------------
101+
func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
102+
103+
return func(doc *spec3.OpenAPI) (*spec3.OpenAPI, error) {
104+
if doc.Components == nil {
105+
doc.Components = &spec3.Components{}
106+
}
107+
if doc.Components.Schemas == nil {
108+
doc.Components.Schemas = map[string]*spec.Schema{}
109+
}
110+
111+
base, ok := doc.Components.Schemas[baseRef]
112+
if !ok {
113+
return doc, fmt.Errorf("base schema %q not found", baseRef)
114+
}
115+
116+
for kind, raw := range kindSchemas {
117+
ref := fmt.Sprintf("%s.%s", "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1", kind)
118+
119+
s := doc.Components.Schemas[ref]
120+
if s == nil { // first time – clone "Application"
121+
s = deepCopySchema(base)
122+
s.Extensions = map[string]interface{}{
123+
"x-kubernetes-group-version-kind": []interface{}{
124+
map[string]interface{}{
125+
"group": "apps.cozystack.io", "version": "v1alpha1", "kind": kind,
126+
},
127+
},
128+
}
129+
doc.Components.Schemas[ref] = s
130+
}
131+
132+
container := findSpecContainer(s)
133+
if container == nil { // fallback: use the root
134+
container = s
135+
}
136+
if err := patchSpec(container, raw); err != nil {
137+
return nil, fmt.Errorf("kind %s: %w", kind, err)
138+
}
139+
}
140+
141+
delete(doc.Components.Schemas, baseRef)
142+
delete(doc.Components.Schemas, baseListRef)
143+
return doc, nil
144+
}
145+
}
146+
147+
// -----------------------------------------------------------------------------
148+
// OpenAPI **v2** (swagger) post-processor
149+
// -----------------------------------------------------------------------------
150+
func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) {
151+
152+
return func(sw *spec.Swagger) (*spec.Swagger, error) {
153+
defs := sw.Definitions
154+
base, ok := defs[baseRef]
155+
if !ok {
156+
return sw, fmt.Errorf("base schema %q not found", baseRef)
157+
}
158+
159+
for kind, raw := range kindSchemas {
160+
ref := fmt.Sprintf("%s.%s", "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1", kind)
161+
162+
s := deepCopySchema(&base)
163+
s.Extensions = map[string]interface{}{
164+
"x-kubernetes-group-version-kind": []interface{}{
165+
map[string]interface{}{
166+
"group": "apps.cozystack.io", "version": "v1alpha1", "kind": kind,
167+
},
168+
},
169+
}
170+
171+
if err := patchSpec(s, raw); err != nil {
172+
return nil, fmt.Errorf("kind %s: %w", kind, err)
173+
}
174+
175+
defs[ref] = *s
176+
177+
// clone the List variant
178+
listName := ref + "List"
179+
listSrc := defs[baseListRef]
180+
listCopy := deepCopySchema(&listSrc)
181+
listCopy.Extensions = map[string]interface{}{
182+
"x-kubernetes-group-version-kind": []interface{}{
183+
map[string]interface{}{
184+
"group": "apps.cozystack.io",
185+
"version": "v1alpha1",
186+
"kind": kind + "List",
187+
},
188+
},
189+
}
190+
if items := listCopy.Properties["items"]; items.Items != nil && items.Items.Schema != nil {
191+
items.Items.Schema.Ref = spec.MustCreateRef("#/definitions/" + ref)
192+
listCopy.Properties["items"] = items
193+
}
194+
defs[listName] = *listCopy
195+
}
196+
197+
delete(defs, baseRef)
198+
delete(defs, baseListRef)
199+
return sw, nil
200+
}
201+
}

‎pkg/cmd/server/start.go‎

Lines changed: 21 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ package server
1818

1919
import (
2020
"context"
21+
"crypto/sha256"
22+
"encoding/hex"
2123
"encoding/json"
2224
"fmt"
2325
"io"
@@ -38,8 +40,6 @@ import (
3840
utilversionpkg "k8s.io/apiserver/pkg/util/version"
3941
"k8s.io/component-base/featuregate"
4042
baseversion "k8s.io/component-base/version"
41-
"k8s.io/klog/v2"
42-
"k8s.io/kube-openapi/pkg/validation/spec"
4343
netutils "k8s.io/utils/net"
4444
)
4545

@@ -159,22 +159,6 @@ func (o AppsServerOptions) Validate(args []string) error {
159159
return utilerrors.NewAggregate(allErrors)
160160
}
161161

162-
// DeepCopySchema делает глубокую копию структуры spec.Schema
163-
func DeepCopySchema(schema *spec.Schema) (*spec.Schema, error) {
164-
data, err := json.Marshal(schema)
165-
if err != nil {
166-
return nil, fmt.Errorf("failed to marshal schema: %w", err)
167-
}
168-
169-
var newSchema spec.Schema
170-
err = json.Unmarshal(data, &newSchema)
171-
if err != nil {
172-
return nil, fmt.Errorf("failed to unmarshal schema: %w", err)
173-
}
174-
175-
return &newSchema, nil
176-
}
177-
178162
// Config returns the configuration for the API server based on AppsServerOptions
179163
func (o *AppsServerOptions) Config() (*apiserver.Config, error) {
180164
// TODO: set the "real" external address
@@ -195,107 +179,34 @@ func (o *AppsServerOptions) Config() (*apiserver.Config, error) {
195179
serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(
196180
sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme),
197181
)
198-
serverConfig.OpenAPIConfig.Info.Title = "Apps"
199-
serverConfig.OpenAPIConfig.Info.Version = "0.1"
200182

201-
serverConfig.OpenAPIConfig.PostProcessSpec = func(swagger *spec.Swagger) (*spec.Swagger, error) {
202-
defs := swagger.Definitions
203-
204-
// Verify the presence of the base Application/ApplicationList definitions
205-
appDef, exists := defs["com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application"]
206-
if !exists {
207-
return swagger, fmt.Errorf("Application definition not found")
208-
}
209-
210-
listDef, exists := defs["com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.ApplicationList"]
211-
if !exists {
212-
return swagger, fmt.Errorf("ApplicationList definition not found")
183+
version := "0.1"
184+
if o.ResourceConfig != nil {
185+
raw, err := json.Marshal(o.ResourceConfig)
186+
if err != nil {
187+
return nil, fmt.Errorf("failed to marshal resource config: %v", err)
213188
}
189+
sum := sha256.Sum256(raw)
190+
version = "0.1-" + hex.EncodeToString(sum[:8])
191+
}
214192

215-
// Iterate over all registered GVKs (e.g., Bucket, Database, etc.)
216-
for _, gvk := range v1alpha1.RegisteredGVKs {
217-
// This will be something like:
218-
// "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Bucket"
219-
resourceName := fmt.Sprintf("com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.%s", gvk.Kind)
220-
221-
// 1. Create a copy of the base Application definition for the new resource
222-
newDef, err := DeepCopySchema(&appDef)
223-
if err != nil {
224-
return nil, fmt.Errorf("failed to deepcopy schema for %s: %w", gvk.Kind, err)
225-
}
226-
227-
// 2. Update x-kubernetes-group-version-kind to match the new resource
228-
if newDef.Extensions == nil {
229-
newDef.Extensions = map[string]interface{}{}
230-
}
231-
newDef.Extensions["x-kubernetes-group-version-kind"] = []map[string]interface{}{
232-
{
233-
"group": gvk.Group,
234-
"version": gvk.Version,
235-
"kind": gvk.Kind,
236-
},
237-
}
238-
239-
// make `.spec` schemaless so any keys are accepted
240-
if specProp, ok := newDef.Properties["spec"]; ok {
241-
specProp.AdditionalProperties = &spec.SchemaOrBool{
242-
Allows: true,
243-
Schema: &spec.Schema{},
244-
}
245-
newDef.Properties["spec"] = specProp
246-
}
247-
248-
// 3. Save the new resource definition under the correct name
249-
defs[resourceName] = *newDef
250-
klog.V(6).Infof("PostProcessSpec: Added OpenAPI definition for %s\n", resourceName)
251-
252-
// 4. Now handle the corresponding List type (e.g., BucketList).
253-
// We'll start by copying the ApplicationList definition.
254-
listResourceName := fmt.Sprintf("com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.%sList", gvk.Kind)
255-
newListDef, err := DeepCopySchema(&listDef)
256-
if err != nil {
257-
return nil, fmt.Errorf("failed to deepcopy schema for %sList: %w", gvk.Kind, err)
258-
}
259-
260-
// 5. Update x-kubernetes-group-version-kind for the List definition
261-
if newListDef.Extensions == nil {
262-
newListDef.Extensions = map[string]interface{}{}
263-
}
264-
newListDef.Extensions["x-kubernetes-group-version-kind"] = []map[string]interface{}{
265-
{
266-
"group": gvk.Group,
267-
"version": gvk.Version,
268-
"kind": fmt.Sprintf("%sList", gvk.Kind),
269-
},
270-
}
271-
272-
// 6. IMPORTANT: Fix the "items" reference so it points to the new resource
273-
// rather than to "Application".
274-
if itemsProp, found := newListDef.Properties["items"]; found {
275-
if itemsProp.Items != nil && itemsProp.Items.Schema != nil {
276-
itemsProp.Items.Schema.Ref = spec.MustCreateRef("#/definitions/" + resourceName)
277-
newListDef.Properties["items"] = itemsProp
278-
}
279-
}
280-
281-
// 7. Finally, save the new List definition
282-
defs[listResourceName] = *newListDef
283-
klog.V(6).Infof("PostProcessSpec: Added OpenAPI definition for %s\n", listResourceName)
284-
}
285-
286-
// Remove the original Application/ApplicationList from the definitions
287-
delete(defs, "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application")
288-
delete(defs, "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.ApplicationList")
289-
290-
swagger.Definitions = defs
291-
return swagger, nil
193+
// capture schemas from config once for fast lookup inside the closure
194+
kindSchemas := map[string]string{}
195+
for _, r := range o.ResourceConfig.Resources {
196+
kindSchemas[r.Application.Kind] = r.Application.OpenAPISchema
292197
}
293198

199+
serverConfig.OpenAPIConfig.Info.Title = "Apps"
200+
serverConfig.OpenAPIConfig.Info.Version = version
201+
serverConfig.OpenAPIConfig.PostProcessSpec = buildPostProcessV2(kindSchemas)
202+
294203
serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(
295204
sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme),
296205
)
297206
serverConfig.OpenAPIV3Config.Info.Title = "Apps"
298-
serverConfig.OpenAPIV3Config.Info.Version = "0.1"
207+
serverConfig.OpenAPIV3Config.Info.Version = version
208+
209+
serverConfig.OpenAPIV3Config.PostProcessSpec = buildPostProcessV3(kindSchemas)
299210

300211
serverConfig.FeatureGate = utilversionpkg.DefaultComponentGlobalsRegistry.FeatureGateFor(
301212
utilversionpkg.DefaultKubeComponent,

‎pkg/config/config.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ type Resource struct {
3636

3737
// ApplicationConfig contains the application settings.
3838
type ApplicationConfig struct {
39-
Kind string `yaml:"kind"`
40-
Singular string `yaml:"singular"`
41-
Plural string `yaml:"plural"`
42-
ShortNames []string `yaml:"shortNames"`
39+
Kind string `yaml:"kind"`
40+
Singular string `yaml:"singular"`
41+
Plural string `yaml:"plural"`
42+
ShortNames []string `yaml:"shortNames"`
43+
OpenAPISchema string `yaml:"openAPISchema"`
4344
}
4445

4546
// ReleaseConfig contains the release settings.

0 commit comments

Comments
 (0)