Repository: kubernetes-client/javascript
Pull request #2520 (2025-06-25 09:25:20)

fix: make yaml functions use serializer
Fixes #2483 

This PR changes the implementation of the `yaml`-related functions, to make them serialize/deserialize the objects.




Patch:
diff --git a/src/object.ts b/src/object.ts
index 0548b03a..7761cd26 100644
--- a/src/object.ts
+++ b/src/object.ts
@@ -15,6 +15,7 @@ import { ObjectSerializer } from './serializer.js';
 import { KubernetesListObject, KubernetesObject } from './types.js';
 import { from, mergeMap, of } from './gen/rxjsStub.js';
 import { PatchStrategy } from './patch.js';
+import { getSerializationType } from './util.js';
 
 /** Kubernetes API verbs. */
 type KubernetesApiAction = 'create' | 'delete' | 'patch' | 'read' | 'list' | 'replace';
@@ -29,11 +30,6 @@ type KubernetesObjectHeader<T extends KubernetesObject | KubernetesObject> = Pic
     };
 };
 
-interface GroupVersion {
-    group: string;
-    version: string;
-}
-
 /**
  * Dynamically construct Kubernetes API request URIs so client does not have to know what type of object it is acting
  * on.
@@ -107,7 +103,7 @@ export class KubernetesObjectApi {
         if (fieldManager !== undefined) {
             requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string'));
         }
-        const type = await this.getSerializationType(spec.apiVersion, spec.kind);
+        const type = getSerializationType(spec.apiVersion, spec.kind);
 
         // Body Params
         const contentType = ObjectSerializer.getPreferredMediaType([]);
@@ -269,7 +265,7 @@ export class KubernetesObjectApi {
             requestContext.setQueryParam('force', ObjectSerializer.serialize(force, 'boolean'));
         }
 
-        const type = await this.getSerializationType(spec.apiVersion, spec.kind);
+        const type = getSerializationType(spec.apiVersion, spec.kind);
 
         // Body Params
         const serializedBody = ObjectSerializer.stringify(
@@ -468,7 +464,7 @@ export class KubernetesObjectApi {
             requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string'));
         }
 
-        const type = await this.getSerializationType(spec.apiVersion, spec.kind);
+        const type = getSerializationType(spec.apiVersion, spec.kind);
 
         // Body Params
         const contentType = ObjectSerializer.getPreferredMediaType([]);
@@ -593,30 +589,6 @@ export class KubernetesObjectApi {
         }
     }
 
-    protected async getSerializationType(apiVersion?: string, kind?: string): Promise<string> {
-        if (apiVersion === undefined || kind === undefined) {
-            return 'KubernetesObject';
-        }
-        // Types are defined in src/gen/api/models with the format "<Version><Kind>".
-        // Version and Kind are in PascalCase.
-        const gv = this.groupVersion(apiVersion);
-        const version = gv.version.charAt(0).toUpperCase() + gv.version.slice(1);
-        return `${version}${kind}`;
-    }
-
-    protected groupVersion(apiVersion: string): GroupVersion {
-        const v = apiVersion.split('/');
-        return v.length === 1
-            ? {
-                  group: 'core',
-                  version: apiVersion,
-              }
-            : {
-                  group: v[0],
-                  version: v[1],
-              };
-    }
-
     protected async requestPromise<T extends KubernetesObject | KubernetesObject>(
         requestContext: RequestContext,
         type?: string,
@@ -670,7 +642,7 @@ export class KubernetesObjectApi {
         if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) {
             const data = ObjectSerializer.parse(await response.body.text(), contentType);
             if (type === undefined) {
-                type = await this.getSerializationType(data.apiVersion, data.kind);
+                type = getSerializationType(data.apiVersion, data.kind);
             }
 
             if (!type) {
diff --git a/src/util.ts b/src/util.ts
index 791d89c7..e502e600 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -164,3 +164,32 @@ export function normalizeResponseHeaders(response: Response): { [key: string]: s
 
     return normalizedHeaders;
 }
+
+export function getSerializationType(apiVersion?: string, kind?: string): string {
+    if (apiVersion === undefined || kind === undefined) {
+        return 'KubernetesObject';
+    }
+    // Types are defined in src/gen/api/models with the format "<Version><Kind>".
+    // Version and Kind are in PascalCase.
+    const gv = groupVersion(apiVersion);
+    const version = gv.version.charAt(0).toUpperCase() + gv.version.slice(1);
+    return `${version}${kind}`;
+}
+
+interface GroupVersion {
+    group: string;
+    version: string;
+}
+
+function groupVersion(apiVersion: string): GroupVersion {
+    const v = apiVersion.split('/');
+    return v.length === 1
+        ? {
+              group: 'core',
+              version: apiVersion,
+          }
+        : {
+              group: v[0],
+              version: v[1],
+          };
+}
diff --git a/src/yaml.ts b/src/yaml.ts
index 7682392a..09468a3e 100644
--- a/src/yaml.ts
+++ b/src/yaml.ts
@@ -1,13 +1,48 @@
 import yaml from 'js-yaml';
+import { getSerializationType } from './util.js';
+import { KubernetesObject } from './types.js';
+import { ObjectSerializer } from './serializer.js';
 
+/**
+ * Load a Kubernetes object from YAML.
+ * @param data - The YAML string to load.
+ * @param opts - Optional YAML load options.
+ * @returns The deserialized Kubernetes object.
+ */
 export function loadYaml<T>(data: string, opts?: yaml.LoadOptions): T {
-    return yaml.load(data, opts) as any as T;
+    const yml = yaml.load(data, opts) as any as KubernetesObject;
+    if (!yml) {
+        throw new Error('Failed to load YAML');
+    }
+    const type = getSerializationType(yml.apiVersion, yml.kind);
+
+    return ObjectSerializer.deserialize(yml, type) as T;
 }
 
