-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathopenapi.go
More file actions
430 lines (398 loc) · 11.5 KB
/
openapi.go
File metadata and controls
430 lines (398 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
package framework
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
k8syaml "sigs.k8s.io/yaml"
)
// OpenAPI helpers for the testing framework.
//
// Modules ship two OpenAPI v3 schemas in their `openapi/` directory:
//
// - config-values.yaml — describes the user-controllable module config;
// - values.yaml — extends config-values with system fields
// (typically `internal:` and `registry:`).
//
// In Deckhouse, addon-operator validates and applies defaults from these
// schemas before invoking a hook. Hook tests, however, run the handler
// directly with whatever values the test author passed. That makes it
// easy to forget that a real environment would have, for example,
// `https.mode = "Disabled"` set by default — and tests then drift from
// production behaviour.
//
// The helpers here close that gap. They:
//
// 1. read an OpenAPI v3 schema from a file path;
// 2. extract a values document populated with all `default:` values
// declared by the schema (recursively, including objects and arrays);
// 3. merge values supplied by the test on top of those defaults so the
// test's values override the schema-provided ones.
//
// The functions are intentionally lightweight: they manipulate the schema
// as a `map[string]any` and do not validate values. For full validation
// use the validators in `pkg/values/validation` (or the upstream
// addon-operator implementation).
// LoadOpenAPISchema reads an OpenAPI v3 schema from a YAML or JSON file
// and returns the parsed document.
//
// If the schema document declares the addon-operator x-extend extension,
// e.g.:
//
// x-extend:
// schema: config-values.yaml
//
// LoadOpenAPISchema also loads the referenced schema (resolved relative
// to the current schema's directory) and merges it as a parent: the
// parent's `properties`, `patternProperties`, `definitions`, `required`
// and extensions are folded into the current schema, with the current
// schema winning on conflicts. This mirrors the behaviour of
// addon-operator's ExtendTransformer.
//
// LoadOpenAPISchema does not resolve `$ref`s.
func LoadOpenAPISchema(path string) (map[string]any, error) {
return loadOpenAPISchemaWithStack(path, nil)
}
// loadOpenAPISchemaWithStack tracks already-visited paths to break
// pathological cycles in x-extend chains.
func loadOpenAPISchemaWithStack(path string, stack []string) (map[string]any, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("openapi: resolve %q: %w", path, err)
}
for _, prev := range stack {
if prev == abs {
return nil, fmt.Errorf("openapi: x-extend cycle detected at %q", abs)
}
}
data, err := os.ReadFile(abs)
if err != nil {
return nil, fmt.Errorf("openapi: read %q: %w", abs, err)
}
var doc map[string]any
if err := k8syaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("openapi: parse %q: %w", abs, err)
}
if doc == nil {
doc = map[string]any{}
}
parentPath, ok := extractExtendSchemaPath(doc)
if !ok {
return doc, nil
}
parentResolved := parentPath
if !filepath.IsAbs(parentResolved) {
parentResolved = filepath.Join(filepath.Dir(abs), parentResolved)
}
parent, err := loadOpenAPISchemaWithStack(parentResolved, append(stack, abs))
if err != nil {
return nil, fmt.Errorf("openapi: load x-extend parent %q: %w", parentPath, err)
}
mergeSchemaWithParent(doc, parent)
return doc, nil
}
// extractExtendSchemaPath reads the optional `x-extend.schema` value
// from a schema document and returns it.
func extractExtendSchemaPath(doc map[string]any) (string, bool) {
raw, ok := doc["x-extend"]
if !ok {
return "", false
}
settings, ok := raw.(map[string]any)
if !ok {
return "", false
}
schemaPath, ok := settings["schema"].(string)
if !ok || schemaPath == "" {
return "", false
}
return schemaPath, true
}
// mergeSchemaWithParent folds the parent schema's properties/required/etc.
// into the current schema, mirroring addon-operator's ExtendTransformer.
// The current schema wins on conflicts.
func mergeSchemaWithParent(current, parent map[string]any) {
current["properties"] = mergeSchemaMap(current["properties"], parent["properties"])
current["patternProperties"] = mergeSchemaMap(current["patternProperties"], parent["patternProperties"])
current["definitions"] = mergeSchemaMap(current["definitions"], parent["definitions"])
current["required"] = mergeRequired(current["required"], parent["required"])
if _, has := current["title"]; !has {
if title, ok := parent["title"].(string); ok && title != "" {
current["title"] = title
}
}
if _, has := current["description"]; !has {
if desc, ok := parent["description"].(string); ok && desc != "" {
current["description"] = desc
}
}
for k, v := range parent {
if k == "properties" || k == "patternProperties" || k == "definitions" ||
k == "required" || k == "title" || k == "description" || k == "x-extend" {
continue
}
if _, has := current[k]; has {
continue
}
// Only carry over OpenAPI extensions and a small set of known
// schema-level keys. We deliberately don't override `type`,
// `properties`, etc. that the current schema already declared.
if isExtension(k) {
current[k] = v
}
}
}
// mergeSchemaMap merges two map-shaped schema fields (e.g. `properties`).
// Keys present in `current` win.
func mergeSchemaMap(current, parent any) any {
out := map[string]any{}
if pm, ok := parent.(map[string]any); ok {
for k, v := range pm {
out[k] = v
}
}
if cm, ok := current.(map[string]any); ok {
for k, v := range cm {
out[k] = v
}
}
if len(out) == 0 {
// Preserve "field absent" instead of writing back an empty map.
if current == nil && parent == nil {
return nil
}
}
return out
}
// mergeRequired deduplicates two `required:` lists (parent first).
func mergeRequired(current, parent any) any {
pSlice := toStringSlice(parent)
cSlice := toStringSlice(current)
if len(pSlice) == 0 && len(cSlice) == 0 {
if current == nil && parent == nil {
return nil
}
return []any{}
}
seen := make(map[string]struct{}, len(pSlice)+len(cSlice))
out := make([]any, 0, len(pSlice)+len(cSlice))
for _, name := range pSlice {
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
for _, name := range cSlice {
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
func toStringSlice(v any) []string {
switch s := v.(type) {
case []string:
return s
case []any:
out := make([]string, 0, len(s))
for _, item := range s {
if str, ok := item.(string); ok {
out = append(out, str)
}
}
return out
default:
return nil
}
}
func isExtension(key string) bool {
return len(key) > 2 && key[0] == 'x' && key[1] == '-'
}
// SchemaDefaults walks an OpenAPI schema (as returned by LoadOpenAPISchema)
// and produces a values document populated with the schema's `default:`
// values.
//
// The algorithm mirrors addon-operator's defaulting:
//
// - A property's `default:` (if any) is used as the starting value.
// - For object-typed properties, sub-properties' defaults are then
// applied for any keys the explicit `default:` left empty.
// - For arrays, items' defaults are applied to each existing item of
// the explicit `default:` list (item entries themselves are never
// synthesised from `items.default`).
//
// SchemaDefaults always returns a non-nil map (possibly empty).
func SchemaDefaults(schema map[string]any) map[string]any {
out, _ := schemaDefaults(schema)
if out == nil {
return map[string]any{}
}
m, ok := out.(map[string]any)
if !ok {
return map[string]any{}
}
return m
}
// schemaDefaults returns (defaultValue, hasDefault). It is recursive and
// works on any subschema, not just the top-level object.
func schemaDefaults(schema map[string]any) (any, bool) {
if schema == nil {
return nil, false
}
rawDef, hasDef := schema["default"]
var base any
if hasDef {
base = deepCopyJSONValue(rawDef)
}
t, _ := schema["type"].(string)
_, hasProps := schema["properties"]
if t == "" && hasProps {
t = "object"
}
switch t {
case "object":
baseMap, _ := base.(map[string]any)
if hasDef && base != nil && baseMap == nil {
// The default is a non-object value (e.g. null) for an
// object-typed property — leave it alone.
return base, true
}
if baseMap == nil {
baseMap = map[string]any{}
}
props, _ := schema["properties"].(map[string]any)
for name, raw := range props {
sub, ok := raw.(map[string]any)
if !ok {
continue
}
subDefault, has := schemaDefaults(sub)
if !has {
continue
}
existing, exists := baseMap[name]
if !exists {
baseMap[name] = subDefault
continue
}
// Both sides exist. If both are maps, the explicit
// default's entries win on conflict; otherwise leave
// the explicit default untouched.
if eMap, eOk := existing.(map[string]any); eOk {
if subMap, sOk := subDefault.(map[string]any); sOk {
baseMap[name] = MergeValues(subMap, eMap)
}
}
}
if hasDef || len(baseMap) > 0 {
return baseMap, true
}
return nil, false
case "array":
// We only recurse into existing array entries (those provided
// by the explicit default). We never create new entries from
// `items.default` alone.
if !hasDef {
return nil, false
}
list, ok := base.([]any)
if !ok {
return base, true
}
items, _ := schema["items"].(map[string]any)
if items == nil {
return list, true
}
for i, item := range list {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemDefaults, has := schemaDefaults(items)
if !has {
continue
}
itemMapDefaults, ok := itemDefaults.(map[string]any)
if !ok {
continue
}
list[i] = MergeValues(itemMapDefaults, itemMap)
}
return list, true
default:
if hasDef {
return base, true
}
return nil, false
}
}
// MergeValues deep-merges override into base and returns the result.
//
// Object-typed values are merged property-by-property (recursing). For
// arrays and scalar values the override replaces the base entirely.
//
// Neither input is modified.
//
// MergeValues is the natural counterpart to SchemaDefaults: combine
// defaults extracted from a schema with values supplied by the test,
// letting the test's values win on every conflict.
func MergeValues(base, override map[string]any) map[string]any {
out := deepCopyJSONMap(base)
if out == nil {
out = map[string]any{}
}
for k, v := range override {
if existing, ok := out[k]; ok {
if eMap, eOk := existing.(map[string]any); eOk {
if vMap, vOk := v.(map[string]any); vOk {
out[k] = MergeValues(eMap, vMap)
continue
}
}
}
out[k] = deepCopyJSONValue(v)
}
return out
}
// deepCopyJSONValue returns a deep copy of a JSON-compatible value.
// Maps and slices are cloned recursively; other values are returned as-is
// (they are immutable in JSON semantics).
func deepCopyJSONValue(v any) any {
switch t := v.(type) {
case map[string]any:
return deepCopyJSONMap(t)
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = deepCopyJSONValue(item)
}
return out
default:
return v
}
}
func deepCopyJSONMap(in map[string]any) map[string]any {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = deepCopyJSONValue(v)
}
return out
}
// fileExists returns true if path refers to an existing regular file.
func fileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false
}
return false
}
return !info.IsDir()
}