+/**
+ * Load all Kubernetes objects from YAML.
+ * @param data - The YAML string to load.
+ * @param opts - Optional YAML load options.
+ * @returns An array of deserialized Kubernetes objects.
+ */
 export function loadAllYaml(data: string, opts?: yaml.LoadOptions): any[] {
-    return yaml.loadAll(data, undefined, opts);
+    const ymls = yaml.loadAll(data, undefined, opts);
+    return ymls.map((yml) => {
+        const obj = yml as KubernetesObject;
+        const type = getSerializationType(obj.apiVersion, obj.kind);
+        return ObjectSerializer.deserialize(yml, type);
+    });
 }
 
+/**
+ * Dump a Kubernetes object to YAML.
+ * @param object - The Kubernetes object to dump.
+ * @param opts - Optional YAML dump options.
+ * @returns The YAML string representation of the serialized Kubernetes object.
+ */
 export function dumpYaml(object: any, opts?: yaml.DumpOptions): string {
-    return yaml.dump(object, opts);
+    const kubeObject = object as KubernetesObject;
+    const type = getSerializationType(kubeObject.apiVersion, kubeObject.kind);
+    const serialized = ObjectSerializer.serialize(kubeObject, type);
+    return yaml.dump(serialized, opts);
 }


Test patch:
diff --git a/src/util_test.ts b/src/util_test.ts
index dd1061f8..74a323a8 100644
--- a/src/util_test.ts
+++ b/src/util_test.ts
@@ -9,6 +9,7 @@ import {
     quantityToScalar,
     totalCPU,
     totalMemory,
+    getSerializationType,
 } from './util.js';
 
 describe('Utils', () => {
@@ -142,4 +143,10 @@ describe('Utils', () => {
 
         deepStrictEqual(normalizeResponseHeaders(response), { foo: 'bar', baz: 'k8s' });
     });
+
+    it('should get the serialization type correctly', () => {
+        strictEqual(getSerializationType('v1', 'Pod'), 'V1Pod');
+        strictEqual(getSerializationType('apps/v1', 'Deployment'), 'V1Deployment');
+        strictEqual(getSerializationType(undefined, undefined), 'KubernetesObject');
+    });
 });
diff --git a/src/yaml_test.ts b/src/yaml_test.ts
index ba53293d..958f7ba4 100644
--- a/src/yaml_test.ts
+++ b/src/yaml_test.ts
@@ -1,6 +1,6 @@
 import { describe, it } from 'node:test';
-import { deepStrictEqual, strictEqual } from 'node:assert';
-import { V1Namespace } from './api.js';
+import { deepEqual, deepStrictEqual, strictEqual } from 'node:assert';
+import { V1CustomResourceDefinition, V1Namespace } from './api.js';
 import { dumpYaml, loadAllYaml, loadYaml } from './yaml.js';
 
 describe('yaml', () => {
@@ -12,6 +12,43 @@ describe('yaml', () => {
         strictEqual(ns.kind, 'Namespace');
         strictEqual(ns.metadata!.name, 'some-namespace');
     });
+    it('should load safely by mapping properties correctly', () => {
+        const yaml = `
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+    name: my-crd.example.com
+spec:
+  group: example.com
+  versions:
+    - name: v1
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            foobar:
+                anyOf:
+                - type: integer
+                - type: string
+                x-kubernetes-int-or-string: true
+`;
+        const ns = loadYaml<V1CustomResourceDefinition>(yaml);
+
+        strictEqual(ns.apiVersion, 'apiextensions.k8s.io/v1');
+        strictEqual(ns.kind, 'CustomResourceDefinition');
+        strictEqual(ns.metadata!.name, 'my-crd.example.com');
+        strictEqual(
+            ns.spec.versions[0]!.schema!.openAPIV3Schema!.properties!['foobar'].x_kubernetes_int_or_string,
+            true,
+        );
+        strictEqual(
+            ns.spec.versions[0]!.schema!.openAPIV3Schema!.properties!['foobar']['x-kubernetes-int-or-string'],
+            undefined,
+        );
+    });
+
     it('should load all safely', () => {
         const yaml =
             'apiVersion: v1\n' +
@@ -23,15 +60,49 @@ describe('yaml', () => {
             'kind: Pod\n' +
             'metadata:\n' +
             '  name: some-pod\n' +
-            '  namespace: some-ns\n';
+            '  namespace: some-ns\n' +
+            '---\n' +
+            'apiVersion: apiextensions.k8s.io/v1\n' +
+            'kind: CustomResourceDefinition\n' +
+            'metadata:\n' +
+            '  name: my-crd.example.com\n' +
+            'spec:\n' +
+            '  group: example.com\n' +
+            '  versions:\n' +
+            '    - name: v1\n' +
+            '      served: true\n' +
+            '      storage: true\n' +
+            '      schema:\n' +
+            '        openAPIV3Schema:\n' +
+            '          type: object\n' +
+            '          properties:\n' +
+            '            foobar:\n' +
+            '              anyOf:\n' +
+            '              - type: integer\n' +
+            '              - type: string\n' +
+            '              x-kubernetes-int-or-string: true\n';
         const objects = loadAllYaml(yaml);
 
-        strictEqual(objects.length, 2);
+        strictEqual(objects.length, 3);
         strictEqual(objects[0].kind, 'Namespace');
         strictEqual(objects[1].kind, 'Pod');
         strictEqual(objects[0].metadata.name, 'some-namespace');
         strictEqual(objects[1].metadata.name, 'some-pod');
         strictEqual(objects[1].metadata.namespace, 'some-ns');
+        strictEqual(objects[2].apiVersion, 'apiextensions.k8s.io/v1');
+        strictEqual(objects[2].kind, 'CustomResourceDefinition');
+        strictEqual(objects[2].metadata!.name, 'my-crd.example.com');
+        strictEqual(
+            objects[2].spec.versions[0]!.schema!.openAPIV3Schema!.properties!['foobar']
+                .x_kubernetes_int_or_string,
+            true,
+        );
+        strictEqual(
+            objects[2].spec.versions[0]!.schema!.openAPIV3Schema!.properties!['foobar'][
+                'x-kubernetes-int-or-string'
+            ],
+            undefined,
+        );
     });
     it('should round trip successfully', () => {
         const expected = {
@@ -43,4 +114,44 @@ describe('yaml', () => {
         const actual = loadYaml(yamlString);
         deepStrictEqual(actual, expected);
     });
+
+    it('should round trip successfully with mapped properties', () => {
+        const expected: V1CustomResourceDefinition = {
+            apiVersion: 'apiextensions.k8s.io/v1',
+            kind: 'CustomResourceDefinition',
+            metadata: {
+                name: 'my-crd.example.com',
+            },
+            spec: {
+                group: 'example.com',
+                names: {
+                    kind: 'MyCRD',
+                    plural: 'mycrds',
+                },
+                scope: 'Namespaced',
+                versions: [
+                    {
+                        name: 'v1',
+                        schema: {
+                            openAPIV3Schema: {
+                                properties: {
+                                    foobar: {
+                                        anyOf: [{ type: 'integer' }, { type: 'string' }],
+                                        x_kubernetes_int_or_string: true,
+                                    },
+                                },
+                                type: 'object',
+                            },
+                        },
+                        served: true,
+                        storage: true,
+                    },
+                ],
+            },
+        };
+        const yamlString = dumpYaml(expected);
+        const actual = loadYaml(yamlString);
+        // not using strict equality as types are not matching
+        deepEqual(actual, expected);
+    });
 });